Compare commits
13
Commits
62365568e9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4efe6b192 | ||
|
|
ecdbc0b281 | ||
|
|
b17cc3e9c8 | ||
|
|
effe76d83f | ||
|
|
e7a4613bac | ||
|
|
1fee3e5461 | ||
|
|
0732dfddef | ||
|
|
ab8ddc8354 | ||
|
|
e69455fbfc | ||
|
|
1be1b00511 | ||
|
|
babfc71f13 | ||
|
|
ae49b95e0f | ||
|
|
33b39006c0 |
@@ -64,3 +64,8 @@ Thumbs.db
|
||||
|
||||
# fast-deploy build artifact
|
||||
/frontend/dist.tar.gz
|
||||
|
||||
# ad-hoc probe/test scratch files (cookies, tokens, live probes)
|
||||
_tmp_*
|
||||
_arp_*.txt
|
||||
zz_live_probe_test.go
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
ID string
|
||||
Pool string
|
||||
Status string
|
||||
AccountEmail string
|
||||
ImageLimited bool
|
||||
VideoLimited bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := os.Getenv("POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
fmt.Println("POSTGRES_DSN env is required, e.g. host=127.0.0.1 user=postgres password=... dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai")
|
||||
os.Exit(1)
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
fmt.Println("open err:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var pick row
|
||||
db.Raw(`SELECT id, pool, status, account_email, image_limited, video_limited FROM token_accounts WHERE pool='adobe' ORDER BY id LIMIT 1`).Scan(&pick)
|
||||
fmt.Printf("picked: id=%s email=%s status=%s image_limited=%v video_limited=%v\n", pick.ID, pick.AccountEmail, pick.Status, pick.ImageLimited, pick.VideoLimited)
|
||||
|
||||
if err := db.Exec(`UPDATE token_accounts SET video_limited=true, updated_at=now() WHERE id=?`, pick.ID).Error; err != nil {
|
||||
fmt.Println("update err:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("-> set video_limited=true")
|
||||
|
||||
var after row
|
||||
db.Raw(`SELECT id, pool, status, account_email, image_limited, video_limited FROM token_accounts WHERE id=?`, pick.ID).Scan(&after)
|
||||
fmt.Printf("after: id=%s status=%s image_limited=%v video_limited=%v\n", after.ID, after.Status, after.ImageLimited, after.VideoLimited)
|
||||
}
|
||||
@@ -5,25 +5,16 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"backend/internal/config"
|
||||
"backend/internal/http/handler"
|
||||
"backend/internal/http/router"
|
||||
"backend/internal/model"
|
||||
"backend/internal/provider/adobe"
|
||||
"backend/internal/provider/chatgpt"
|
||||
"backend/internal/provider/custom"
|
||||
"backend/internal/provider/creativefabrica"
|
||||
"backend/internal/provider/grok"
|
||||
"backend/internal/provider/imagine"
|
||||
"backend/internal/provider/krea"
|
||||
"backend/internal/provider/leonardo"
|
||||
"backend/internal/provider/runway"
|
||||
"backend/internal/repo"
|
||||
"backend/internal/service"
|
||||
"backend/internal/storage"
|
||||
"backend/internal/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -51,9 +42,9 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
return nil, fmt.Errorf("create generated root %s: %w", cfg.GeneratedRoot, err)
|
||||
}
|
||||
|
||||
// TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成 gorm.ErrDuplicatedKey,
|
||||
// 否则各 import-*(krea/adobe/leonardo/runway)里的 errors.Is(err, gorm.ErrDuplicatedKey)
|
||||
// 兜底命不中,重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。
|
||||
// TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成
|
||||
// gorm.ErrDuplicatedKey,否则 errors.Is(err, gorm.ErrDuplicatedKey) 兜底命不中,
|
||||
// 重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。
|
||||
db, err := gorm.Open(postgres.Open(cfg.PostgresDSN), &gorm.Config{TranslateError: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres: %w", err)
|
||||
@@ -97,8 +88,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
eventRepo := repo.NewEventRepository(db)
|
||||
cdkRepo := repo.NewCDKRepository(db)
|
||||
apiKeyRepo := repo.NewAPIKeyRepository(db)
|
||||
tokenRepo := repo.NewTokenRepository(db)
|
||||
refreshRepo := repo.NewRefreshProfileRepository(db)
|
||||
channelRepo := repo.NewChannelRepository(db)
|
||||
cgroupRepo := repo.NewConcurrencyGroupRepository(db)
|
||||
// Seed the "默认并发" group (cap 10) and bind any ungrouped users to it.
|
||||
if err := cgroupRepo.EnsureDefault(ctx); err != nil {
|
||||
@@ -117,32 +107,21 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
authSvc := service.NewAuthService(userRepo, siteRepo, sessionSvc, emailCodeSvc, smtpSvc, cgroupRepo)
|
||||
appSettingsSvc := service.NewAppSettingsService(siteRepo, eventRepo, smtpSvc, rustfsClient)
|
||||
imageAccessSvc := service.NewImageAccessService(cfg.GeneratedRoot, showcaseRepo, authSvc)
|
||||
adobeClient := adobe.NewClient("projectx_webapp", "")
|
||||
chatGPTClient := chatgpt.NewClient("")
|
||||
runwayClient := runway.NewClient("")
|
||||
leonardoClient := leonardo.NewClient("")
|
||||
kreaClient := krea.NewClient("")
|
||||
imagineClient := imagine.NewClient("")
|
||||
grokClient := grok.NewClient("")
|
||||
// Keep grok's x-statsig-id anti-bot recipe current by reading grok's own
|
||||
// headless-browser signer output. Event-driven: seed from the persisted
|
||||
// recipe, capture once at startup, then re-capture only on an anti-bot 403
|
||||
// (a reship made the recipe stale). No polling.
|
||||
startGrokStatsigRefresh(siteRepo)
|
||||
customClient := custom.NewClient()
|
||||
cfClient := creativefabrica.NewClient("")
|
||||
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, cfClient, rustfsClient)
|
||||
upClient := upstream.NewClient()
|
||||
channelSecret := cfg.ChannelSecret
|
||||
if channelSecret == "" {
|
||||
// Stable fallback so a restarted process can still decrypt existing
|
||||
// rows when CHANNEL_SECRET isn't set.
|
||||
channelSecret = cfg.PostgresDSN
|
||||
}
|
||||
channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret)
|
||||
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
|
||||
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
||||
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
||||
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient, showcaseRepo)
|
||||
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo, orderRepo)
|
||||
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, channelRepo, cdkRepo, rustfsClient, showcaseRepo)
|
||||
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, channelRepo, orderRepo)
|
||||
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo, orderRepo)
|
||||
apiKeySvc := service.NewAPIKeyService(apiKeyRepo)
|
||||
tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, cfClient)
|
||||
refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient)
|
||||
// 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.
|
||||
v1Svc.SetRefresh(refreshSvc)
|
||||
bannedWordRepo := repo.NewBannedWordRepository(db)
|
||||
v1Svc.SetBannedWords(bannedWordRepo)
|
||||
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
|
||||
@@ -150,30 +129,30 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
canvasSvc := service.NewCanvasService(canvasRepo)
|
||||
|
||||
engine := router.New(cfg, authSvc, router.Handlers{
|
||||
Health: handler.NewHealthHandler(),
|
||||
Images: handler.NewImageHandler(cfg, imageAccessSvc, rustfsClient),
|
||||
V1: handler.NewV1Handler(v1Svc),
|
||||
Site: handler.NewSiteHandler(siteSvc),
|
||||
Showcase: handler.NewShowcaseHandler(showcaseSvc),
|
||||
Auth: handler.NewAuthHandler(cfg, authSvc, rateLimitSvc),
|
||||
SiteSettings: handler.NewSiteSettingsHandler(siteSvc),
|
||||
AppSettings: handler.NewAppSettingsHandler(appSettingsSvc),
|
||||
AdminRead: handler.NewAdminReadHandler(adminReadSvc),
|
||||
AdminWrite: handler.NewAdminWriteHandler(adminWriteSvc),
|
||||
CDK: handler.NewCDKHandler(cdkSvc),
|
||||
UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc),
|
||||
UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc),
|
||||
ProviderAdmin: handler.NewProviderAdminHandler(tokenSvc, refreshSvc),
|
||||
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
|
||||
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
||||
Payment: handler.NewPaymentHandler(paymentSvc),
|
||||
BannedWords: handler.NewBannedWordsHandler(bannedWordRepo),
|
||||
Canvas: handler.NewCanvasHandler(canvasSvc),
|
||||
Health: handler.NewHealthHandler(),
|
||||
Images: handler.NewImageHandler(cfg, imageAccessSvc, rustfsClient),
|
||||
V1: handler.NewV1Handler(v1Svc),
|
||||
Site: handler.NewSiteHandler(siteSvc),
|
||||
Showcase: handler.NewShowcaseHandler(showcaseSvc),
|
||||
Auth: handler.NewAuthHandler(cfg, authSvc, rateLimitSvc),
|
||||
SiteSettings: handler.NewSiteSettingsHandler(siteSvc),
|
||||
AppSettings: handler.NewAppSettingsHandler(appSettingsSvc),
|
||||
AdminRead: handler.NewAdminReadHandler(adminReadSvc),
|
||||
AdminWrite: handler.NewAdminWriteHandler(adminWriteSvc),
|
||||
CDK: handler.NewCDKHandler(cdkSvc),
|
||||
UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc),
|
||||
UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc),
|
||||
ChannelAdmin: handler.NewChannelAdminHandler(channelSvc),
|
||||
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
|
||||
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
||||
Payment: handler.NewPaymentHandler(paymentSvc),
|
||||
BannedWords: handler.NewBannedWordsHandler(bannedWordRepo),
|
||||
Canvas: handler.NewCanvasHandler(canvasSvc),
|
||||
})
|
||||
|
||||
// Background self-healing sweep (quota recovery, cookie refresh, stale-pending
|
||||
// cleanup, log retention) — the Go equivalent of the Python daemon thread.
|
||||
maintenanceSvc := service.NewMaintenanceService(tokenRepo, tokenSvc, eventRepo, userRepo, refreshSvc, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo, orderRepo)
|
||||
// Background self-healing sweep (order expiry, stale-pending cleanup+refund,
|
||||
// log/media retention).
|
||||
maintenanceSvc := service.NewMaintenanceService(eventRepo, userRepo, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo, orderRepo)
|
||||
loopCtx, loopCancel := context.WithCancel(context.Background())
|
||||
go maintenanceSvc.Run(loopCtx)
|
||||
|
||||
@@ -186,33 +165,6 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// startGrokStatsigRefresh wires grok's headless x-statsig-id refresher to the
|
||||
// site-settings store (persisted across restarts) with an app-lifetime context.
|
||||
func startGrokStatsigRefresh(siteRepo *repo.SiteSettingRepository) {
|
||||
const kHeader, kSuffix, kTrailer = "grok.statsig.header", "grok.statsig.suffix", "grok.statsig.trailer"
|
||||
grok.StartStatsigAutoRefresh(context.Background(), 0,
|
||||
func(ctx context.Context) (string, string, int, bool) {
|
||||
h, _ := siteRepo.GetValue(ctx, kHeader)
|
||||
s, _ := siteRepo.GetValue(ctx, kSuffix)
|
||||
tv, _ := siteRepo.GetValue(ctx, kTrailer)
|
||||
if h == "" || s == "" {
|
||||
return "", "", 0, false
|
||||
}
|
||||
t, _ := strconv.Atoi(tv)
|
||||
return h, s, t, true
|
||||
},
|
||||
func(ctx context.Context, h, s string, t int) {
|
||||
if err := siteRepo.UpsertValues(ctx, map[string]string{
|
||||
kHeader: h,
|
||||
kSuffix: s,
|
||||
kTrailer: strconv.Itoa(t),
|
||||
}); err != nil {
|
||||
log.Printf("grok statsig: persist recipe failed: %v", err)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) Close() error {
|
||||
if a.maintenanceCancel != nil {
|
||||
a.maintenanceCancel()
|
||||
|
||||
@@ -26,6 +26,9 @@ type Config struct {
|
||||
RustFSBucket string
|
||||
RustFSAccessKey string
|
||||
RustFSSecretKey string
|
||||
// ChannelSecret encrypts channel upstream API keys at rest (AES-GCM).
|
||||
// Optional — when empty a stable key is derived from the Postgres DSN.
|
||||
ChannelSecret string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -60,6 +63,7 @@ func Load() (*Config, error) {
|
||||
RustFSBucket: envString("RUSTFS_BUCKET", ""),
|
||||
RustFSAccessKey: envString("RUSTFS_ACCESS_KEY", ""),
|
||||
RustFSSecretKey: envString("RUSTFS_SECRET_KEY", ""),
|
||||
ChannelSecret: envString("CHANNEL_SECRET", ""),
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"backend/internal/service"
|
||||
"backend/internal/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ChannelAdminHandler exposes channel CRUD + upstream model import under
|
||||
// /admin/api/channels*. Replaces the old token/account admin surface.
|
||||
type ChannelAdminHandler struct {
|
||||
channels *service.ChannelService
|
||||
}
|
||||
|
||||
func NewChannelAdminHandler(channels *service.ChannelService) *ChannelAdminHandler {
|
||||
return &ChannelAdminHandler{channels: channels}
|
||||
}
|
||||
|
||||
func (h *ChannelAdminHandler) List(c *gin.Context) {
|
||||
data, err := h.channels.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load channels"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func (h *ChannelAdminHandler) Save(c *gin.Context) {
|
||||
var body service.SaveChannelDto
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
body.BaseURL = strings.TrimSpace(body.BaseURL)
|
||||
ch, err := h.channels.Save(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": ch.ID})
|
||||
}
|
||||
|
||||
func (h *ChannelAdminHandler) Delete(c *gin.Context) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
|
||||
return
|
||||
}
|
||||
if err := h.channels.Delete(c.Request.Context(), body.ID); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// FetchModels proxies GET /v1/models on a channel (stored creds or ad-hoc
|
||||
// base_url+api_key for a not-yet-saved channel form).
|
||||
func (h *ChannelAdminHandler) FetchModels(c *gin.Context) {
|
||||
var body service.FetchModelsDto
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
items, err := h.channels.FetchModels(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
writeUpstreamErr(c, err)
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []upstream.RemoteModel{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// ImportModels binds the ticked upstream models to the channel (creating local
|
||||
// model rows on first import).
|
||||
func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Models []service.ChannelModelSpec `json:"models"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
|
||||
return
|
||||
}
|
||||
if err := h.channels.ImportModels(c.Request.Context(), body.ID, body.Models); err != nil {
|
||||
writeUpstreamErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// writeUpstreamErr translates upstream-protocol errors into sensible admin
|
||||
// responses (auth → 400 with the upstream's message, temporary → 502).
|
||||
func writeUpstreamErr(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, upstream.ErrAuth):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "上游鉴权失败:" + err.Error()})
|
||||
case errors.Is(err, upstream.ErrInvalid):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": "上游请求失败:" + err.Error()})
|
||||
}
|
||||
}
|
||||
@@ -1,534 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProviderAdminHandler struct {
|
||||
tokens *service.TokenService
|
||||
refresh *service.RefreshProfileService
|
||||
}
|
||||
|
||||
func NewProviderAdminHandler(tokens *service.TokenService, refresh *service.RefreshProfileService) *ProviderAdminHandler {
|
||||
return &ProviderAdminHandler{
|
||||
tokens: tokens,
|
||||
refresh: refresh,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokensList(c *gin.Context) {
|
||||
data, err := h.tokens.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load tokens"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokensCreate(c *gin.Context) {
|
||||
var body struct {
|
||||
Pool string `json:"pool"`
|
||||
Value string `json:"value"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.tokens.Add(c.Request.Context(), body.Pool, body.Value, body.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportChatGPTToken(c *gin.Context) {
|
||||
var body struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Value string `json:"value"`
|
||||
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
|
||||
if token == "" {
|
||||
token = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportChatGPTToken(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) ImportRunwayToken(c *gin.Context) {
|
||||
var body struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Value string `json:"value"`
|
||||
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
|
||||
if token == "" {
|
||||
token = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportRunwayToken(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) 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) ImportCustomAccount(c *gin.Context) {
|
||||
var body struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
URL string `json:"url"`
|
||||
Key string `json:"key"`
|
||||
APIKey string `json:"api_key"`
|
||||
Models string `json:"models"`
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
baseURL := body.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = body.URL
|
||||
}
|
||||
key := body.Key
|
||||
if key == "" {
|
||||
key = body.APIKey
|
||||
}
|
||||
item, err := h.tokens.ImportCustomAccount(c.Request.Context(), baseURL, key, body.Models, body.Name, body.Weight, body.Concurrency, body.ID)
|
||||
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})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportKreaCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
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
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportKreaCookie(c.Request.Context(), cookie, 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) ImportImagineToken(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
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
|
||||
}
|
||||
cred := body.Cookie
|
||||
if cred == "" {
|
||||
cred = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportImagineToken(c.Request.Context(), cred, 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) ImportLeonardoCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
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
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportLeonardoCookie(c.Request.Context(), cookie, 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) ImportAdobeCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
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
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, profile, err := h.tokens.ImportAdobeCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"profile_id": profile.ID,
|
||||
"id": item.ID,
|
||||
"status": item.Status,
|
||||
"pending": item.Status == "pending",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportCreativeFabricaCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
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
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportCreativeFabricaCookie(c.Request.Context(), cookie, 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) TokenUpdate(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.tokens.Update(c.Request.Context(), c.Param("pool"), c.Param("id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokenDelete(c *gin.Context) {
|
||||
if err := h.tokens.Delete(c.Request.Context(), c.Param("pool"), c.Param("id")); err != nil {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "token not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// TokenDeleteBulk removes multiple accounts in one call (account multi-select).
|
||||
func (h *ProviderAdminHandler) TokenDeleteBulk(c *gin.Context) {
|
||||
var body struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if len(body.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "未选择任何账号"})
|
||||
return
|
||||
}
|
||||
n, err := h.tokens.DeleteBulk(c.Request.Context(), body.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": n})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
|
||||
data, err := h.tokens.Accounts(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load accounts"})
|
||||
return
|
||||
}
|
||||
|
||||
// KPI stats are computed over the FULL set (每个类型的 成功/失败/限额), independent
|
||||
// of the current filter/page — mirrors the old client-side `stats` computed.
|
||||
stats := accountsStats(data)
|
||||
|
||||
// Server-side filtering (类型 / 状态 / 搜索 邮箱·ID·类型 / dead) so pagination is
|
||||
// correct across pages. ?dead=1 returns only 异常(已失效) accounts — used by
|
||||
// 「删除异常账号」to collect every dead id regardless of the current page.
|
||||
typeFilter := strings.TrimSpace(c.Query("type"))
|
||||
statusFilter := strings.TrimSpace(c.Query("status"))
|
||||
planFilter := strings.TrimSpace(c.Query("plan"))
|
||||
deadOnly := c.Query("dead") == "1"
|
||||
q := strings.ToLower(strings.TrimSpace(c.Query("q")))
|
||||
filtered := make([]map[string]any, 0, len(data))
|
||||
for _, row := range data {
|
||||
if deadOnly && !rowBool(row, "dead") {
|
||||
continue
|
||||
}
|
||||
if typeFilter != "" && rowStr(row, "type") != typeFilter {
|
||||
continue
|
||||
}
|
||||
if statusFilter != "" && rowStr(row, "status") != statusFilter {
|
||||
continue
|
||||
}
|
||||
// plan filter: vip=非free, free=free, sub=子号, master=vip且非子号
|
||||
if planFilter != "" {
|
||||
plan := strings.ToLower(rowStr(row, "plan"))
|
||||
isSub := rowBool(row, "sub_account")
|
||||
isFree := plan == "" || plan == "free"
|
||||
switch planFilter {
|
||||
case "vip":
|
||||
if isFree { continue }
|
||||
case "free":
|
||||
if !isFree { continue }
|
||||
case "sub":
|
||||
if !isSub { continue }
|
||||
case "master":
|
||||
if isFree || isSub { continue }
|
||||
}
|
||||
}
|
||||
if q != "" {
|
||||
email := strings.ToLower(rowStr(row, "email"))
|
||||
id := strings.ToLower(rowStr(row, "id"))
|
||||
typ := strings.ToLower(rowStr(row, "type"))
|
||||
if !strings.Contains(email, q) && !strings.Contains(id, q) && !strings.Contains(typ, q) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
limit, offset := pageParams(c, 20)
|
||||
page := pageSlice(filtered, limit, offset)
|
||||
c.JSON(http.StatusOK, gin.H{"data": page, "total": total, "limit": limit, "offset": offset, "stats": stats})
|
||||
}
|
||||
|
||||
// accountsStats reproduces the 账号 KPI strip: per-type 正常/失效/限额 counts plus a
|
||||
// grand total and total dead count (drives 「删除异常账号 (N)」).
|
||||
func accountsStats(rows []map[string]any) gin.H {
|
||||
types := []string{"openai", "adobe", "runway", "leonardo", "krea", "imagine", "grok", "creativefabrica"}
|
||||
by := map[string]*struct{ N, Ok, Dead, Quota int }{}
|
||||
for _, t := range types {
|
||||
by[t] = &struct{ N, Ok, Dead, Quota int }{}
|
||||
}
|
||||
deadTotal := 0
|
||||
for _, row := range rows {
|
||||
dead := rowBool(row, "dead")
|
||||
if dead {
|
||||
deadTotal++
|
||||
}
|
||||
g, ok := by[rowStr(row, "type")]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
g.N++
|
||||
status := rowStr(row, "status")
|
||||
switch {
|
||||
case status == "active":
|
||||
g.Ok++
|
||||
case dead || status == "disabled":
|
||||
g.Dead++
|
||||
case status == "quota":
|
||||
g.Quota++
|
||||
}
|
||||
}
|
||||
out := gin.H{"total": len(rows), "dead_total": deadTotal}
|
||||
for _, t := range types {
|
||||
g := by[t]
|
||||
out[t] = gin.H{"n": g.N, "ok": g.Ok, "dead": g.Dead, "quota": g.Quota}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rowStr(m map[string]any, key string) string {
|
||||
s, _ := m[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func rowBool(m map[string]any, key string) bool {
|
||||
b, _ := m[key].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountQuota(c *gin.Context) {
|
||||
data, err := h.tokens.Quota(c.Request.Context(), c.Param("pool"), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountEmail(c *gin.Context) {
|
||||
data, err := h.tokens.Email(c.Request.Context(), c.Param("pool"), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshProfiles(c *gin.Context) {
|
||||
items, err := h.refresh.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load refresh profiles"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshNow(c *gin.Context) {
|
||||
if err := h.refresh.RefreshNow(c.Request.Context(), c.Param("profile_id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshUpdate(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.refresh.Update(c.Request.Context(), c.Param("profile_id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshDelete(c *gin.Context) {
|
||||
if err := h.refresh.Delete(c.Request.Context(), c.Param("profile_id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/repo"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "golang.org/x/image/webp"
|
||||
@@ -248,7 +251,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
||||
Resolution string `json:"resolution"`
|
||||
Duration string `json:"duration"`
|
||||
ReferenceImages []string `json:"reference_images"`
|
||||
AccountID string `json:"account_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
@@ -262,7 +265,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
||||
Resolution: body.Resolution,
|
||||
Duration: body.Duration,
|
||||
ReferenceImages: body.ReferenceImages,
|
||||
AccountID: body.AccountID,
|
||||
ChannelID: body.ChannelID,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
@@ -451,161 +454,19 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []gin.H{
|
||||
{
|
||||
"key": "gemini-veo31",
|
||||
"label": "Veo31 Fast",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "gemini-veo3.1-fast",
|
||||
"label": "Veo 3.1 Fast",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "gemini-veo3.1",
|
||||
"label": "Veo 3.1",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
},
|
||||
{
|
||||
"key": "adobe-seedance-2.0-fast",
|
||||
"label": "Seedance 2.0 Fast",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "adobe-seedance-2.0",
|
||||
"label": "Seedance 2.0",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0-fast",
|
||||
"label": "Seedance 2.0 Fast (Creative Fabrica)",
|
||||
"type": "video",
|
||||
"provider": "creativefabrica",
|
||||
"durations": []string{"14s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE),
|
||||
// 没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"max_videos": 0,
|
||||
"max_audios": 0,
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0",
|
||||
"label": "Seedance 2.0 (Creative Fabrica)",
|
||||
"type": "video",
|
||||
"provider": "creativefabrica",
|
||||
"durations": []string{"10s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE),
|
||||
// 没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"max_videos": 0,
|
||||
"max_audios": 0,
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0-不卡人脸",
|
||||
"label": "Seedance 2.0 (Leonardo 私有)",
|
||||
"type": "video",
|
||||
"provider": "leonardo",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"max_reference_images": 8,
|
||||
"max_images": 4,
|
||||
"max_videos": 3,
|
||||
"max_audios": 1,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0-fast-不卡人脸",
|
||||
"label": "Seedance 2.0 Fast (Leonardo 私有)",
|
||||
"type": "video",
|
||||
"provider": "leonardo",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"max_reference_images": 8,
|
||||
"max_images": 4,
|
||||
"max_videos": 3,
|
||||
"max_audios": 1,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "minimax-h3",
|
||||
"label": "MiniMax H3 (Leonardo 私有)",
|
||||
"type": "video",
|
||||
"provider": "leonardo",
|
||||
"durations": []string{"5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"1440p"},
|
||||
"max_reference_images": 8,
|
||||
"max_images": 5,
|
||||
"max_videos": 0,
|
||||
"max_audios": 3,
|
||||
"max_audio_seconds": 15,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "firefly-video",
|
||||
"label": "Firefly Video",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"5s"},
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"540p", "720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "runway-gen4-turbo",
|
||||
"label": "Runway Gen-4 Turbo",
|
||||
"type": "video",
|
||||
"provider": "runway",
|
||||
"durations": []string{"5s", "10s"},
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"2K"},
|
||||
"max_reference_images": 1,
|
||||
"reference_mode": "frame",
|
||||
// Runway is strictly image-to-video — a first-frame image is required
|
||||
// (no text2video), so the UI must block submit without one.
|
||||
"requires_reference": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load video presets"})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(models))
|
||||
for _, m := range models {
|
||||
if m.Type != "video" || !m.Enabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, modelPreset(m))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Catalog(c *gin.Context) {
|
||||
@@ -620,7 +481,7 @@ func (h *UserGenerationHandler) Catalog(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Models(c *gin.Context) {
|
||||
items, err := h.publicModels()
|
||||
items, err := h.publicModels(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
||||
return
|
||||
@@ -628,537 +489,85 @@ func (h *UserGenerationHandler) Models(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// modelPreset projects one model_configs row into the preset shape the studio
|
||||
// (画图台) / model pickers consume. Database-driven now: providers are gone and
|
||||
// every model arrives via a channel import.
|
||||
func modelPreset(m model.ModelConfig) gin.H {
|
||||
durations := repo.JSONStrings(m.Durations)
|
||||
if len(durations) == 0 && m.DurationMin > 0 && m.DurationMax >= m.DurationMin {
|
||||
// Range spec → expand integer seconds (capped so absurd ranges can't
|
||||
// flood a dropdown).
|
||||
for d := m.DurationMin; d <= m.DurationMax && len(durations) < 61; d++ {
|
||||
durations = append(durations, fmt.Sprintf("%ds", d))
|
||||
}
|
||||
}
|
||||
_, perSecond := m.DurationPrices["per_second"]
|
||||
return gin.H{
|
||||
"key": m.EffectiveName(),
|
||||
"id": m.ID,
|
||||
"label": m.EffectiveName(),
|
||||
"type": m.Type,
|
||||
"provider": m.Provider,
|
||||
"ratios": repo.JSONStrings(m.Ratios),
|
||||
"resolutions": repo.JSONStrings(m.Resolutions),
|
||||
"durations": durations,
|
||||
"duration_min": m.DurationMin,
|
||||
"duration_max": m.DurationMax,
|
||||
"per_second": perSecond,
|
||||
"max_reference_images": m.MaxReferenceImages,
|
||||
"max_images": m.MaxReferenceImages,
|
||||
"max_videos": m.MaxReferenceVideos,
|
||||
"max_audios": m.MaxReferenceAudios,
|
||||
"max_reference_videos": m.MaxReferenceVideos,
|
||||
"max_reference_audios": m.MaxReferenceAudios,
|
||||
"max_video_seconds": m.MaxRefVideoSeconds,
|
||||
"max_audio_seconds": m.MaxRefAudioSeconds,
|
||||
"reference_mode": m.ReferenceMode,
|
||||
"image_to_image": m.ImageToImage,
|
||||
"description": m.Name,
|
||||
"added": true,
|
||||
}
|
||||
}
|
||||
|
||||
// catalogEntries lists the managed models (previously a static picklist of
|
||||
// offerable provider models). With channel imports the DB IS the catalog —
|
||||
// rows are already "added", so the admin picklist degrades gracefully.
|
||||
func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) {
|
||||
items := []gin.H{
|
||||
{
|
||||
"id": "gpt-image-2",
|
||||
"provider": "chatgpt",
|
||||
"type": "image",
|
||||
// ChatGPT web backend only reliably produces 1K and honors a limited
|
||||
// ratio set; size params are advisory prompt hints. Mirrors the Python
|
||||
// reference (providers/chatgpt/provider.py) — do not offer 2K/4K.
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"description": "ChatGPT image generation",
|
||||
},
|
||||
{
|
||||
"id": "firefly-gpt-image-2",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"description": "Adobe Firefly GPT Image",
|
||||
},
|
||||
{
|
||||
"id": "firefly-image-5",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"image_to_image": true,
|
||||
"description": "Adobe Firefly Image 5",
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-2",
|
||||
"provider": "runway",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Runway Nano Banana 2 (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-pro",
|
||||
"provider": "runway",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Runway Nano Banana Pro (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-2",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Nano Banana 2 (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-pro",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Nano Banana Pro (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1-fast",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"max_reference_images": 3,
|
||||
"reference_mode": "frame",
|
||||
"description": "Veo 3.1 Fast",
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"max_reference_images": 3,
|
||||
"reference_mode": "style",
|
||||
"description": "Veo 3.1",
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"5s", "10s"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
"description": "Luma Ray video",
|
||||
},
|
||||
{
|
||||
"id": "firefly-video",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"5s"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
"description": "Adobe Firefly Video",
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0-fast",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
"description": "Seedance 2.0 Fast",
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
"description": "Seedance 2.0",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast",
|
||||
"provider": "creativefabrica",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// 一次性账号:积分刚好够一次生成,时长固定 14 秒。
|
||||
"durations": []string{"14s"},
|
||||
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"description": "Seedance 2.0 Fast (Creative Fabrica)",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0",
|
||||
"provider": "creativefabrica",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// 一次性账号:积分刚好够一次生成,时长固定 10 秒。
|
||||
"durations": []string{"10s"},
|
||||
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"description": "Seedance 2.0 (Creative Fabrica)",
|
||||
},
|
||||
{
|
||||
"id": "runway-gen4-turbo",
|
||||
"provider": "runway",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"5s", "10s"},
|
||||
"max_reference_images": 1,
|
||||
"reference_mode": "frame",
|
||||
"description": "Runway Gen-4 Turbo video (图生视频)",
|
||||
},
|
||||
{
|
||||
"id": "grok-video",
|
||||
"provider": "grok",
|
||||
"type": "video",
|
||||
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
// Console 视频吃 1–15 秒的整数时长,所以按秒计价(同 seedance);
|
||||
// 参考图只有 1 张首帧(上游只有 image 字段,没有尾帧)。
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 1,
|
||||
"reference_mode": "frame",
|
||||
"description": "Grok Imagine video (文/图生视频)",
|
||||
},
|
||||
{
|
||||
"id": "grok-image",
|
||||
"provider": "grok",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 3,
|
||||
"reference_mode": "asset",
|
||||
"description": "Grok Imagine image (文生图 / 图生图)",
|
||||
},
|
||||
{
|
||||
"id": "seedream-4.5",
|
||||
"provider": "leonardo",
|
||||
"type": "image",
|
||||
"ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"},
|
||||
"resolutions": []string{"2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"description": "Leonardo Seedream 4.5 (生图 / 图生图)",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
// 参考资产总上限 8 = 4 图 + 1 音频 + 3 视频(分类上限在服务端校验)。
|
||||
"max_reference_images": 8,
|
||||
"reference_mode": "style",
|
||||
"description": "Leonardo Seedance 2.0 (私有生成 / 图音视频参考)",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 8,
|
||||
"reference_mode": "style",
|
||||
"description": "Leonardo Seedance 2.0 Fast (私有生成 / 图音视频参考)",
|
||||
},
|
||||
{
|
||||
"id": "minimax-h3",
|
||||
"provider": "leonardo",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"1440p"},
|
||||
// 上游吃 5–15 秒整数时长,所以按秒计价(后台只填一个 /s 价)。
|
||||
"durations": []string{"5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
// 参考资产总上限 8 = 5 图 + 3 音频(分类上限在服务端校验)。
|
||||
"max_reference_images": 8,
|
||||
"reference_mode": "style",
|
||||
"description": "Leonardo MiniMax H3 / hailuo-03 (1440p / 图音参考)",
|
||||
},
|
||||
{
|
||||
"id": "flux-klein-2",
|
||||
"provider": "krea",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 4,
|
||||
"description": "Krea Flux Klein (生图 / 图生图)",
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5",
|
||||
"provider": "imagine",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"2K"},
|
||||
"max_reference_images": 0,
|
||||
"description": "Imagine 1.5 (文生图)",
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5pro",
|
||||
"provider": "imagine",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"4K"},
|
||||
"max_reference_images": 0,
|
||||
"description": "Imagine 1.5 Pro (文生图)",
|
||||
},
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing := map[string]bool{}
|
||||
if h.admin != nil {
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range models {
|
||||
existing[item.ID] = true
|
||||
}
|
||||
}
|
||||
for i := range items {
|
||||
items[i]["added"] = existing[items[i]["id"].(string)]
|
||||
items := make([]gin.H, 0, len(models))
|
||||
for _, m := range models {
|
||||
row := modelPreset(m)
|
||||
row["id"] = m.EffectiveName()
|
||||
items = append(items, row)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
||||
items := []gin.H{
|
||||
{
|
||||
"id": "gpt-image-2",
|
||||
"provider": "chatgpt",
|
||||
"kind": "image",
|
||||
// See catalogEntries — ChatGPT only reliably does 1K and a limited
|
||||
// ratio set; matches the Python reference. Keep both lists in sync.
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"description": "ChatGPT image generation",
|
||||
// publicModels — GET /api/models for the public site (docs/landing pickers).
|
||||
// Enabled models only, in the same shape as before minus the provider id.
|
||||
func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]gin.H, 0, len(models))
|
||||
for _, m := range models {
|
||||
if !m.Enabled {
|
||||
continue
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"id": m.EffectiveName(),
|
||||
"kind": m.Type,
|
||||
"type": m.Type,
|
||||
"ratios": repo.JSONStrings(m.Ratios),
|
||||
"resolutions": repo.JSONStrings(m.Resolutions),
|
||||
"durations": repo.JSONStrings(m.Durations),
|
||||
"description": m.Name,
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-gpt-image-2",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Adobe Firefly GPT Image",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-image-5",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"description": "Adobe Firefly Image 5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-2",
|
||||
"provider": "runway",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Runway Nano Banana 2",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-pro",
|
||||
"provider": "runway",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Runway Nano Banana Pro",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-2",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Nano Banana 2",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-pro",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Nano Banana Pro",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1-fast",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"description": "Veo 3.1 Fast",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Veo 3.1",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-ray",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Luma Ray video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-video",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"description": "Adobe Firefly Video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0-fast",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Seedance 2.0 Fast",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"description": "Seedance 2.0",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast",
|
||||
"provider": "creativefabrica",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Seedance 2.0 Fast (Creative Fabrica)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0",
|
||||
"provider": "creativefabrica",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Seedance 2.0 (Creative Fabrica)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "runway-gen4-turbo",
|
||||
"provider": "runway",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Runway Gen-4 Turbo video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "grok-video",
|
||||
"provider": "grok",
|
||||
"kind": "video",
|
||||
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Grok Imagine video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "grok-image",
|
||||
"provider": "grok",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"description": "Grok Imagine image",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedream-4.5",
|
||||
"provider": "leonardo",
|
||||
"kind": "image",
|
||||
"ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"},
|
||||
"resolutions": []string{"2K", "4K"},
|
||||
"description": "Leonardo Seedream 4.5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Leonardo Seedance 2.0 (私有生成)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Leonardo Seedance 2.0 Fast (私有生成)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "minimax-h3",
|
||||
"provider": "leonardo",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"1440p"},
|
||||
"description": "Leonardo MiniMax H3 (1440p)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "flux-klein-2",
|
||||
"provider": "krea",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"description": "Krea Flux Klein",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5",
|
||||
"provider": "imagine",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"2K"},
|
||||
"description": "Imagine 1.5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5pro",
|
||||
"provider": "imagine",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"4K"},
|
||||
"description": "Imagine 1.5 Pro",
|
||||
"stub": false,
|
||||
},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
+174
-332
@@ -1,19 +1,33 @@
|
||||
// Package handler — /v1/* 消费者 HTTP 接口(vivid 协议同款)。
|
||||
// 这套接口同时是渠道上游契约:同构实例经相同的四个端点互相对接。
|
||||
//
|
||||
// POST /v1/generate 创建生成任务(NDJSON 流:首帧 jobId → 终态帧;断流携 jobId 重发续等)
|
||||
// POST /v1/uploads 参考素材预签名直传(返回 key 随 generate 的 refs 提交)
|
||||
// GET /v1/models 可售模型裸数组(启用 + 有渠道绑定 + 全部档位已配价)
|
||||
// GET /v1/balance {balance}:余额积分
|
||||
//
|
||||
// 鉴权:Authorization: Bearer vk_...(用户 API Key,哈希落库)。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// v1MaxWait 同步等待终态的上限。视频单渠道预算 30 分钟、最多换几个渠道,
|
||||
// 留足余量取 2 小时(与 vivid 一致)。
|
||||
const v1MaxWait = 2 * time.Hour
|
||||
|
||||
const v1PollInterval = time.Second
|
||||
|
||||
type V1Handler struct {
|
||||
v1 *service.V1Service
|
||||
}
|
||||
@@ -22,385 +36,213 @@ func NewV1Handler(v1 *service.V1Service) *V1Handler {
|
||||
return &V1Handler{v1: v1}
|
||||
}
|
||||
|
||||
func (h *V1Handler) Models(c *gin.Context) {
|
||||
func (h *V1Handler) auth(c *gin.Context) (*service.APIPrincipal, bool) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
|
||||
return nil, false
|
||||
}
|
||||
_ = principal
|
||||
return principal, true
|
||||
}
|
||||
|
||||
items, err := h.v1.ListModels(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": items,
|
||||
// v1Err writes the {"error":{"code","message"}} envelope — the wire shape every
|
||||
// vivid-protocol caller parses.
|
||||
func v1Err(c *gin.Context, httpStatus int, code, msg string) {
|
||||
c.JSON(httpStatus, gin.H{
|
||||
"error": gin.H{"code": code, "message": msg},
|
||||
})
|
||||
}
|
||||
|
||||
// UserBalance — GET /v1/user/balance. 返回 API Key 所属用户的账户级余额
|
||||
// (剩余 / 累计已用),与具体令牌无关。
|
||||
func (h *V1Handler) UserBalance(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
// v1Fail maps a service error to the vivid code/HTTP status pair.
|
||||
func v1Fail(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMissingAPIKey), errors.Is(err, service.ErrInvalidAPIKey):
|
||||
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
|
||||
case errors.Is(err, service.ErrInsufficientFunds):
|
||||
v1Err(c, http.StatusBadRequest, "4002", "积分不足,请先充值")
|
||||
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
|
||||
v1Err(c, http.StatusTooManyRequests, "4293", "当前并发已满,请稍后重试")
|
||||
case errors.Is(err, service.ErrVideoJobNotFound):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "任务不存在")
|
||||
case errors.Is(err, service.ErrReferenceTooLarge):
|
||||
v1Err(c, http.StatusRequestEntityTooLarge, "4001", "参考素材超过大小上限")
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "模型不存在或未启用")
|
||||
case errors.Is(err, service.ErrNoProviderAccount):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "模型无可用渠道")
|
||||
case errors.Is(err, service.ErrBannedPrompt):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "提示词包含违禁内容")
|
||||
case errors.Is(err, service.ErrUnsupportedParams):
|
||||
v1Err(c, http.StatusBadRequest, "4001", err.Error())
|
||||
case errors.Is(err, service.ErrProviderTemporary), errors.Is(err, service.ErrProviderExecution),
|
||||
errors.Is(err, service.ErrProviderAuth):
|
||||
v1Err(c, http.StatusInternalServerError, "5001", "生成暂时不可用,积分已退还")
|
||||
default:
|
||||
v1Err(c, http.StatusBadRequest, "4001", err.Error())
|
||||
}
|
||||
resp, err := h.v1.UserBalance(c.Request.Context(), principal)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load balance"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ImageGenerations — OpenAI POST /v1/images/generations (text-to-image only).
|
||||
// Accepts exactly OpenAI's fields; size→aspect ratio and quality→resolution tier
|
||||
// are mapped server-side. Returns {created, data:[{b64_json}]}.
|
||||
func (h *V1Handler) ImageGenerations(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
// ---- POST /v1/generate ----
|
||||
|
||||
func (h *V1Handler) Generate(c *gin.Context) {
|
||||
principal, ok := h.auth(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
N int `json:"n"`
|
||||
Size string `json:"size"`
|
||||
Quality string `json:"quality"`
|
||||
ResponseFormat string `json:"response_format"`
|
||||
Background string `json:"background"`
|
||||
OutputFormat string `json:"output_format"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
var body service.V1JobRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
|
||||
Model: body.Model,
|
||||
Prompt: body.Prompt,
|
||||
N: body.N,
|
||||
Size: body.Size,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, resp)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, openaiImageResponse(resp))
|
||||
}
|
||||
|
||||
// ImageEdits — OpenAI POST /v1/images/edits (image-to-image). multipart/form-data
|
||||
// only: image / image[] file uploads (+ optional mask), prompt, model, n, size,
|
||||
// quality. Files become reference images. Returns {created, data:[{b64_json}]}.
|
||||
func (h *V1Handler) ImageEdits(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires multipart/form-data"})
|
||||
return
|
||||
}
|
||||
if err := c.Request.ParseMultipartForm(64 << 20); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
refs := readMultipartImages(c, "image", "image[]")
|
||||
if len(refs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires at least one image file"})
|
||||
return
|
||||
}
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("n")))
|
||||
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
|
||||
Model: c.PostForm("model"),
|
||||
Prompt: c.PostForm("prompt"),
|
||||
N: n,
|
||||
Size: c.PostForm("size"),
|
||||
ReferenceImages: refs,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, resp)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, openaiImageResponse(resp))
|
||||
}
|
||||
|
||||
// CreateVideo — OpenAI POST /v1/videos. Creates an async job and returns the
|
||||
// video object immediately ({id, status:"queued"}). Accepts JSON {model, prompt,
|
||||
// seconds, size} or multipart (with an input_reference file). size→ratio+
|
||||
// resolution, seconds→duration.
|
||||
func (h *V1Handler) CreateVideo(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
var modelID, prompt, seconds, size, referenceMode string
|
||||
var refs []string
|
||||
if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
|
||||
if err := c.Request.ParseMultipartForm(256 << 20); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
modelID = c.PostForm("model")
|
||||
prompt = c.PostForm("prompt")
|
||||
seconds = c.PostForm("seconds")
|
||||
size = c.PostForm("size")
|
||||
referenceMode = c.PostForm("reference_mode")
|
||||
refs = readMultipartImages(c, "input_reference", "input_reference[]")
|
||||
var ev *model.EventLog
|
||||
var err error
|
||||
// 重连模式:只带 jobId → 不建新任务,回读该任务并等待终态。
|
||||
if body.JobID != nil && strings.TrimSpace(*body.JobID) != "" {
|
||||
ev, err = h.v1.JobForUser(c.Request.Context(), principal, *body.JobID)
|
||||
} else {
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Seconds json.RawMessage `json:"seconds"`
|
||||
Size string `json:"size"`
|
||||
ReferenceMode string `json:"reference_mode"`
|
||||
InputReference []string `json:"input_reference"`
|
||||
ReferenceImages []string `json:"reference_images"`
|
||||
ev, err = h.v1.CreateJob(c.Request.Context(), principal, body)
|
||||
}
|
||||
if err != nil {
|
||||
v1Fail(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
wait := 300 * time.Second
|
||||
if h := strings.TrimSpace(c.GetHeader("X-Wait-Seconds")); h != "" {
|
||||
if sec, serr := strconv.Atoi(h); serr == nil && sec > 0 && time.Duration(sec)*time.Second <= v1MaxWait {
|
||||
wait = time.Duration(sec) * time.Second
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "application/x-ndjson")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
writeFrame := func(v *model.EventLog) bool {
|
||||
frame, _ := json.Marshal(v1JobBody(h.v1, v))
|
||||
if _, werr := c.Writer.Write(append(frame, '\n')); werr != nil {
|
||||
return false
|
||||
}
|
||||
modelID, prompt, size = body.Model, body.Prompt, body.Size
|
||||
seconds = rawToString(body.Seconds)
|
||||
referenceMode = strings.TrimSpace(body.ReferenceMode)
|
||||
refs = append(body.InputReference, body.ReferenceImages...)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return true
|
||||
}
|
||||
duration := strings.TrimSpace(seconds)
|
||||
if duration != "" && !strings.HasSuffix(duration, "s") {
|
||||
duration += "s"
|
||||
}
|
||||
aspect, resolution := videoSizeToInternal(size)
|
||||
resp, err := h.v1.StartVideoJob(c.Request.Context(), principal, service.V1VideoRequest{
|
||||
Model: modelID,
|
||||
Prompt: prompt,
|
||||
Duration: duration,
|
||||
AspectRatio: aspect,
|
||||
Resolution: resolution,
|
||||
ReferenceImages: refs,
|
||||
ReferenceMode: referenceMode,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetVideo — OpenAI GET /v1/videos/{id}. Returns the job status object.
|
||||
func (h *V1Handler) GetVideo(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
// 已终态:直接回终态帧关流。
|
||||
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
|
||||
writeFrame(ev)
|
||||
return
|
||||
}
|
||||
resp, err := h.v1.VideoJob(c.Request.Context(), principal, c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
// 连上即回执 jobId(断流后携它重发的凭据)。
|
||||
if !writeFrame(ev) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetVideoContent — OpenAI GET /v1/videos/{id}/content. Streams the rendered mp4
|
||||
// by proxying the stored upstream URL (downloaded on demand, never persisted).
|
||||
func (h *V1Handler) GetVideoContent(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
body, contentType, err := h.v1.OpenVideoContent(c.Request.Context(), principal, c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = io.Copy(c.Writer, body)
|
||||
}
|
||||
|
||||
// GetImageContent — GET /v1/images/{id}/content. Streams a no-store image by
|
||||
// proxying its stored (possibly auth-gated) upstream URL. Never persisted.
|
||||
func (h *V1Handler) GetImageContent(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
body, contentType, err := h.v1.OpenImageContent(c.Request.Context(), principal, c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = io.Copy(c.Writer, body)
|
||||
}
|
||||
|
||||
// readMultipartImages reads the given file fields and returns each as base64.
|
||||
func readMultipartImages(c *gin.Context, keys ...string) []string {
|
||||
var out []string
|
||||
form := c.Request.MultipartForm
|
||||
if form == nil {
|
||||
return out
|
||||
}
|
||||
for _, key := range keys {
|
||||
for _, fh := range form.File[key] {
|
||||
f, e := fh.Open()
|
||||
if e != nil {
|
||||
deadline := time.Now().Add(wait)
|
||||
poll := time.NewTicker(v1PollInterval)
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer poll.Stop()
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return // client gone — the job keeps running; caller may reconnect with jobId
|
||||
case <-poll.C:
|
||||
latest, lerr := h.v1.JobForUser(c.Request.Context(), principal, ev.ID)
|
||||
if lerr != nil || latest == nil {
|
||||
continue
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(f, 20<<20+1))
|
||||
f.Close()
|
||||
if len(b) > 0 {
|
||||
out = append(out, base64.StdEncoding.EncodeToString(b))
|
||||
ev = latest
|
||||
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
|
||||
writeFrame(ev)
|
||||
return
|
||||
}
|
||||
case <-heartbeat.C:
|
||||
if _, werr := c.Writer.WriteString("\n"); werr != nil {
|
||||
return
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
case <-time.After(time.Until(deadline)):
|
||||
writeFrame(ev) // snapshot; client reconnects with jobId
|
||||
return
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rawToString accepts OpenAI's `seconds` whether sent as a JSON string or number.
|
||||
func rawToString(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
// v1JobBody assembles one NDJSON frame (running receipt and terminal frame
|
||||
// share the shape; succeeded adds result+creditsCharged, failed adds error).
|
||||
func v1JobBody(v1 *service.V1Service, ev *model.EventLog) map[string]any {
|
||||
body := map[string]any{"jobId": ev.ID, "status": service.JobStatus(ev)}
|
||||
switch service.JobStatus(ev) {
|
||||
case "succeeded":
|
||||
body["result"] = v1.JobResultURL(ev)
|
||||
body["creditsCharged"] = ev.Cost
|
||||
case "failed":
|
||||
body["error"] = ev.Error
|
||||
body["refunded"] = true
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return s
|
||||
}
|
||||
var n json.Number
|
||||
if json.Unmarshal(raw, &n) == nil {
|
||||
return n.String()
|
||||
}
|
||||
return strings.Trim(string(raw), `"`)
|
||||
return body
|
||||
}
|
||||
|
||||
// videoSizeToInternal maps OpenAI's "WxH" size to our aspect ratio + resolution
|
||||
// tier. An absent/unparsable size leaves the resolution empty so the caller can
|
||||
// fall back to whatever tier the model actually prices — hardcoding 720p here
|
||||
// rejects models that only offer 1440p.
|
||||
func videoSizeToInternal(size string) (ratio, resolution string) {
|
||||
var w, h int
|
||||
if s := strings.TrimSpace(strings.ToLower(size)); s != "" {
|
||||
_, _ = fmt.Sscanf(s, "%dx%d", &w, &h)
|
||||
// ---- POST /v1/uploads ----
|
||||
|
||||
func (h *V1Handler) Uploads(c *gin.Context) {
|
||||
principal, ok := h.auth(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if w == 0 || h == 0 {
|
||||
return "16:9", ""
|
||||
var body struct {
|
||||
Kind string `json:"kind"`
|
||||
Mime string `json:"mime"`
|
||||
}
|
||||
// The "p" resolution is the SHORT edge (720p = 1280×720, 1080p = 1920×1080),
|
||||
// so a standard 1280×720 must read as 720p — not 1080p off the long edge.
|
||||
resolution = "720p"
|
||||
switch {
|
||||
case min(w, h) >= 1440:
|
||||
resolution = "1440p"
|
||||
case min(w, h) >= 1080:
|
||||
resolution = "1080p"
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
||||
return
|
||||
}
|
||||
return guessRatioWH(w, h), resolution
|
||||
view, err := h.v1.PresignRefUpload(c.Request.Context(), principal, body.Kind, body.Mime)
|
||||
if err != nil {
|
||||
v1Fail(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
func guessRatioWH(w, h int) string {
|
||||
if w == h {
|
||||
return "1:1"
|
||||
// ---- GET /v1/models ----
|
||||
|
||||
func (h *V1Handler) Models(c *gin.Context) {
|
||||
if _, ok := h.auth(c); !ok {
|
||||
return
|
||||
}
|
||||
r := float64(w) / float64(h)
|
||||
cands := []struct {
|
||||
name string
|
||||
v float64
|
||||
}{{"16:9", 16.0 / 9}, {"9:16", 9.0 / 16}, {"4:3", 4.0 / 3}, {"3:4", 3.0 / 4}, {"1:1", 1}}
|
||||
best, bestD := "16:9", 1e9
|
||||
for _, cd := range cands {
|
||||
d := r - cd.v
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d < bestD {
|
||||
best, bestD = cd.name, d
|
||||
}
|
||||
items, err := h.v1.SellableModels(c.Request.Context())
|
||||
if err != nil {
|
||||
v1Err(c, http.StatusInternalServerError, "5001", "模型加载失败")
|
||||
return
|
||||
}
|
||||
return best
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// openaiImageResponse strips our rich internal map down to OpenAI's image shape.
|
||||
func openaiImageResponse(m map[string]any) gin.H {
|
||||
out := gin.H{"created": m["created"]}
|
||||
if d, ok := m["data"]; ok && d != nil {
|
||||
out["data"] = d
|
||||
} else {
|
||||
out["data"] = []any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// ---- GET /v1/balance ----
|
||||
|
||||
func (h *V1Handler) writeAuthError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMissingAPIKey):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrInvalidAPIKey):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to validate api key"})
|
||||
func (h *V1Handler) UserBalance(c *gin.Context) {
|
||||
principal, ok := h.auth(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *V1Handler) writeV1Error(c *gin.Context, err error, payload map[string]any) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrInsufficientFunds):
|
||||
c.JSON(http.StatusPaymentRequired, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrReferenceTooLarge):
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrNoProviderAccount):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderAuth):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderQuota):
|
||||
// Match the Python contract: provider quota exhaustion maps to 401
|
||||
// (QuotaExhaustedError is handled alongside AuthError in routes.py).
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderTemporary):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrVideoJobNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrVideoNotReady):
|
||||
c.JSON(http.StatusConflict, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderUnsupported):
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderExecution):
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrGenerationPending):
|
||||
c.JSON(http.StatusNotImplemented, payload)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
bal, err := h.v1.UserBalance(c.Request.Context(), principal)
|
||||
if err != nil {
|
||||
v1Err(c, http.StatusInternalServerError, "5001", "余额查询失败")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"balance": bal})
|
||||
}
|
||||
|
||||
// requestBaseURL derives the scheme+host of the inbound request so the service
|
||||
// layer can build absolute, directly-downloadable output URLs. Honors
|
||||
// X-Forwarded-Proto (reverse-proxy / TLS termination) before falling back to
|
||||
// the connection's TLS state. Returns "" when the host is unknown, which makes
|
||||
// the service fall back to a relative path.
|
||||
// layer can build absolute /images URLs for the session path.
|
||||
func requestBaseURL(c *gin.Context) string {
|
||||
host := c.Request.Host
|
||||
if host == "" {
|
||||
|
||||
@@ -11,25 +11,25 @@ import (
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
Health *handler.HealthHandler
|
||||
Images *handler.ImageHandler
|
||||
V1 *handler.V1Handler
|
||||
Site *handler.SiteHandler
|
||||
Showcase *handler.ShowcaseHandler
|
||||
Auth *handler.AuthHandler
|
||||
SiteSettings *handler.SiteSettingsHandler
|
||||
AppSettings *handler.AppSettingsHandler
|
||||
AdminRead *handler.AdminReadHandler
|
||||
AdminWrite *handler.AdminWriteHandler
|
||||
CDK *handler.CDKHandler
|
||||
UserTools *handler.UserToolsHandler
|
||||
UserGen *handler.UserGenerationHandler
|
||||
ProviderAdmin *handler.ProviderAdminHandler
|
||||
ConcGroups *handler.ConcurrencyGroupHandler
|
||||
Announcement *handler.AnnouncementHandler
|
||||
Payment *handler.PaymentHandler
|
||||
BannedWords *handler.BannedWordsHandler
|
||||
Canvas *handler.CanvasHandler
|
||||
Health *handler.HealthHandler
|
||||
Images *handler.ImageHandler
|
||||
V1 *handler.V1Handler
|
||||
Site *handler.SiteHandler
|
||||
Showcase *handler.ShowcaseHandler
|
||||
Auth *handler.AuthHandler
|
||||
SiteSettings *handler.SiteSettingsHandler
|
||||
AppSettings *handler.AppSettingsHandler
|
||||
AdminRead *handler.AdminReadHandler
|
||||
AdminWrite *handler.AdminWriteHandler
|
||||
CDK *handler.CDKHandler
|
||||
UserTools *handler.UserToolsHandler
|
||||
UserGen *handler.UserGenerationHandler
|
||||
ChannelAdmin *handler.ChannelAdminHandler
|
||||
ConcGroups *handler.ConcurrencyGroupHandler
|
||||
Announcement *handler.AnnouncementHandler
|
||||
Payment *handler.PaymentHandler
|
||||
BannedWords *handler.BannedWordsHandler
|
||||
Canvas *handler.CanvasHandler
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine {
|
||||
@@ -59,16 +59,12 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
|
||||
engine.GET("/health", handlers.Health.Handle)
|
||||
engine.GET("/images/:user/:name", handlers.Images.Serve)
|
||||
// vivid-protocol public API — same four endpoints a vivid instance exposes,
|
||||
// which is what lets this service act as a channel upstream itself.
|
||||
engine.POST("/v1/generate", handlers.V1.Generate)
|
||||
engine.POST("/v1/uploads", handlers.V1.Uploads)
|
||||
engine.GET("/v1/models", handlers.V1.Models)
|
||||
engine.GET("/v1/user/balance", handlers.V1.UserBalance)
|
||||
engine.POST("/v1/images/generations", handlers.V1.ImageGenerations)
|
||||
engine.POST("/v1/images/edits", handlers.V1.ImageEdits)
|
||||
// OpenAI Sora-style async video: create job → poll → stream content.
|
||||
engine.POST("/v1/videos", handlers.V1.CreateVideo)
|
||||
engine.GET("/v1/videos/:id", handlers.V1.GetVideo)
|
||||
engine.GET("/v1/videos/:id/content", handlers.V1.GetVideoContent)
|
||||
// No-store image content proxy (auth-gated upstream URLs, e.g. chatgpt).
|
||||
engine.GET("/v1/images/:id/content", handlers.V1.GetImageContent)
|
||||
engine.GET("/v1/balance", handlers.V1.UserBalance)
|
||||
|
||||
publicAdmin := engine.Group("/admin/api")
|
||||
{
|
||||
@@ -79,7 +75,6 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
// owner names is admin-only, see /stats/recent below.
|
||||
publicAdmin.GET("/stats", handlers.AdminRead.Stats)
|
||||
publicAdmin.GET("/video-presets", handlers.UserGen.VideoPresets)
|
||||
publicAdmin.GET("/catalog", handlers.UserGen.Catalog)
|
||||
publicAdmin.GET("/models", handlers.UserGen.Models)
|
||||
publicAdmin.GET("/deai-pricing", handlers.AppSettings.DeAIGet)
|
||||
// 易支付 async notify — called server-to-server by the pay platform, no auth.
|
||||
@@ -147,23 +142,11 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
authed.POST("/cdks", handlers.CDK.Create)
|
||||
authed.POST("/cdks/delete-bulk", handlers.CDK.DeleteBulk)
|
||||
authed.DELETE("/cdks/:code", handlers.CDK.Delete)
|
||||
authed.GET("/tokens", handlers.ProviderAdmin.TokensList)
|
||||
authed.POST("/tokens", handlers.ProviderAdmin.TokensCreate)
|
||||
authed.POST("/tokens/import-chatgpt-token", handlers.ProviderAdmin.ImportChatGPTToken)
|
||||
authed.POST("/tokens/import-adobe-cookie", handlers.ProviderAdmin.ImportAdobeCookie)
|
||||
authed.POST("/tokens/import-creativefabrica-cookie", handlers.ProviderAdmin.ImportCreativeFabricaCookie)
|
||||
authed.POST("/tokens/import-runway-token", handlers.ProviderAdmin.ImportRunwayToken)
|
||||
authed.POST("/tokens/import-leonardo-cookie", handlers.ProviderAdmin.ImportLeonardoCookie)
|
||||
authed.POST("/tokens/import-krea-cookie", handlers.ProviderAdmin.ImportKreaCookie)
|
||||
authed.POST("/tokens/import-imagine-token", handlers.ProviderAdmin.ImportImagineToken)
|
||||
authed.POST("/tokens/import-grok-token", handlers.ProviderAdmin.ImportGrokToken)
|
||||
authed.POST("/tokens/import-custom-account", handlers.ProviderAdmin.ImportCustomAccount)
|
||||
authed.POST("/tokens/delete-bulk", handlers.ProviderAdmin.TokenDeleteBulk)
|
||||
authed.PATCH("/tokens/:pool/:id", handlers.ProviderAdmin.TokenUpdate)
|
||||
authed.DELETE("/tokens/:pool/:id", handlers.ProviderAdmin.TokenDelete)
|
||||
authed.GET("/accounts", handlers.ProviderAdmin.AccountsList)
|
||||
authed.GET("/accounts/:pool/:id/quota", handlers.ProviderAdmin.AccountQuota)
|
||||
authed.GET("/accounts/:pool/:id/email", handlers.ProviderAdmin.AccountEmail)
|
||||
authed.GET("/channels", handlers.ChannelAdmin.List)
|
||||
authed.POST("/channels", handlers.ChannelAdmin.Save)
|
||||
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
|
||||
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
|
||||
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
|
||||
authed.GET("/providers", handlers.AdminRead.Providers)
|
||||
authed.GET("/images", handlers.AdminRead.Images)
|
||||
authed.DELETE("/images", handlers.AdminRead.DeleteImage)
|
||||
@@ -172,10 +155,6 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
authed.POST("/banned-words/import", handlers.BannedWords.Import)
|
||||
authed.DELETE("/banned-words/:id", handlers.BannedWords.Delete)
|
||||
authed.GET("/banned-word-hits", handlers.BannedWords.Hits)
|
||||
authed.GET("/refresh/profiles", handlers.ProviderAdmin.RefreshProfiles)
|
||||
authed.POST("/refresh/profiles/:profile_id/refresh-now", handlers.ProviderAdmin.RefreshNow)
|
||||
authed.PATCH("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshUpdate)
|
||||
authed.DELETE("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshDelete)
|
||||
authed.POST("/managed-models", handlers.AdminWrite.CreateModel)
|
||||
authed.PATCH("/managed-models/:model_id", handlers.AdminWrite.UpdateModel)
|
||||
authed.DELETE("/managed-models/:model_id", handlers.AdminWrite.DeleteModel)
|
||||
|
||||
@@ -95,19 +95,20 @@ type EventLog struct {
|
||||
Duration string `gorm:"size:32"`
|
||||
Refs int `gorm:"not null;default:0"`
|
||||
DeAI bool `gorm:"not null;default:false"` // 去AI特征 was applied (image only)
|
||||
RefFiles datatypes.JSON `gorm:"type:jsonb"` // relative paths of saved reference images, for回显 on reload
|
||||
RefFiles datatypes.JSON `gorm:"type:jsonb"` // relative paths of saved reference images, for回显 on reload
|
||||
Source string `gorm:"size:32;index"`
|
||||
// AccountID is the provider token/account chosen to fulfil this generation,
|
||||
// stamped when the upstream call begins. Drives the accounts view's live
|
||||
// in-flight count (pending events per account) and lets an abandoned-event
|
||||
// purge attribute the failure back to the account it was using.
|
||||
// AccountID is the channel chosen to fulfil this generation, stamped when
|
||||
// the upstream call begins. (Field name kept from the pre-channel schema.)
|
||||
AccountID string `gorm:"size:64;index"`
|
||||
// AccountEmail denormalizes the account's email at stamp time, so log rows
|
||||
// keep showing which mailbox fulfilled the generation even after the account
|
||||
// is deleted or re-imported under a different ID.
|
||||
AccountEmail string `gorm:"size:255"`
|
||||
UserID string `gorm:"size:32;index"`
|
||||
Cost float64 `gorm:"not null;default:0"`
|
||||
// AccountEmail denormalizes the channel's display name at stamp time, so log
|
||||
// rows keep showing which channel fulfilled the generation even after the
|
||||
// channel is deleted or renamed.
|
||||
AccountEmail string `gorm:"size:255"`
|
||||
// UpstreamJob is the job id assigned by the upstream vivid instance (from
|
||||
// the NDJSON stream's jobId field) — kept for cross-instance debugging.
|
||||
UpstreamJob string `gorm:"size:64"`
|
||||
UserID string `gorm:"size:32;index"`
|
||||
Cost float64 `gorm:"not null;default:0"`
|
||||
// Refunded marks that this event's up-front charge has already been credited
|
||||
// back, so the normal failure path and the abandoned-purge sweep can never
|
||||
// double-refund the same generation.
|
||||
@@ -138,12 +139,20 @@ type ModelConfig struct {
|
||||
PricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:prices_agent"`
|
||||
DurationPricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:duration_prices_agent"`
|
||||
Durations datatypes.JSON `gorm:"type:jsonb"`
|
||||
MaxReferenceImages int `gorm:"not null;default:0"`
|
||||
ReferenceMode string `gorm:"size:32;not null;default:'none'"`
|
||||
// Custom-upstream models (provider="custom"): UpstreamModel is the model name
|
||||
// sent to the upstream OpenAI-compatible API; the base_url + key live on the
|
||||
// matching custom account (pool="custom", meta.base_url). Empty for built-ins.
|
||||
UpstreamModel string `gorm:"size:255;not null;default:''"`
|
||||
// DurationMin/DurationMax bound a free-form duration (seconds) for models
|
||||
// whose duration spec is a range rather than discrete options. 0 = unset.
|
||||
DurationMin int `gorm:"not null;default:0"`
|
||||
DurationMax int `gorm:"not null;default:0"`
|
||||
MaxReferenceImages int `gorm:"not null;default:0"`
|
||||
// Per-kind reference caps for upstreams that accept video/audio refs.
|
||||
// 0 = that kind is not accepted.
|
||||
MaxReferenceVideos int `gorm:"not null;default:0"`
|
||||
MaxReferenceAudios int `gorm:"not null;default:0"`
|
||||
// MaxRefVideoSeconds/MaxRefAudioSeconds cap the combined duration of
|
||||
// reference videos/audios. 0 = unlimited.
|
||||
MaxRefVideoSeconds int `gorm:"not null;default:0"`
|
||||
MaxRefAudioSeconds int `gorm:"not null;default:0"`
|
||||
ReferenceMode string `gorm:"size:32;not null;default:'none'"`
|
||||
// Weight controls display order in the model dropdown / admin list: higher
|
||||
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
|
||||
// back to created_at desc. Default 0.
|
||||
@@ -176,55 +185,37 @@ type CDKCode struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type TokenAccount struct {
|
||||
ID string `gorm:"primaryKey;size:64"`
|
||||
Pool string `gorm:"size:64;index;not null"`
|
||||
Value string `gorm:"type:text"`
|
||||
Status string `gorm:"size:32;index;not null"`
|
||||
Fails int `gorm:"not null;default:0"`
|
||||
FailTotal int `gorm:"not null;default:0"`
|
||||
SuccessTotal int `gorm:"not null;default:0"`
|
||||
Dead bool `gorm:"not null;default:false"`
|
||||
Meta datatypes.JSONMap `gorm:"type:jsonb"`
|
||||
AddedAt *time.Time
|
||||
LastUsedAt *time.Time
|
||||
CachedQuotaResetAfter string `gorm:"size:128"`
|
||||
QuotaRecoverAt *time.Time
|
||||
// Adobe quota is tracked separately for image vs video. An account only
|
||||
// enters the shared "quota" waiting status when BOTH are limited; a single
|
||||
// limit leaves the account usable for the other kind. Recovery time is shared
|
||||
// (QuotaRecoverAt / CachedQuotaResetAfter) since Adobe resets both at once.
|
||||
ImageLimited bool `gorm:"not null;default:false"`
|
||||
VideoLimited bool `gorm:"not null;default:false"`
|
||||
AccountEmail string `gorm:"size:255"`
|
||||
AccountDisplayName string `gorm:"size:255"`
|
||||
// Weight biases scheduling order for ANY account — higher weight is picked
|
||||
// first within its pool (ties fall back to round-robin). Default 0.
|
||||
Weight int `gorm:"not null;default:0"`
|
||||
// Concurrency is the max simultaneous jobs for THIS account. Only custom
|
||||
// (upstream) accounts honor it; built-in pools use their system default
|
||||
// (1 per account, grok 10). 0 = use the system default.
|
||||
// Channel is one upstream vivid-protocol instance this gateway routes
|
||||
// generation to. Replaces the old per-provider token account pool: a channel
|
||||
// is just a base_url + api_key plus routing knobs (enabled/weight/concurrency)
|
||||
// and local usage counters. ApiKey is stored AES-GCM encrypted.
|
||||
type Channel struct {
|
||||
ID string `gorm:"primaryKey;size:40"`
|
||||
Name string `gorm:"size:255;not null"`
|
||||
BaseURL string `gorm:"size:500;not null"`
|
||||
APIKeyEnc string `gorm:"type:text;not null"` // AES-GCM encrypted upstream API key
|
||||
Enabled bool `gorm:"not null;default:true;index"`
|
||||
// Concurrency caps simultaneous in-flight jobs on this channel. 0 = default.
|
||||
Concurrency int `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// Weight biases weighted-random channel order: weight 2 is picked first at
|
||||
// roughly 2x the rate of weight 1.
|
||||
Weight int `gorm:"not null;default:1"`
|
||||
SuccessTotal int64 `gorm:"not null;default:0"` // locally-tracked successful generations
|
||||
UsedCredits float64 `gorm:"not null;default:0"` // upstream credits spent (reported by the upstream)
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type RefreshProfile struct {
|
||||
ID string `gorm:"primaryKey;size:64"`
|
||||
Name string `gorm:"size:255;not null"`
|
||||
Pool string `gorm:"size:64;index;not null"`
|
||||
Kind string `gorm:"size:64;index;not null"`
|
||||
Cookie string `gorm:"type:text"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
IntervalSeconds int `gorm:"not null;default:54000"`
|
||||
ImportedAt *time.Time
|
||||
LastAttemptAt *time.Time
|
||||
LastSuccessAt *time.Time
|
||||
LastError string `gorm:"type:text"`
|
||||
NextRetryAt *time.Time
|
||||
ConsecutiveFailures int `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// ChannelModel binds a local model_configs row to a channel and records the
|
||||
// upstream model name to send on the wire (the local row's alias stays the
|
||||
// user-facing name).
|
||||
type ChannelModel struct {
|
||||
ChannelID string `gorm:"primaryKey;size:40"`
|
||||
ModelID string `gorm:"primaryKey;size:255;index"`
|
||||
UpstreamModel string `gorm:"size:255;not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type SiteSetting struct {
|
||||
@@ -244,8 +235,8 @@ func AutoMigrateModels() []any {
|
||||
&EventLog{},
|
||||
&ModelConfig{},
|
||||
&CDKCode{},
|
||||
&TokenAccount{},
|
||||
&RefreshProfile{},
|
||||
&Channel{},
|
||||
&ChannelModel{},
|
||||
&SiteSetting{},
|
||||
&StatCounter{},
|
||||
&ConcurrencyGroup{},
|
||||
@@ -263,15 +254,15 @@ func AutoMigrateModels() []any {
|
||||
type Order struct {
|
||||
ID string `gorm:"primaryKey;size:40"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
Amount float64 `gorm:"not null"` // 充值金额(元)
|
||||
Points int `gorm:"not null"` // 到账积分
|
||||
PayType string `gorm:"size:16"` // wxpay | alipay | admin | cdk
|
||||
Status string `gorm:"size:16;index;not null"` // pending | paid | cancelled
|
||||
Amount float64 `gorm:"not null"` // 充值金额(元)
|
||||
Points int `gorm:"not null"` // 到账积分
|
||||
PayType string `gorm:"size:16"` // wxpay | alipay | admin | cdk
|
||||
Status string `gorm:"size:16;index;not null"` // pending | paid | cancelled
|
||||
Source string `gorm:"size:16;index;not null;default:'epay'"` // epay | admin | cdk
|
||||
Remark string `gorm:"type:text"` // e.g. 兑换码 code / 管理员操作说明
|
||||
TradeNo string `gorm:"size:64;index"` // 易支付平台订单号
|
||||
PayInfo string `gorm:"type:text"` // 二维码 url / 跳转 url
|
||||
PayInfoType string `gorm:"size:16"` // qrcode | jump | html | ...
|
||||
Remark string `gorm:"type:text"` // e.g. 兑换码 code / 管理员操作说明
|
||||
TradeNo string `gorm:"size:64;index"` // 易支付平台订单号
|
||||
PayInfo string `gorm:"type:text"` // 二维码 url / 跳转 url
|
||||
PayInfoType string `gorm:"size:16"` // qrcode | jump | html | ...
|
||||
ExpiresAt time.Time `gorm:"index"`
|
||||
PaidAt *time.Time
|
||||
CreatedAt time.Time
|
||||
@@ -294,10 +285,10 @@ type ConcurrencyGroup struct {
|
||||
// node/edge graph as a versioned JSON blob; saves are whole-document
|
||||
// replacements (last write wins), which matches the single-editor frontend.
|
||||
type Canvas struct {
|
||||
ID string `gorm:"primaryKey;size:40"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
Title string `gorm:"size:120;not null;default:'未命名画布'"`
|
||||
Data string `gorm:"type:text"`
|
||||
ID string `gorm:"primaryKey;size:40"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
Title string `gorm:"size:120;not null;default:'未命名画布'"`
|
||||
Data string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// captured from live firefly.adobe.com traffic:
|
||||
//
|
||||
// ark: 91818c89a54748463.1048135404|r=ap-southeast-1|…|rid=84|ag=101|…
|
||||
// ftr: dbd9d77a491b4437bc5c4d649a04a794_1785846934401_6890_UDF43-m4_31ck_YRQXWT0P1AE=-7389-v2_tt
|
||||
//
|
||||
// The Arkose slot is deliberately emitted empty rather than synthesized — see
|
||||
// buildARPSessionID — so the expected ftr ends in "_31ck__tt".
|
||||
var (
|
||||
arkPat = regexp.MustCompile(`^[0-9a-f]{17}\.[1-9][0-9]{9}\|r=ap-southeast-1\|.*\|rid=[0-9]{1,2}\|ag=101\|`)
|
||||
ftrPat = regexp.MustCompile(`^[0-9a-f]{32}_[0-9]{13}_[0-9]{4,5}_UDF43-m4_31ck__tt$`)
|
||||
)
|
||||
|
||||
func TestARPSessionIDShape(t *testing.T) {
|
||||
raw, err := base64.StdEncoding.DecodeString(buildARPSessionID("tok-a"))
|
||||
if err != nil {
|
||||
t.Fatalf("not base64: %v", err)
|
||||
}
|
||||
var got struct{ Sid, Ark, Ftr string }
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("not json: %v", err)
|
||||
}
|
||||
if !arkPat.MatchString(got.Ark) {
|
||||
t.Errorf("ark shape mismatch:\n%s", got.Ark)
|
||||
}
|
||||
if !ftrPat.MatchString(got.Ftr) {
|
||||
t.Errorf("ftr shape mismatch:\n%s", got.Ftr)
|
||||
}
|
||||
if len(got.Sid) != 36 {
|
||||
t.Errorf("sid not a uuid: %q", got.Sid)
|
||||
}
|
||||
}
|
||||
|
||||
// ark must differ per call — a frozen blob is a cross-account correlation key.
|
||||
func TestARKVariesAcrossCalls(t *testing.T) {
|
||||
if buildARKBlob() == buildARKBlob() {
|
||||
t.Error("ark is constant across calls")
|
||||
}
|
||||
}
|
||||
|
||||
// pid is stable per token but distinct across tokens.
|
||||
func TestPIDStablePerToken(t *testing.T) {
|
||||
defer ReleasePID("tok-x")
|
||||
defer ReleasePID("tok-y")
|
||||
if allocPID("tok-x") != allocPID("tok-x") {
|
||||
t.Error("pid changed for the same token")
|
||||
}
|
||||
if allocPID("tok-x") == allocPID("tok-y") {
|
||||
t.Error("two tokens share a pid")
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
refreshURL = "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"
|
||||
clientID = "projectx_webapp"
|
||||
scopeValue = "AdobeID,firefly_api,openid"
|
||||
// clioClientID / clioScopeValue mint the firefly.adobe.com token that is
|
||||
// entitled to the Firefly-native seedance models (creative_production scope).
|
||||
// Unlike the Express token this exchange requires user_id and no guest_allowed.
|
||||
clioClientID = "clio-playground-web"
|
||||
clioScopeValue = "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext,additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations,additional_info.roles,account_cluster.read,creative_production,tk_platform,tk_platform_sync,profile"
|
||||
)
|
||||
|
||||
var ErrAdobeCookieEmpty = errors.New("cookie is empty")
|
||||
|
||||
type CookieExchangeResult struct {
|
||||
AccessToken string
|
||||
ExpiresIn int
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
func ExchangeCookieToAccessToken(ctx context.Context, client *http.Client, cookie string) (*CookieExchangeResult, error) {
|
||||
_ = client
|
||||
tlsClient, err := NewClient(clientID, "").newTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exchangeCookieWithTLSClient(ctx, tlsClient, cookie)
|
||||
}
|
||||
|
||||
func normalizeCookie(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if strings.HasPrefix(strings.ToLower(v), "cookie:") {
|
||||
v = strings.TrimSpace(v[len("cookie:"):])
|
||||
}
|
||||
return v
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,517 +0,0 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type modelSpec struct {
|
||||
UpstreamModelID string
|
||||
UpstreamModelVersion string
|
||||
}
|
||||
|
||||
var lumaSize = map[string]map[string][2]int{
|
||||
"720p": {
|
||||
"21:9": {1280, 548}, "16:9": {1280, 720}, "4:3": {960, 720},
|
||||
"1:1": {720, 720}, "3:4": {720, 960}, "9:16": {720, 1280}, "9:21": {548, 1280},
|
||||
},
|
||||
"1080p": {
|
||||
"21:9": {1920, 822}, "16:9": {1920, 1080}, "4:3": {1440, 1080},
|
||||
"1:1": {1080, 1080}, "3:4": {1080, 1440}, "9:16": {1080, 1920}, "9:21": {822, 1920},
|
||||
},
|
||||
"4k": {
|
||||
"21:9": {3840, 1646}, "16:9": {3840, 2160}, "4:3": {2880, 2160},
|
||||
"1:1": {2160, 2160}, "3:4": {2160, 2880}, "9:16": {2160, 3840}, "9:21": {1646, 3840},
|
||||
},
|
||||
}
|
||||
|
||||
var gptImageSize = map[string]map[string][2]int{
|
||||
"1K": {"1:1": {1024, 1024}, "5:4": {1120, 896}, "9:16": {720, 1280}, "21:9": {1456, 624}, "16:9": {1280, 720}, "4:3": {1152, 864}, "3:2": {1248, 832}, "4:5": {896, 1120}, "3:4": {864, 1152}, "2:3": {832, 1248}},
|
||||
"2K": {"1:1": {2048, 2048}, "5:4": {2240, 1792}, "9:16": {1440, 2560}, "21:9": {3024, 1296}, "16:9": {2560, 1440}, "4:3": {2304, 1728}, "3:2": {2496, 1664}, "4:5": {1792, 2240}, "3:4": {1728, 2304}, "2:3": {1664, 2496}},
|
||||
"4K": {"1:1": {2880, 2880}, "5:4": {3200, 2560}, "9:16": {2160, 3840}, "21:9": {3696, 1584}, "16:9": {3840, 2160}, "4:3": {3264, 2448}, "3:2": {3504, 2336}, "4:5": {2560, 3200}, "3:4": {2448, 3264}, "2:3": {2336, 3504}},
|
||||
}
|
||||
|
||||
var fluxSize = map[string][2]int{
|
||||
"1:1": {1024, 1024},
|
||||
"16:9": {1408, 768},
|
||||
"9:16": {768, 1408},
|
||||
"4:3": {1280, 896},
|
||||
"3:4": {896, 1280},
|
||||
}
|
||||
|
||||
var defaultSize = map[string]map[string][2]int{
|
||||
"1K": {"1:1": {1024, 1024}, "1:8": {384, 3072}, "1:4": {512, 2048}, "16:9": {1360, 768}, "9:16": {768, 1360}, "4:1": {2048, 512}, "4:3": {1152, 864}, "3:4": {864, 1152}, "8:1": {3072, 384}},
|
||||
"2K": {"1:1": {2048, 2048}, "1:8": {768, 6144}, "1:4": {1024, 4096}, "16:9": {2752, 1536}, "9:16": {1536, 2752}, "4:1": {4096, 1024}, "4:3": {2048, 1536}, "3:4": {1536, 2048}, "8:1": {6144, 768}},
|
||||
"4K": {"1:1": {4096, 4096}, "1:8": {1536, 12288}, "1:4": {2048, 8192}, "16:9": {5504, 3072}, "9:16": {3072, 5504}, "4:1": {8192, 2048}, "4:3": {4096, 3072}, "3:4": {3072, 4096}, "8:1": {12288, 1536}},
|
||||
}
|
||||
|
||||
func ResolveModelSpec(modelID string) modelSpec {
|
||||
switch modelID {
|
||||
case "firefly-gpt-image", "firefly-gpt-image-2":
|
||||
return modelSpec{UpstreamModelID: "gpt-image", UpstreamModelVersion: "2"}
|
||||
case "flux-kontext-max":
|
||||
return modelSpec{UpstreamModelID: "flux", UpstreamModelVersion: "fluxKontextMax"}
|
||||
case "nano-banana-2":
|
||||
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-3"}
|
||||
case "nano-banana-pro":
|
||||
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-2"}
|
||||
default:
|
||||
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "gemini-3-pro-image-preview"}
|
||||
}
|
||||
}
|
||||
|
||||
// buildImage5Payload builds the Adobe Firefly Image 5 request. It uses a distinct
|
||||
// schema from the firefly-3p models: NO modelId/size, a top-level aspectRatio
|
||||
// string label and a resolutionLevel (1K→1MP, 2K→4MP). Mirrors a captured
|
||||
// working image-v5.ff.adobe.io request.
|
||||
func buildImage5Payload(prompt, aspectRatio, resolution string, blobIDs []string) map[string]any {
|
||||
p := map[string]any{
|
||||
"n": 1,
|
||||
"seeds": []int{rand.Intn(999999)},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"prompt": prompt,
|
||||
"referenceBlobs": []any{},
|
||||
"modelSpecificPayload": map[string]any{"locale": "en-US", "prompt_reasoner": "quality"},
|
||||
"modelVersion": "image5",
|
||||
"resolutionLevel": image5ResolutionLevel(resolution),
|
||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
// Instruct-edit: aspect ratio is derived from the reference image; sending
|
||||
// aspectRatio is rejected with a validation_error.
|
||||
p["referenceBlobs"] = blobRefs(blobIDs, "general")
|
||||
} else {
|
||||
p["aspectRatio"] = defaultString(aspectRatio, "1:1")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// image5ResolutionLevel maps the UI resolution tier to Image 5's megapixel level.
|
||||
func image5ResolutionLevel(resolution string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(resolution)) {
|
||||
case "1K":
|
||||
return "1MP"
|
||||
case "2K":
|
||||
return "4MP"
|
||||
default:
|
||||
return "4MP"
|
||||
}
|
||||
}
|
||||
|
||||
func BuildImagePayloadCandidates(modelID, prompt, aspectRatio, outputResolution string, blobIDs []string) []map[string]any {
|
||||
spec := ResolveModelSpec(modelID)
|
||||
ratio := defaultString(aspectRatio, "1:1")
|
||||
resolution := defaultString(outputResolution, "2K")
|
||||
|
||||
switch spec.UpstreamModelID {
|
||||
case "gpt-image":
|
||||
return buildGPTImagePayloads(spec, prompt, ratio, resolution, blobIDs)
|
||||
case "flux":
|
||||
return buildFluxPayloads(spec, prompt, ratio, blobIDs)
|
||||
default:
|
||||
return buildDefaultPayloads(spec, prompt, ratio, resolution, blobIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func buildGPTImagePayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any {
|
||||
size := getSize(gptImageSize, resolution, ratio, "1:1")
|
||||
// Mirrors the captured working gpt-image request shape: modelSpecificPayload.size,
|
||||
// generationSettings.detailLevel 3, and NO top-level size / outputResolution
|
||||
// (sending those got 403). Keeps the chosen size via modelSpecificPayload.size
|
||||
// ("WxH") rather than "auto".
|
||||
base := map[string]any{
|
||||
"modelId": spec.UpstreamModelID,
|
||||
"modelVersion": spec.UpstreamModelVersion,
|
||||
"n": 1,
|
||||
"prompt": prompt,
|
||||
"seeds": []int{rand.Intn(999999)},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
||||
"modelSpecificPayload": map[string]any{"size": sizeString(size)},
|
||||
"generationSettings": map[string]any{"detailLevel": 3},
|
||||
}
|
||||
if len(blobIDs) == 0 {
|
||||
return []map[string]any{base}
|
||||
}
|
||||
subject := cloneMap(base)
|
||||
subject["referenceBlobs"] = blobRefs(blobIDs, "subject")
|
||||
return []map[string]any{subject}
|
||||
}
|
||||
|
||||
func buildFluxPayloads(spec modelSpec, prompt, ratio string, blobIDs []string) []map[string]any {
|
||||
size := fluxSize[ratio]
|
||||
if size == [2]int{} {
|
||||
size = fluxSize["1:1"]
|
||||
}
|
||||
base := map[string]any{
|
||||
"modelId": spec.UpstreamModelID,
|
||||
"modelVersion": spec.UpstreamModelVersion,
|
||||
"n": 1,
|
||||
"prompt": prompt,
|
||||
"size": map[string]any{"width": size[0], "height": size[1]},
|
||||
"seeds": []int{rand.Intn(999999)},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
"modelSpecificPayload": map[string]any{
|
||||
"prompt_upsampling": true,
|
||||
"safety_tolerance": 2,
|
||||
"aspect_ratio": ratio,
|
||||
},
|
||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
||||
}
|
||||
if len(blobIDs) == 0 {
|
||||
return []map[string]any{base}
|
||||
}
|
||||
edited := cloneMap(base)
|
||||
edited["generationMetadata"] = map[string]any{"module": "image2image", "submodule": "ff-image-generate"}
|
||||
edited["referenceBlobs"] = blobRefs(blobIDs, "general")
|
||||
return []map[string]any{edited}
|
||||
}
|
||||
|
||||
func buildDefaultPayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any {
|
||||
size := getSize(defaultSize, resolution, ratio, "16:9")
|
||||
// Shape mirrors a captured working firefly.adobe.com request exactly: top-level
|
||||
// size object, modelSpecificPayload only {parameters:{addWatermark:false}},
|
||||
// groundSearch:false, module "text2image" (even with a reference blob). NO
|
||||
// skipCai and NO modelSpecificPayload.aspectRatio — sending those got 403.
|
||||
base := map[string]any{
|
||||
"modelId": spec.UpstreamModelID,
|
||||
"modelVersion": spec.UpstreamModelVersion,
|
||||
"n": 1,
|
||||
"prompt": prompt,
|
||||
"size": map[string]any{"width": size[0], "height": size[1]},
|
||||
"seeds": []int{rand.Intn(999999)},
|
||||
"groundSearch": false,
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"generationMetadata": map[string]any{
|
||||
"module": "text2image",
|
||||
"submodule": "ff-image-generate",
|
||||
},
|
||||
"modelSpecificPayload": map[string]any{
|
||||
"parameters": map[string]any{"addWatermark": false},
|
||||
},
|
||||
}
|
||||
if len(blobIDs) == 0 {
|
||||
base["referenceBlobs"] = []any{}
|
||||
return []map[string]any{base}
|
||||
}
|
||||
edited := cloneMap(base)
|
||||
edited["referenceBlobs"] = blobRefs(blobIDs, "general")
|
||||
return []map[string]any{edited}
|
||||
}
|
||||
|
||||
func getSize(table map[string]map[string][2]int, resolution, ratio, fallbackRatio string) [2]int {
|
||||
level := defaultString(resolution, "2K")
|
||||
levelTable, ok := table[level]
|
||||
if !ok {
|
||||
levelTable = table["2K"]
|
||||
}
|
||||
size, ok := levelTable[ratio]
|
||||
if !ok {
|
||||
size = levelTable[fallbackRatio]
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func sizeString(size [2]int) string {
|
||||
return itoa(size[0]) + "x" + itoa(size[1])
|
||||
}
|
||||
|
||||
func blobRefs(ids []string, usage string) []any {
|
||||
out := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]any{"id": id, "usage": usage})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func referenceImagesByID(ids []string) []any {
|
||||
out := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]any{"id": id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func referenceImagesByLocal(ids []string) []any {
|
||||
out := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]any{"localBlobRef": id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMap(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs, videoBlobIDs, audioBlobIDs []string) map[string]any {
|
||||
seedVal := rand.Intn(999999)
|
||||
engine = defaultString(engine, "sora2")
|
||||
resolution = defaultString(resolution, "720p")
|
||||
aspectRatio = defaultString(aspectRatio, "16:9")
|
||||
if durationSeconds <= 0 {
|
||||
durationSeconds = 5
|
||||
}
|
||||
|
||||
switch engine {
|
||||
case "firefly-video":
|
||||
// Firefly-native video model — a distinct schema (mirrors a captured
|
||||
// working video-v1.ff.adobe.io request): sizes[] carries width/height +
|
||||
// numFrames (numFrames encodes duration, ~25.6fps so 5s = 128), and
|
||||
// reference frames go under image.conditions with placement.start
|
||||
// (0 = first frame / 首帧, 1 = last frame / 末帧). NO modelId / version /
|
||||
// engine / duration / referenceBlobs fields.
|
||||
w, h, frames := fireflyVideoSize(aspectRatio, resolution, durationSeconds)
|
||||
payload := map[string]any{
|
||||
"addOnTransparentBackground": false,
|
||||
"prompt": prompt,
|
||||
"seeds": []int{seedVal},
|
||||
"sizes": []any{map[string]any{"width": w, "height": h, "numFrames": frames}},
|
||||
"videoSettings": map[string]any{},
|
||||
"locale": "en-US",
|
||||
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
conds := make([]any, 0, 2)
|
||||
conds = append(conds, map[string]any{
|
||||
"source": map[string]any{"id": blobIDs[0]},
|
||||
"placement": map[string]any{"start": 0},
|
||||
})
|
||||
if len(blobIDs) > 1 {
|
||||
conds = append(conds, map[string]any{
|
||||
"source": map[string]any{"id": blobIDs[1]},
|
||||
"placement": map[string]any{"start": 1},
|
||||
})
|
||||
}
|
||||
payload["image"] = map[string]any{"conditions": conds}
|
||||
}
|
||||
return payload
|
||||
case "veo31-fast", "veo31-standard":
|
||||
modelVersion := "3.1-fast-generate"
|
||||
if engine == "veo31-standard" {
|
||||
modelVersion = "3.1-generate"
|
||||
}
|
||||
payload := map[string]any{
|
||||
"modelId": "veo",
|
||||
"modelVersion": modelVersion,
|
||||
"size": videoSize(aspectRatio, resolution),
|
||||
"seeds": []int{seedVal},
|
||||
"prompt": prompt,
|
||||
"negativePrompt": "",
|
||||
"duration": durationSeconds,
|
||||
"generateAudio": false,
|
||||
"generationMetadata": map[string]any{
|
||||
"module": "text2video",
|
||||
"submodule": "ff-video-generate",
|
||||
},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
|
||||
refs := make([]any, 0, min(len(blobIDs), 3))
|
||||
if referenceMode == "frame" {
|
||||
for idx, id := range blobIDs[:min(len(blobIDs), 3)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
|
||||
}
|
||||
} else {
|
||||
for _, id := range blobIDs[:min(len(blobIDs), 3)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "asset"})
|
||||
}
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
}
|
||||
return payload
|
||||
case "seedance-2.0-fast", "seedance-2.0":
|
||||
modelVersion := "seedance_2.0"
|
||||
if engine == "seedance-2.0-fast" {
|
||||
modelVersion = "seedance_2.0_fast"
|
||||
}
|
||||
payload := map[string]any{
|
||||
"modelId": "seedance",
|
||||
"modelVersion": modelVersion,
|
||||
"size": videoSize(aspectRatio, resolution),
|
||||
"seeds": []int{seedVal},
|
||||
"prompt": prompt,
|
||||
"negativePrompt": "",
|
||||
"duration": durationSeconds,
|
||||
"generateAudio": true,
|
||||
"generationSettings": map[string]any{"aspectRatio": aspectRatio},
|
||||
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
// Seedance keeps module "text2video" even with reference blobs
|
||||
// (HAR-confirmed) — unlike veo/luma it does not switch to image2video.
|
||||
if referenceMode == "frame" {
|
||||
refs := make([]any, 0, min(len(blobIDs), 2))
|
||||
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
} else {
|
||||
refs := make([]any, 0, min(len(blobIDs), 9))
|
||||
for _, id := range blobIDs[:min(len(blobIDs), 9)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "style"})
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
}
|
||||
}
|
||||
// Video/audio source refs (seedance)
|
||||
for _, id := range videoBlobIDs[:min(len(videoBlobIDs), 3)] {
|
||||
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
|
||||
}
|
||||
for _, id := range audioBlobIDs[:min(len(audioBlobIDs), 3)] {
|
||||
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
|
||||
}
|
||||
return payload
|
||||
case "luma":
|
||||
payload := map[string]any{
|
||||
"modelId": "luma",
|
||||
"modelVersion": "3.14-ray",
|
||||
"size": lumaVideoSize(aspectRatio, resolution),
|
||||
"mode": "flex_2",
|
||||
"prompt": prompt,
|
||||
"negativePrompt": "",
|
||||
"duration": durationSeconds,
|
||||
"generationMetadata": map[string]any{
|
||||
"module": "text2video",
|
||||
"submodule": "ff-video-generate",
|
||||
},
|
||||
"modelSpecificPayload": map[string]any{
|
||||
"resolution": strings.ToLower(resolution),
|
||||
"aspect_ratio": aspectRatio,
|
||||
},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
payload["generationMetadata"] = map[string]any{
|
||||
"module": "image2video",
|
||||
"submodule": "ff-video-generate",
|
||||
}
|
||||
refs := make([]any, 0, min(len(blobIDs), 2))
|
||||
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
}
|
||||
return payload
|
||||
default:
|
||||
upstream := defaultString(upstreamModel, "openai:firefly:colligo:sora2")
|
||||
payload := map[string]any{
|
||||
"n": 1,
|
||||
"seeds": []int{seedVal},
|
||||
"modelId": "sora",
|
||||
"modelVersion": "sora-2",
|
||||
"size": videoSize(aspectRatio, resolution),
|
||||
"duration": durationSeconds,
|
||||
"fps": 24,
|
||||
"prompt": buildVideoPromptJSON(prompt, durationSeconds),
|
||||
"generationMetadata": map[string]any{"module": "text2video"},
|
||||
"model": upstream,
|
||||
"generateAudio": true,
|
||||
"generateLoop": false,
|
||||
"transparentBackground": false,
|
||||
"seed": itoa(seedVal),
|
||||
"locale": "en-US",
|
||||
"camera": map[string]any{"angle": "none", "shotSize": "none", "motion": nil, "promptStyle": nil},
|
||||
"negativePrompt": "",
|
||||
"jobMode": "standard",
|
||||
"debugGenerationEndpoint": "",
|
||||
"referenceBlobs": []any{},
|
||||
"referenceFrames": []any{},
|
||||
"referenceVideo": nil,
|
||||
"cameraMotionReferenceVideo": nil,
|
||||
"characterReference": nil,
|
||||
"editReferenceVideo": nil,
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
firstID := blobIDs[0]
|
||||
payload["generationMetadata"] = map[string]any{"module": "image2video"}
|
||||
payload["referenceBlobs"] = []any{
|
||||
map[string]any{"id": firstID, "usage": "general", "promptReference": 1},
|
||||
}
|
||||
payload["referenceFrames"] = []any{map[string]any{"localBlobRef": firstID}, nil}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
// fireflyVideoSizeTable maps the firefly-video resolution tier + aspect ratio to
|
||||
// pixel dimensions. Only 1080p 9:16 (1080x1920) is HAR-confirmed; the rest follow
|
||||
// the standard 540p/720p/1080p grid for each ratio.
|
||||
var fireflyVideoSizeTable = map[string]map[string][2]int{
|
||||
"540p": {"16:9": {960, 540}, "1:1": {540, 540}, "9:16": {540, 960}},
|
||||
"720p": {"16:9": {1280, 720}, "1:1": {720, 720}, "9:16": {720, 1280}},
|
||||
"1080p": {"16:9": {1920, 1080}, "1:1": {1080, 1080}, "9:16": {1080, 1920}},
|
||||
}
|
||||
|
||||
// fireflyVideoSize returns width, height and numFrames. numFrames encodes the
|
||||
// clip length (~25.6fps; 5s = 128 frames, HAR-confirmed).
|
||||
func fireflyVideoSize(aspectRatio, resolution string, durationSeconds int) (int, int, int) {
|
||||
table, ok := fireflyVideoSizeTable[strings.ToLower(defaultString(resolution, "1080p"))]
|
||||
if !ok {
|
||||
table = fireflyVideoSizeTable["1080p"]
|
||||
}
|
||||
wh, ok := table[defaultString(aspectRatio, "9:16")]
|
||||
if !ok {
|
||||
wh = table["9:16"]
|
||||
}
|
||||
frames := durationSeconds * 128 / 5
|
||||
if frames <= 0 {
|
||||
frames = 128
|
||||
}
|
||||
return wh[0], wh[1], frames
|
||||
}
|
||||
|
||||
func videoSize(aspectRatio, resolution string) map[string]any {
|
||||
if strings.EqualFold(resolution, "1080p") {
|
||||
if aspectRatio == "16:9" {
|
||||
return map[string]any{"width": 1920, "height": 1080}
|
||||
}
|
||||
return map[string]any{"width": 1080, "height": 1920}
|
||||
}
|
||||
if aspectRatio == "16:9" {
|
||||
return map[string]any{"width": 1280, "height": 720}
|
||||
}
|
||||
return map[string]any{"width": 720, "height": 1280}
|
||||
}
|
||||
|
||||
func lumaVideoSize(aspectRatio, resolution string) map[string]any {
|
||||
table, ok := lumaSize[strings.ToLower(defaultString(resolution, "720p"))]
|
||||
if !ok {
|
||||
table = lumaSize["720p"]
|
||||
}
|
||||
size, ok := table[defaultString(aspectRatio, "16:9")]
|
||||
if !ok {
|
||||
size = table["16:9"]
|
||||
}
|
||||
return map[string]any{"width": size[0], "height": size[1]}
|
||||
}
|
||||
|
||||
func buildVideoPromptJSON(prompt string, durationSeconds int) string {
|
||||
payload := map[string]any{
|
||||
"id": 1,
|
||||
"duration_sec": durationSeconds,
|
||||
"prompt_text": prompt,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// adobeUserIDPat matches Adobe IMS user IDs embedded in cookies (e.g.
|
||||
// "4BDA81F069FC6DA40A495FAB@AdobeID").
|
||||
var adobeUserIDPat = regexp.MustCompile(`[A-Fa-f0-9]{20,}@AdobeID`)
|
||||
|
||||
// PID pool: one PID per token, reused across requests for the same account.
|
||||
var (
|
||||
arpPIDMu sync.Mutex
|
||||
arpTokenPID = map[string]int{} // token → pid
|
||||
arpPIDToken = map[int]string{} // pid → token
|
||||
)
|
||||
|
||||
func extractUserIDFromCookie(cookie string) string {
|
||||
decoded, err := url.QueryUnescape(cookie)
|
||||
if err != nil {
|
||||
decoded = cookie
|
||||
}
|
||||
return adobeUserIDPat.FindString(decoded)
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return strings.TrimSpace(strings.ReplaceAll(toJSONScalar(x), "\n", " "))
|
||||
}
|
||||
}
|
||||
|
||||
func toJSONScalar(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case float32:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func defaultString(v, fallback string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
|
||||
func decodeJWTPayload(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
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
|
||||
}
|
||||
|
||||
// arkHead and arkTail are the site-constant halves of the ark blob, as captured
|
||||
// from firefly.adobe.com. The leading "<17hex>.<10digits>" pair and the rid
|
||||
// field sit between them and vary per session, so buildARKBlob randomizes those
|
||||
// instead of shipping one frozen blob for every account.
|
||||
const (
|
||||
arkHead = "|r=ap-southeast-1|meta=3|metabgclr=transparent|metaiconclr=%23757575|guitextcolor=%23000000|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40|sup=1"
|
||||
arkTail = "|ag=101|cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|surl=https%3A%2F%2Farks-client.adobe.com|smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager"
|
||||
)
|
||||
|
||||
func buildARKBlob() string {
|
||||
return randomHex(9)[:17] + "." + randomDigits(10) +
|
||||
arkHead + "|rid=" + strconv.Itoa(randomInt(1, 99)) + arkTail
|
||||
}
|
||||
|
||||
// buildARPSessionID returns the x-arp-session-id value: base64 of
|
||||
// {"sid","ark","ftr"}, shaped to match live firefly.adobe.com traffic:
|
||||
//
|
||||
// ark: <17hex>.<10digits>|…|rid=<n>|…
|
||||
// ftr: <32hex>_<unix_ms>_<pid>_UDF43-m4_31ck__tt
|
||||
//
|
||||
// The pid is allocated per token (allocPID) so it stays stable across an
|
||||
// account's requests, the way a real browser session would.
|
||||
//
|
||||
// The Arkose segment is left empty on purpose. In a real browser that slot
|
||||
// carries a "<b64>=-<4digits>-v2" payload emitted by Adobe's Arkose bot-manager
|
||||
// script; a synthesized one has the right shape but fails Adobe's server-side
|
||||
// validation, and 3p-images/generate-async rejects it with a misleading
|
||||
// {"error_code":"timeout_error","message":"system under load"}. Sending the
|
||||
// slot empty reads as "no Arkose payload yet" and is accepted.
|
||||
func buildARPSessionID(token string) string {
|
||||
ftr := strings.Join([]string{
|
||||
randomHex(16), // 32 hex chars, matching the capture
|
||||
strconv.FormatInt(time.Now().UnixMilli(), 10),
|
||||
strconv.Itoa(allocPID(token)),
|
||||
"UDF43-m4",
|
||||
"31ck",
|
||||
"",
|
||||
"tt",
|
||||
}, "_")
|
||||
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"sid": uuid.NewString(),
|
||||
"ark": buildARKBlob(),
|
||||
"ftr": ftr,
|
||||
})
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// randomDigits returns n decimal digits with a non-zero leading digit, so the
|
||||
// rendered value keeps a constant width.
|
||||
func randomDigits(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(n)
|
||||
b.WriteByte(byte('1' + randomInt(0, 8)))
|
||||
for i := 1; i < n; i++ {
|
||||
b.WriteByte(byte('0' + randomInt(0, 9)))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func randomBase64(n int) string {
|
||||
buf := make([]byte, n)
|
||||
rand.Read(buf)
|
||||
return base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func allocPID(token string) int {
|
||||
arpPIDMu.Lock()
|
||||
defer arpPIDMu.Unlock()
|
||||
if pid, ok := arpTokenPID[token]; ok {
|
||||
return pid
|
||||
}
|
||||
for {
|
||||
pid := randomInt(1000, 99999)
|
||||
if _, used := arpPIDToken[pid]; !used {
|
||||
arpPIDToken[pid] = token
|
||||
arpTokenPID[token] = pid
|
||||
return pid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReleasePID releases the PID bound to token so it can be reused by another
|
||||
// account.
|
||||
func ReleasePID(token string) {
|
||||
arpPIDMu.Lock()
|
||||
defer arpPIDMu.Unlock()
|
||||
if pid, ok := arpTokenPID[token]; ok {
|
||||
delete(arpPIDToken, pid)
|
||||
delete(arpTokenPID, token)
|
||||
}
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
now := time.Now().UnixNano()
|
||||
for i := range buf {
|
||||
buf[i] = byte(now >> ((i % 8) * 8))
|
||||
}
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func randomInt(min, max int) int {
|
||||
if max <= min {
|
||||
return min
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
|
||||
if err != nil {
|
||||
return min
|
||||
}
|
||||
return min + int(n.Int64())
|
||||
}
|
||||
|
||||
func intOrNil(v any) any {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case float32:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, err := x.Int64()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return int(n)
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(x))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return n
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,151 +0,0 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
var (
|
||||
cores = []int{8, 16, 24, 32}
|
||||
documentKeys = []string{"__reactContainer$fzelfjyxej8", "_reactListening5dehydibo78", "location"}
|
||||
screenResolutions = [][2]int{{1920, 1080}, {1440, 900}, {2560, 1440}, {3840, 2160}}
|
||||
navKeys = []string{
|
||||
"registerProtocolHandler−function registerProtocolHandler() { [native code] }",
|
||||
"storage−[object StorageManager]",
|
||||
"locks−[object LockManager]",
|
||||
"appCodeName−Mozilla",
|
||||
"permissions−[object Permissions]",
|
||||
"share−function share() { [native code] }",
|
||||
"webdriver−false",
|
||||
"vendor−Google Inc.",
|
||||
"mediaDevices−[object MediaDevices]",
|
||||
"cookieEnabled−true",
|
||||
"onLine−true",
|
||||
"mimeTypes−[object MimeTypeArray]",
|
||||
"credentials−[object CredentialsContainer]",
|
||||
"serviceWorker−[object ServiceWorkerContainer]",
|
||||
"keyboard−[object Keyboard]",
|
||||
"gpu−[object GPU]",
|
||||
"doNotTrack",
|
||||
"language−zh-CN",
|
||||
"geolocation−[object Geolocation]",
|
||||
"hardwareConcurrency−32",
|
||||
}
|
||||
winKeys = []string{
|
||||
"0", "window", "self", "document", "name", "location", "history",
|
||||
"navigation", "innerWidth", "innerHeight", "screen", "chrome",
|
||||
"navigator", "performance", "crypto", "indexedDB", "sessionStorage",
|
||||
"localStorage", "fetch", "matchMedia", "postMessage", "setTimeout",
|
||||
"caches", "__NEXT_DATA__",
|
||||
}
|
||||
)
|
||||
|
||||
func buildLegacyRequirementsToken(userAgent string, scriptSources []string, dataBuild string) string {
|
||||
cfg := buildPOWConfig(userAgent, scriptSources, dataBuild)
|
||||
body, _ := json.Marshal(cfg)
|
||||
return "gAAAAAC" + base64.StdEncoding.EncodeToString(body)
|
||||
}
|
||||
|
||||
func buildProofToken(seed, difficulty, userAgent string, scriptSources []string, dataBuild string) (string, error) {
|
||||
cfg := buildPOWConfig(userAgent, scriptSources, dataBuild)
|
||||
answer, solved := powGenerate(seed, difficulty, cfg, 500000)
|
||||
if !solved {
|
||||
return "", errors.New("failed to solve proof token")
|
||||
}
|
||||
return "gAAAAAB" + answer, nil
|
||||
}
|
||||
|
||||
func buildPOWConfig(userAgent string, scriptSources []string, dataBuild string) []any {
|
||||
// scriptSources/dataBuild are no longer part of the sentinel config array
|
||||
// (the current chatgpt.com client dropped them); kept in the signature for
|
||||
// call-site compatibility.
|
||||
_ = scriptSources
|
||||
_ = dataBuild
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
screen := screenResolutions[rng.Intn(len(screenResolutions))]
|
||||
loc := time.FixedZone("GMT+0800", 8*3600)
|
||||
nowLocal := time.Now().In(loc).Format("Mon Jan 02 2006 15:04:05") + " GMT+0800 (中国标准时间)"
|
||||
perf := float64(time.Now().UnixNano()%1_000_000_000) / 1_000_000
|
||||
return []any{
|
||||
screen[0] + screen[1], // [0]
|
||||
nowLocal, // [1] local time, JS Date.toString() shape
|
||||
4395630592, // [2]
|
||||
1, // [3] overwritten by powGenerate counter
|
||||
userAgent, // [4]
|
||||
nil, // [5] (was script source; now null)
|
||||
defaultClientVersion, // [6] oai-client-version, must match header
|
||||
"zh-CN", // [7] matches oai-language
|
||||
"zh-CN,en,en-GB,en-US", // [8]
|
||||
rng.Float64(), // [9] overwritten by powGenerate counter
|
||||
navKeys[rng.Intn(len(navKeys))],
|
||||
documentKeys[rng.Intn(len(documentKeys))],
|
||||
winKeys[rng.Intn(len(winKeys))],
|
||||
perf, // [13]
|
||||
newUUID(), // [14]
|
||||
"", // [15]
|
||||
cores[rng.Intn(len(cores))], // [16]
|
||||
float64(timeMillis()) - perf, // [17]
|
||||
0, 0, 0, 0, 0, 0,
|
||||
0,
|
||||
}
|
||||
}
|
||||
|
||||
func powGenerate(seed, difficulty string, cfg []any, limit int) (string, bool) {
|
||||
target, err := hex.DecodeString(strings.TrimSpace(difficulty))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
diffLen := len(strings.TrimSpace(difficulty)) / 2
|
||||
seedBytes := []byte(seed)
|
||||
head1, _ := json.Marshal(cfg[:3])
|
||||
head2, _ := json.Marshal(cfg[4:9])
|
||||
head3, _ := json.Marshal(cfg[10:])
|
||||
static1 := []byte(string(head1[:len(head1)-1]) + ",")
|
||||
static2 := []byte("," + string(head2[1:len(head2)-1]) + ",")
|
||||
static3 := []byte("," + string(head3[1:]))
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
finalJSON := append([]byte{}, static1...)
|
||||
finalJSON = append(finalJSON, []byte(strconvItoa(i))...)
|
||||
finalJSON = append(finalJSON, static2...)
|
||||
finalJSON = append(finalJSON, []byte(strconvItoa(i>>1))...)
|
||||
finalJSON = append(finalJSON, static3...)
|
||||
encoded := base64.StdEncoding.EncodeToString(finalJSON)
|
||||
sum := sha3.Sum512(append(seedBytes, []byte(encoded)...))
|
||||
if bytesCompare(sum[:diffLen], target) <= 0 {
|
||||
return encoded, true
|
||||
}
|
||||
}
|
||||
fallback := "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + base64.StdEncoding.EncodeToString([]byte(`"`+seed+`"`))
|
||||
return fallback, false
|
||||
}
|
||||
|
||||
func bytesCompare(a, b []byte) int {
|
||||
for i := 0; i < len(a) && i < len(b); i++ {
|
||||
if a[i] < b[i] {
|
||||
return -1
|
||||
}
|
||||
if a[i] > b[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if len(a) < len(b) {
|
||||
return -1
|
||||
}
|
||||
if len(a) > len(b) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func strconvItoa(v int) string {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type orderedMap struct {
|
||||
keys []string
|
||||
values map[string]any
|
||||
}
|
||||
|
||||
func newOrderedMap() *orderedMap {
|
||||
return &orderedMap{values: map[string]any{}}
|
||||
}
|
||||
|
||||
func (m *orderedMap) add(key string, value any) {
|
||||
if _, ok := m.values[key]; !ok {
|
||||
m.keys = append(m.keys, key)
|
||||
}
|
||||
m.values[key] = value
|
||||
}
|
||||
|
||||
func solveTurnstileToken(dx, p string) string {
|
||||
decoded, err := base64.StdEncoding.DecodeString(dx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var tokenList [][]any
|
||||
if err := json.Unmarshal([]byte(xorString(string(decoded), p)), &tokenList); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
processMap := map[int]any{16: p}
|
||||
start := time.Now()
|
||||
result := ""
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
toStr := func(value any) string {
|
||||
if value == nil {
|
||||
return "undefined"
|
||||
}
|
||||
if s, ok := value.(string); ok {
|
||||
special := map[string]string{
|
||||
"window.Math": "[object Math]",
|
||||
"window.Reflect": "[object Reflect]",
|
||||
"window.performance": "[object Performance]",
|
||||
"window.localStorage": "[object Storage]",
|
||||
"window.Object": "function Object() { [native code] }",
|
||||
"window.Reflect.set": "function set() { [native code] }",
|
||||
"window.performance.now": "function () { [native code] }",
|
||||
"window.Object.create": "function create() { [native code] }",
|
||||
"window.Object.keys": "function keys() { [native code] }",
|
||||
"window.Math.random": "function random() { [native code] }",
|
||||
}
|
||||
if specialValue, ok := special[s]; ok {
|
||||
return specialValue
|
||||
}
|
||||
return s
|
||||
}
|
||||
if list, ok := value.([]string); ok {
|
||||
return strings.Join(list, ",")
|
||||
}
|
||||
return stringValue(value)
|
||||
}
|
||||
|
||||
for _, token := range tokenList {
|
||||
if len(token) == 0 {
|
||||
continue
|
||||
}
|
||||
op := intValue(token[0])
|
||||
switch op {
|
||||
case 2:
|
||||
if len(token) >= 3 {
|
||||
processMap[intValue(token[1])] = token[2]
|
||||
}
|
||||
case 3:
|
||||
if len(token) >= 2 {
|
||||
result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])])))
|
||||
}
|
||||
case 5:
|
||||
if len(token) >= 3 {
|
||||
e := intValue(token[1])
|
||||
t := intValue(token[2])
|
||||
cur := processMap[e]
|
||||
inc := processMap[t]
|
||||
if list, ok := cur.([]any); ok {
|
||||
processMap[e] = append(list, inc)
|
||||
} else if _, ok := cur.(string); ok {
|
||||
processMap[e] = toStr(cur) + toStr(inc)
|
||||
} else {
|
||||
processMap[e] = "NaN"
|
||||
}
|
||||
}
|
||||
case 6, 24:
|
||||
if len(token) >= 4 {
|
||||
e := intValue(token[1])
|
||||
t := toStr(processMap[intValue(token[2])])
|
||||
n := toStr(processMap[intValue(token[3])])
|
||||
v := t + "." + n
|
||||
if op == 6 && v == "window.document.location" {
|
||||
v = "https://chatgpt.com/"
|
||||
}
|
||||
processMap[e] = v
|
||||
}
|
||||
case 8:
|
||||
if len(token) >= 3 {
|
||||
processMap[intValue(token[1])] = processMap[intValue(token[2])]
|
||||
}
|
||||
case 14:
|
||||
if len(token) >= 3 {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(toStr(processMap[intValue(token[2])])), &parsed); err == nil {
|
||||
processMap[intValue(token[1])] = parsed
|
||||
}
|
||||
}
|
||||
case 15:
|
||||
if len(token) >= 3 {
|
||||
b, _ := json.Marshal(processMap[intValue(token[2])])
|
||||
processMap[intValue(token[1])] = string(b)
|
||||
}
|
||||
case 17:
|
||||
if len(token) >= 3 {
|
||||
e := intValue(token[1])
|
||||
target := toStr(processMap[intValue(token[2])])
|
||||
switch target {
|
||||
case "window.performance.now":
|
||||
processMap[e] = float64(time.Since(start).Nanoseconds())/1e6 + rng.Float64()
|
||||
case "window.Object.create":
|
||||
processMap[e] = newOrderedMap()
|
||||
case "window.Object.keys":
|
||||
processMap[e] = []string{
|
||||
"STATSIG_LOCAL_STORAGE_INTERNAL_STORE_V4",
|
||||
"STATSIG_LOCAL_STORAGE_STABLE_ID",
|
||||
"client-correlated-secret",
|
||||
"oai/apps/capExpiresAt",
|
||||
"oai-did",
|
||||
"STATSIG_LOCAL_STORAGE_LOGGING_REQUEST",
|
||||
"UiState.isNavigationCollapsed.1",
|
||||
}
|
||||
case "window.Math.random":
|
||||
processMap[e] = rng.Float64()
|
||||
}
|
||||
}
|
||||
case 18:
|
||||
if len(token) >= 2 {
|
||||
raw, err := base64.StdEncoding.DecodeString(toStr(processMap[intValue(token[1])]))
|
||||
if err == nil {
|
||||
processMap[intValue(token[1])] = string(raw)
|
||||
}
|
||||
}
|
||||
case 19:
|
||||
if len(token) >= 2 {
|
||||
processMap[intValue(token[1])] = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])])))
|
||||
}
|
||||
case 20:
|
||||
if len(token) >= 4 {
|
||||
if toStr(processMap[intValue(token[1])]) == toStr(processMap[intValue(token[2])]) {
|
||||
if intValue(token[3]) == 3 && len(token) >= 5 {
|
||||
result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[4])])))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func xorString(text, key string) string {
|
||||
if key == "" {
|
||||
return text
|
||||
}
|
||||
out := make([]rune, 0, len(text))
|
||||
keyRunes := []rune(key)
|
||||
for i, ch := range text {
|
||||
out = append(out, ch^keyRunes[i%len(keyRunes)])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://chatgpt.com"
|
||||
defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0"
|
||||
defaultClientVersion = "prod-db390ebea64862bf1899c420a4c736e0cf639747"
|
||||
defaultClientBuildNumber = "7904904"
|
||||
defaultPOWScript = "https://chatgpt.com/backend-api/sentinel/sdk.js"
|
||||
// sseIdleGrace bounds how long startImageGeneration keeps reading the image
|
||||
// SSE with no activity at all before giving up. ChatGPT's inline image pipeline
|
||||
// holds the stream open with periodic ": ping" keepalives (~15s apart) while
|
||||
// the image renders, so this is an *idle* timeout reset on every received line
|
||||
// (see the watchdog in startImageGeneration): it only fires when the stream is
|
||||
// genuinely silent, not on the normal render wait.
|
||||
sseIdleGrace = 45 * time.Second
|
||||
|
||||
// pictureV2Command is the "@创建图片" ecosystem mention the ChatGPT web app
|
||||
// prepends to an image (picture_v2) prompt; the server identifies the image
|
||||
// command from this mention plus its custom_symbol_offset, so we send it to
|
||||
// match the browser exactly.
|
||||
pictureV2Command = "@创建图片"
|
||||
// pictureV2MentionEnd is the UTF-16 length of pictureV2Command — the
|
||||
// ecosystemMention custom_symbol_offset endIndex the web app reports.
|
||||
pictureV2MentionEnd = 5
|
||||
|
||||
// submitConnectRetries / submitRetryBackoff bound how many times the image
|
||||
// submit (the proxied /backend-api/f/conversation POST) is retried on a bare
|
||||
// connection error (EOF / reset) before surfacing it. The proxy hop
|
||||
// occasionally drops the connection; a fresh retry usually succeeds without
|
||||
// failing over to another account.
|
||||
submitConnectRetries = 2
|
||||
submitRetryBackoff = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
fileServiceIDPattern = regexp.MustCompile(`file-service://([A-Za-z0-9_-]+)`)
|
||||
sedimentIDPattern = regexp.MustCompile(`sediment://([A-Za-z0-9_-]+)`)
|
||||
realImageIDPattern = regexp.MustCompile(`\bfile_00000000[a-f0-9]{24}\b`)
|
||||
conversationIDRE = regexp.MustCompile(`"conversation_id"\s*:\s*"([^"]+)"`)
|
||||
scriptSrcRE = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
|
||||
dataBuildPathRE = regexp.MustCompile(`c/[^/]*/_`)
|
||||
htmlDataBuildRE = regexp.MustCompile(`<html[^>]*data-build="([^"]*)"`)
|
||||
|
||||
// asyncMarkers signal that ChatGPT accepted the prompt and switched to the
|
||||
// async image pipeline (image is delivered later via conversation polling
|
||||
// rather than inline in the SSE stream). Their presence means "generating —
|
||||
// keep polling", NOT failure.
|
||||
asyncMarkers = []string{"image_gen_async", "image_gen_task_id", "trigger_async_ux", "ImageGenToolTemporal", "image_gen_title"}
|
||||
|
||||
// contentPolicyMarkers are stable substrings of ChatGPT's content-audit
|
||||
// refusal message. When one appears in an assistant turn the prompt was
|
||||
// rejected upstream — no image will ever arrive, so we must fail fast
|
||||
// instead of polling until timeout.
|
||||
contentPolicyMarkers = []string{
|
||||
"this request may violate our content polic",
|
||||
"this prompt may violate our content polic",
|
||||
"may violate our content policies",
|
||||
"i can't help with",
|
||||
"i can\u2019t help with",
|
||||
"i cannot help with",
|
||||
"i can't assist with",
|
||||
"i can\u2019t assist with",
|
||||
"i cannot assist with",
|
||||
"i'm unable to help with",
|
||||
"i\u2019m unable to help with",
|
||||
"can't create images",
|
||||
"can\u2019t create images",
|
||||
"cannot create images",
|
||||
"can't generate images",
|
||||
"can\u2019t generate images",
|
||||
"cannot generate images",
|
||||
"unable to create images",
|
||||
"unable to generate images",
|
||||
}
|
||||
)
|
||||
|
||||
// containsAsyncMarker reports whether the SSE payload indicates the async image
|
||||
// pipeline was engaged.
|
||||
func containsAsyncMarker(text string) bool {
|
||||
for _, m := range asyncMarkers {
|
||||
if strings.Contains(text, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectContentPolicyRejection reports whether text contains a ChatGPT content
|
||||
// audit refusal. Matching is case-insensitive for the English variants.
|
||||
func detectContentPolicyRejection(text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
for _, m := range contentPolicyMarkers {
|
||||
if strings.Contains(text, m) || strings.Contains(lower, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// collectText concatenates every string found under value (recursively) into sb.
|
||||
func collectText(value any, sb *strings.Builder) {
|
||||
switch x := value.(type) {
|
||||
case string:
|
||||
sb.WriteString(x)
|
||||
sb.WriteByte('\n')
|
||||
case map[string]any:
|
||||
for _, item := range x {
|
||||
collectText(item, sb)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range x {
|
||||
collectText(item, sb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// conversationRejected scans assistant turns of a fetched conversation for a
|
||||
// content-policy refusal.
|
||||
func conversationRejected(conversation map[string]any) bool {
|
||||
mapping, _ := conversation["mapping"].(map[string]any)
|
||||
for _, rawNode := range mapping {
|
||||
node, _ := rawNode.(map[string]any)
|
||||
message, _ := node["message"].(map[string]any)
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
author, _ := message["author"].(map[string]any)
|
||||
role := strings.ToLower(strings.TrimSpace(stringValue(author["role"])))
|
||||
if role != "assistant" {
|
||||
continue
|
||||
}
|
||||
var sb strings.Builder
|
||||
collectText(message["content"], &sb)
|
||||
if detectContentPolicyRejection(sb.String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// conversationEndedWithoutImage reports whether the model finished its turn
|
||||
// with a plain-text answer and never engaged the async image pipeline (no tool
|
||||
// turn, no image_gen task). ChatGPT localizes audit refusals, so this catches
|
||||
// rejections in ANY language structurally: a closed text-only turn can never
|
||||
// produce an image, and polling further only burns the budget.
|
||||
func conversationEndedWithoutImage(conversation map[string]any) bool {
|
||||
mapping, _ := conversation["mapping"].(map[string]any)
|
||||
if len(mapping) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, rawNode := range mapping {
|
||||
node, _ := rawNode.(map[string]any)
|
||||
message, _ := node["message"].(map[string]any)
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
if metadata, _ := message["metadata"].(map[string]any); metadata != nil {
|
||||
if stringValue(metadata["image_gen_task_id"]) != "" || metadata["image_gen_async"] == true {
|
||||
return false
|
||||
}
|
||||
}
|
||||
author, _ := message["author"].(map[string]any)
|
||||
role := strings.ToLower(strings.TrimSpace(stringValue(author["role"])))
|
||||
if role == "tool" {
|
||||
return false
|
||||
}
|
||||
if role == "assistant" {
|
||||
if content, _ := message["content"].(map[string]any); content != nil {
|
||||
// Only tool-invoking content types mean generation is underway.
|
||||
// Context nodes (model_editable_context, thoughts, …) also appear
|
||||
// on refused turns and must NOT suppress the detection.
|
||||
switch stringValue(content["content_type"]) {
|
||||
case "code", "multimodal_text":
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rawNode := range mapping {
|
||||
node, _ := rawNode.(map[string]any)
|
||||
message, _ := node["message"].(map[string]any)
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
author, _ := message["author"].(map[string]any)
|
||||
if strings.ToLower(strings.TrimSpace(stringValue(author["role"]))) != "assistant" {
|
||||
continue
|
||||
}
|
||||
if message["end_turn"] != true || stringValue(message["status"]) != "finished_successfully" {
|
||||
continue
|
||||
}
|
||||
var sb strings.Builder
|
||||
collectText(message["content"], &sb)
|
||||
if strings.TrimSpace(sb.String()) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isConnResetErr reports whether err is a bare connection-level failure (the
|
||||
// peer/proxy closed the connection before responding) rather than an HTTP-level
|
||||
// error. Such failures are safe to retry: no response was received, so no
|
||||
// conversation was created upstream.
|
||||
func isConnResetErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
for _, m := range []string{"eof", "connection reset", "reset by peer", "broken pipe", "connection refused"} {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case float32:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJWTPayload(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
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 newUUID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
func clip(v []byte, n int) string {
|
||||
s := strings.TrimSpace(string(v))
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
func parsePOWResources(html string) ([]string, string) {
|
||||
matches := scriptSrcRE.FindAllStringSubmatch(html, -1)
|
||||
sources := make([]string, 0, len(matches))
|
||||
dataBuild := ""
|
||||
for _, match := range matches {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
src := strings.TrimSpace(match[1])
|
||||
if src == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, src)
|
||||
if dataBuild == "" {
|
||||
if path := dataBuildPathRE.FindString(src); path != "" {
|
||||
dataBuild = path
|
||||
}
|
||||
}
|
||||
}
|
||||
if dataBuild == "" {
|
||||
if match := htmlDataBuildRE.FindStringSubmatch(html); len(match) >= 2 {
|
||||
dataBuild = strings.TrimSpace(match[1])
|
||||
}
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
sources = []string{defaultPOWScript}
|
||||
}
|
||||
return sources, dataBuild
|
||||
}
|
||||
|
||||
func timeMillis() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
@@ -1,629 +0,0 @@
|
||||
// Package creativefabrica implements the Creative Fabrica Studio
|
||||
// (studio.creativefabrica.com) video-generation upstream.
|
||||
//
|
||||
// One-shot accounts: every account's coins are just enough for exactly one
|
||||
// generation, so a successful render kills the account. The credential is a
|
||||
// .creativefabrica.com session cookie; a short-lived JWT is minted from it on
|
||||
// demand via GraphQL /query/userAuth, and every model request authenticates
|
||||
// with that JWT (the cookie is sent along as a fallback).
|
||||
package creativefabrica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("creativefabrica auth failed")
|
||||
ErrQuotaExhausted = errors.New("creativefabrica quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("creativefabrica upstream temporary error")
|
||||
ErrDeadUpstream = errors.New("creativefabrica upstream fatal error")
|
||||
ErrRateLimited = errors.New("creativefabrica rate limited")
|
||||
// ErrPaymentRequired marks an account whose payment intent is in a failed
|
||||
// state — it can never generate (the studio answers 400 failed_precondition
|
||||
// "payment required ... COIN_PAYMENT_INTENT_STATUS_FAILED"). It wraps ErrAuth
|
||||
// so the pool kills the account and fails over instead of burning retries.
|
||||
ErrPaymentRequired = fmt.Errorf("%w: payment required", ErrAuth)
|
||||
)
|
||||
|
||||
// isPaymentRequired reports whether a non-200 body is the account-level
|
||||
// "payment required" rejection rather than a request-level parameter error.
|
||||
//
|
||||
// Connect unary errors don't always carry the marker in plaintext: the studio
|
||||
// answers failed_precondition with the real detail base64-protobuf-encoded in
|
||||
// details[].value ("payment required. payment status: COIN_PAYMENT_INTENT_...").
|
||||
// Decode those values and scan the decoded bytes, so a failed coin intent still
|
||||
// kills the account instead of being misread as a request-level 400.
|
||||
func isPaymentRequired(status int, body string) bool {
|
||||
if status != 400 {
|
||||
return false
|
||||
}
|
||||
b := strings.ToLower(body)
|
||||
if strings.Contains(b, "payment required") || strings.Contains(b, "coin_payment_intent") {
|
||||
return true
|
||||
}
|
||||
var env struct {
|
||||
Details []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"details"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &env); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, d := range env.Details {
|
||||
raw, err := base64.StdEncoding.DecodeString(d.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(string(raw))
|
||||
if strings.Contains(low, "payment required") ||
|
||||
strings.Contains(low, "coin_payment_intent") ||
|
||||
strings.Contains(low, "coin_error_code_payment_required") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
graphQLHost = "https://graphql-gw.creativefabrica.com"
|
||||
mediaMatrixHost = "https://studio-media-matrix.creativefabrica.com"
|
||||
userAuthPath = "/query/userAuth"
|
||||
userBalancePath = "/query/userBalance"
|
||||
userPath = "/query/user"
|
||||
initiatePath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/InitiateSession"
|
||||
listSessionsPath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/ListSessions"
|
||||
origin = "https://studio.creativefabrica.com"
|
||||
pollInterval = 5 * time.Second
|
||||
pollTimeout = 16 * time.Minute
|
||||
downloadTimeout = 3 * time.Minute
|
||||
videoServiceType = "SERVICE_TYPE_VIDEO_GENERATOR"
|
||||
videoFrameRef = "VIDEO_FRAME_TYPE_REFERENCE"
|
||||
visibilityPrivate = "SESSION_VISIBILITY_PRIVATE"
|
||||
)
|
||||
|
||||
// Model is one Creative Fabrica video model: the local catalog id, the upstream
|
||||
// enum, the fixed duration in seconds, and the upstream resolution label.
|
||||
type Model struct {
|
||||
ID string // local model_configs id, e.g. "seedance-2.0"
|
||||
Enum string // upstream enum, e.g. VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2
|
||||
Duration int // fixed seconds (account plan is fixed-length)
|
||||
Resolution string // upstream resolution label, e.g. 720p
|
||||
}
|
||||
|
||||
// Models returns the two Creative Fabrica seedance models. The upstream enum
|
||||
// differs between the two (SEEDANCE_2_FAST vs SEEDDREAM_2), matching the
|
||||
// studio frontend's InitiateSession payloads.
|
||||
func Models() map[string]Model {
|
||||
return map[string]Model{
|
||||
"seedance-2.0-fast": {
|
||||
ID: "seedance-2.0-fast",
|
||||
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDANCE_2_FAST",
|
||||
Duration: 14,
|
||||
Resolution: "720p",
|
||||
},
|
||||
"seedance-2.0": {
|
||||
ID: "seedance-2.0",
|
||||
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2",
|
||||
Duration: 10,
|
||||
Resolution: "720p",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LookupModel resolves a local model id to its upstream config. Returns ok=false
|
||||
// when the id isn't a Creative Fabrica model.
|
||||
func LookupModel(modelID string) (Model, bool) {
|
||||
m, ok := Models()[modelID]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// Client talks to the Creative Fabrica Studio API through a Chrome-fingerprinted
|
||||
// TLS client so the Cloudflare-protected Connect endpoints don't reject us.
|
||||
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)
|
||||
}
|
||||
|
||||
// ExchangeToken mints the short-lived JWT from the account cookie via GraphQL
|
||||
// /query/userAuth. Returns the token and the user id. A null / missing me means
|
||||
// the cookie no longer authenticates → ErrAuth.
|
||||
func (c *Client) ExchangeToken(ctx context.Context, cookie string) (token, userID string, err error) {
|
||||
query := `{"query":"\n query userAuth {\n me {\n token\n user {\n id\n isTemporary\n }\n }\n}\n "}`
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Me *struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"user"`
|
||||
} `json:"me"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, err := c.postGraphQL(ctx, cookie, "", userAuthPath, query)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", "", fmt.Errorf("%w: bad token response: %s", ErrAuth, clip(body, 300))
|
||||
}
|
||||
if payload.Data.Me == nil || strings.TrimSpace(payload.Data.Me.Token) == "" {
|
||||
return "", "", fmt.Errorf("%w: cookie did not authenticate", ErrAuth)
|
||||
}
|
||||
return payload.Data.Me.Token, payload.Data.Me.User.ID, nil
|
||||
}
|
||||
|
||||
// FetchBalance reads the coin balance via GraphQL /query/userBalance (the
|
||||
// request authenticates with the cookie alone). Negative value on error.
|
||||
func (c *Client) FetchBalance(ctx context.Context, cookie string) (int64, error) {
|
||||
query := `{"query":"\nquery userBalance {\n userBalance {\n balance\n }\n}\n\n"}`
|
||||
var payload struct {
|
||||
Data struct {
|
||||
UserBalance *struct {
|
||||
Balance json.Number `json:"balance"`
|
||||
} `json:"userBalance"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, err := c.postGraphQL(ctx, cookie, "", userBalancePath, query)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return -1, fmt.Errorf("bad balance response: %s", clip(body, 300))
|
||||
}
|
||||
if payload.Data.UserBalance == nil {
|
||||
return -1, ErrAuth
|
||||
}
|
||||
b, _ := payload.Data.UserBalance.Balance.Int64()
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// FetchUser reads the profile (email, name) via GraphQL /query/user, which the
|
||||
// studio browser hits on every page load. The request authenticates with the
|
||||
// cookie alone. Returns the email (empty on error); a null me means the cookie
|
||||
// no longer authenticates → ErrAuth.
|
||||
func (c *Client) FetchUser(ctx context.Context, cookie string) (string, error) {
|
||||
query := `{"query":"\n query user {\n me {\n token\n user {\n id\n email\n }\n }\n}\n "}`
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Me *struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
} `json:"user"`
|
||||
} `json:"me"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, err := c.postGraphQL(ctx, cookie, "", userPath, query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", fmt.Errorf("%w: bad user response: %s", ErrAuth, clip(body, 300))
|
||||
}
|
||||
if payload.Data.Me == nil || payload.Data.Me.User.ID == "" {
|
||||
return "", ErrAuth
|
||||
}
|
||||
return strings.TrimSpace(payload.Data.Me.User.Email), nil
|
||||
}
|
||||
|
||||
// GenerateVideo runs the full generation: InitiateSession → PUT reference
|
||||
// images to the presigned S3 URLs → poll ListSessions until COMPLETED → (when
|
||||
// downloadResult) fetch the MP4. Returns the bytes (nil when url-only) and the
|
||||
// previewMediaUrl. durationSeconds is ignored: the plan fixes the length per
|
||||
// model (LookupModel.Duration).
|
||||
func (c *Client) GenerateVideo(ctx context.Context, cookie, token, modelID, prompt, aspectRatio string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
|
||||
m, ok := LookupModel(modelID)
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("creativefabrica: unknown model %q", modelID)
|
||||
}
|
||||
sessionID, uploads, err := c.initiateSession(ctx, cookie, token, m, prompt, aspectRatio, refs)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// Each ref uploads to the presigned URL the session returned for it.
|
||||
for i, u := range uploads {
|
||||
if err := c.putS3(ctx, u, refs[i]); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
videoURL, err := c.pollSession(ctx, cookie, token, sessionID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, videoURL, nil
|
||||
}
|
||||
data, err := c.download(ctx, videoURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, videoURL, nil
|
||||
}
|
||||
|
||||
// initiateSession submits the generation and returns the session id plus the
|
||||
// presigned upload URLs (one per reference image).
|
||||
func (c *Client) initiateSession(ctx context.Context, cookie, token string, m Model, prompt, aspectRatio string, refs [][]byte) (string, []string, error) {
|
||||
frames := make([]any, 0, len(refs))
|
||||
refPrompt := strings.TrimSpace(prompt)
|
||||
for i := range refs {
|
||||
ref := fmt.Sprintf("img%d", i+1)
|
||||
frames = append(frames, map[string]any{
|
||||
"type": videoFrameRef,
|
||||
"fileSize": fmt.Sprintf("%d", len(refs[i])),
|
||||
"fileName": randomFileName(i + 1),
|
||||
"ref": ref,
|
||||
})
|
||||
if !strings.Contains(refPrompt, "["+ref+"]") {
|
||||
refPrompt += " Use [" + ref + "]"
|
||||
}
|
||||
}
|
||||
reqBody := map[string]any{
|
||||
"visibility": visibilityPrivate,
|
||||
"sessionRequestPromptToVideoGeneratorContent": map[string]any{
|
||||
"serviceType": videoServiceType,
|
||||
"promptContent": map[string]any{"prompt": refPrompt},
|
||||
"resolution": resolutionEnum(m.Resolution),
|
||||
"model": m.Enum,
|
||||
"frames": frames,
|
||||
"aspectRatio": aspectRatioEnum(aspectRatio),
|
||||
"directorConfig": map[string]any{
|
||||
"filmStock": map[string]any{"color": "DIRECTOR_FILM_STOCK_COLOR_FULL_COLOR"},
|
||||
},
|
||||
"videoDuration": map[string]any{"inSeconds": m.Duration},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
resp, err := c.postConnect(ctx, cookie, token, initiatePath, body, true)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var payload struct {
|
||||
Session struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
// The presigned upload URLs are nested under promptToVideoGeneratorContent.frames
|
||||
// (each frame echoes the request frame + the S3 presigned `url`), not under a
|
||||
// top-level session.frames. Reading the wrong level yields 0 uploads.
|
||||
PromptToVideoGeneratorContent struct {
|
||||
Frames []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"frames"`
|
||||
} `json:"promptToVideoGeneratorContent"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &payload); err != nil {
|
||||
return "", nil, fmt.Errorf("creativefabrica bad initiate response: %s", clip(resp, 300))
|
||||
}
|
||||
if strings.TrimSpace(payload.Session.ID) == "" {
|
||||
return "", nil, fmt.Errorf("creativefabrica initiate missing session: %s", clip(resp, 300))
|
||||
}
|
||||
respFrames := payload.Session.PromptToVideoGeneratorContent.Frames
|
||||
uploads := make([]string, 0, len(respFrames))
|
||||
for _, f := range respFrames {
|
||||
if u := strings.TrimSpace(f.URL); u != "" {
|
||||
uploads = append(uploads, u)
|
||||
}
|
||||
}
|
||||
if len(uploads) != len(refs) {
|
||||
return "", nil, fmt.Errorf("creativefabrica initiate returned %d upload urls for %d refs", len(uploads), len(refs))
|
||||
}
|
||||
return payload.Session.ID, uploads, nil
|
||||
}
|
||||
|
||||
// pollSession polls ListSessions until the session is COMPLETED / FAILED and
|
||||
// returns previewMediaUrl on success.
|
||||
func (c *Client) pollSession(ctx context.Context, cookie, token, sessionID string) (string, error) {
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"serviceType": videoServiceType,
|
||||
"pagination": map[string]any{"take": 100},
|
||||
"surface": "SURFACE_STUDIO",
|
||||
})
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("creativefabrica generation timed out after %v", pollTimeout)
|
||||
}
|
||||
resp, err := c.postConnect(ctx, cookie, token, listSessionsPath, reqBody, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload struct {
|
||||
Sessions []map[string]any `json:"sessions"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &payload); err != nil {
|
||||
return "", fmt.Errorf("creativefabrica bad list response: %s", clip(resp, 300))
|
||||
}
|
||||
for _, s := range payload.Sessions {
|
||||
id, _ := s["id"].(string)
|
||||
if id != sessionID {
|
||||
continue
|
||||
}
|
||||
status, _ := s["status"].(string)
|
||||
switch status {
|
||||
case "SESSION_STATUS_COMPLETED":
|
||||
if u := strings.TrimSpace(stringValue(s["previewMediaUrl"])); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
return "", fmt.Errorf("creativefabrica session completed without preview url")
|
||||
case "SESSION_STATUS_FAILED", "SESSION_STATUS_CANCELLED", "SESSION_STATUS_ERROR":
|
||||
detail := ""
|
||||
for _, k := range []string{"errorMessage", "failureReason", "error", "message"} {
|
||||
if v, ok := s[k]; ok {
|
||||
if d := strings.TrimSpace(stringValue(v)); d != "" {
|
||||
detail = d
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("creativefabrica session %s%s", status, withDetail(detail))
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// putS3 uploads a reference image to the presigned URL. S3 doesn't care about
|
||||
// TLS fingerprinting, so a plain client is fine here.
|
||||
func (c *Client) putS3(ctx context.Context, presigned string, data []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, presigned, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "image/png")
|
||||
req.Header.Set("Origin", origin)
|
||||
req.Header.Set("Referer", origin+"/")
|
||||
req.Header.Set("User-Agent", defaultUA())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creativefabrica s3 upload: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("creativefabrica s3 upload failed: %d %s", resp.StatusCode, clip(body, 200))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// download fetches the finished MP4 from the public video-v2 URL.
|
||||
func (c *Client) download(parent context.Context, videoURL string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), downloadTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, videoURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", defaultUA())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: download: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("creativefabrica download failed: %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: read body: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// postGraphQL calls a graphql-gw endpoint with the raw JSON body. Authenticated
|
||||
// by cookie (balance) and optionally the JWT too. Direct connection (no proxy).
|
||||
func (c *Client) postGraphQL(ctx context.Context, cookie, token, path, body string) ([]byte, error) {
|
||||
return c.postJSON(ctx, graphQLHost+path, token, cookie, []byte(body), false, true, false)
|
||||
}
|
||||
|
||||
// postConnect calls a Connect unary endpoint on the media-matrix service. The
|
||||
// Connect-Protocol-Version header marks the POST as a Connect RPC (distinct from
|
||||
// a plain JSON REST POST) — the studio browser always sends it. Only the
|
||||
// InitiateSession call (下单) egresses through the proxy; polling runs direct.
|
||||
func (c *Client) postConnect(ctx context.Context, cookie, token, path string, body []byte, useProxy bool) ([]byte, error) {
|
||||
return c.postJSON(ctx, mediaMatrixHost+path, token, cookie, body, true, false, useProxy)
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, url, token, cookie string, body []byte, connect, graphql, useProxy bool) ([]byte, error) {
|
||||
sess, err := c.newTLSClient(useProxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := fhttp.NewRequest(fhttp.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = fhttp.Header{
|
||||
"content-type": {"application/json"},
|
||||
"accept": {"*/*"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"user-agent": {defaultUA()},
|
||||
}
|
||||
if graphql {
|
||||
req.Header.Set("accept", "application/json, multipart/mixed")
|
||||
}
|
||||
if connect {
|
||||
req.Header.Set("connect-protocol-version", "1")
|
||||
}
|
||||
if cookie != "" {
|
||||
req.Header.Set("cookie", cookie)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := sess.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creativefabrica request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
return nil, fmt.Errorf("%w (%d: %s)", ErrAuth, resp.StatusCode, clip(data, 300))
|
||||
case isPaymentRequired(resp.StatusCode, string(data)):
|
||||
return nil, fmt.Errorf("%w (%d: %s)", ErrPaymentRequired, resp.StatusCode, clip(data, 300))
|
||||
case resp.StatusCode == 429:
|
||||
return nil, fmt.Errorf("%w (429)", ErrRateLimited)
|
||||
case resp.StatusCode >= 500:
|
||||
return nil, fmt.Errorf("%w (%d: %s)", ErrDeadUpstream, resp.StatusCode, clip(data, 300))
|
||||
case resp.StatusCode != 200:
|
||||
return nil, fmt.Errorf("creativefabrica %d: %s", resp.StatusCode, clip(data, 300))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func resolutionEnum(res string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(res)) {
|
||||
case "720p":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
|
||||
case "1080p":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_1080P"
|
||||
default:
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
|
||||
}
|
||||
}
|
||||
|
||||
func aspectRatioEnum(ratio string) string {
|
||||
switch strings.ReplaceAll(strings.TrimSpace(ratio), " ", "") {
|
||||
case "16:9":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
|
||||
case "9:16":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_9_16"
|
||||
case "1:1":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_1_1"
|
||||
case "4:3":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_4_3"
|
||||
case "3:4":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_3_4"
|
||||
default:
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
|
||||
}
|
||||
}
|
||||
|
||||
// randomFileName mimics the studio's client-generated reference name
|
||||
// ("_<base36-ish id>_<n>.png"). The value only needs to be unique per upload.
|
||||
func randomFileName(n int) string {
|
||||
return "_" + randomID() + "_" + fmt.Sprintf("%d", n) + ".png"
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, 26)
|
||||
for i := range b {
|
||||
v, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
b[i] = alphabet[v.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TLS client plumbing: a Chrome-fingerprinted client so the Cloudflare front of
|
||||
// the Connect endpoints sees a plausible browser handshake.
|
||||
var fingerprints = []profiles.ClientProfile{
|
||||
profiles.Chrome_146,
|
||||
profiles.Chrome_144,
|
||||
profiles.Chrome_133,
|
||||
profiles.Chrome_131,
|
||||
}
|
||||
|
||||
type tlsSession struct {
|
||||
client tlsclient.HttpClient
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient(useProxy bool) (*tlsSession, error) {
|
||||
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(fingerprints))))
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(fingerprints[idx.Int64()]),
|
||||
tlsclient.WithNotFollowRedirects(),
|
||||
tlsclient.WithRandomTLSExtensionOrder(),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tlsSession{client: client}, nil
|
||||
}
|
||||
|
||||
func defaultUA() string {
|
||||
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
func clip(v []byte, n int) string {
|
||||
s := strings.TrimSpace(string(v))
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
// stringValue coerces a decoded JSON value to its string form.
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", x)
|
||||
case json.Number:
|
||||
return x.String()
|
||||
case bool:
|
||||
if x {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", x)
|
||||
}
|
||||
}
|
||||
|
||||
// withDetail appends a failure detail to an error message when present.
|
||||
func withDetail(detail string) string {
|
||||
if detail == "" {
|
||||
return ""
|
||||
}
|
||||
return " (" + detail + ")"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package creativefabrica
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBalance(t *testing.T) {
|
||||
cookie := os.Getenv("CF_COOKIE")
|
||||
if cookie == "" {
|
||||
t.Skip("CF_COOKIE not set")
|
||||
}
|
||||
c := NewClient("")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
tok, uid, err := c.ExchangeToken(ctx, cookie)
|
||||
if err != nil {
|
||||
t.Fatalf("exchange: %v", err)
|
||||
}
|
||||
bal, err := c.FetchBalance(ctx, cookie)
|
||||
if err != nil {
|
||||
t.Fatalf("balance: %v", err)
|
||||
}
|
||||
t.Logf("user=%s token=%d bal=%d", uid, len(tok), bal)
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
// Package custom implements a generic OpenAI-compatible upstream client. A
|
||||
// "custom" model forwards generation to any OpenAI-compatible API: the upstream
|
||||
// base_url + api_key live on a custom account (pool="custom"), the upstream model
|
||||
// name on the model config (UpstreamModel). Calls go DIRECT (no tls-client, no
|
||||
// proxy) — the upstream is a normal API with no anti-bot.
|
||||
package custom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("custom upstream auth failed")
|
||||
ErrQuotaExhausted = errors.New("custom upstream quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("custom upstream temporary error")
|
||||
)
|
||||
|
||||
type Client struct{}
|
||||
|
||||
func NewClient() *Client { return &Client{} }
|
||||
|
||||
// sanitizeErr strips the upstream URL/host from a network error so a user's
|
||||
// private upstream URL never leaks into the event log / API response.
|
||||
func sanitizeErr(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
s := err.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"):
|
||||
return "request timeout"
|
||||
case strings.Contains(s, "connection refused"):
|
||||
return "connection refused"
|
||||
case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "):
|
||||
return "cannot reach upstream"
|
||||
case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"):
|
||||
return "TLS error"
|
||||
case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"):
|
||||
return "connection reset"
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return strings.ToLower(ue.Op) + " upstream failed"
|
||||
}
|
||||
return "upstream request failed"
|
||||
}
|
||||
|
||||
func httpClient() *http.Client { return &http.Client{Timeout: 30 * time.Minute} }
|
||||
|
||||
// GenerateImage calls the upstream OpenAI image API. With reference images it
|
||||
// uses /v1/images/edits (multipart); otherwise /v1/images/generations. Returns
|
||||
// the raw image bytes (decoded from b64_json, or downloaded from url).
|
||||
func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prompt, size, quality string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return nil, "", ErrAuth
|
||||
}
|
||||
var req *http.Request
|
||||
var err error
|
||||
if len(refs) > 0 {
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
_ = w.WriteField("model", model)
|
||||
_ = w.WriteField("prompt", prompt)
|
||||
// Ask the upstream for a URL (not base64) so we can pass it through directly.
|
||||
_ = w.WriteField("response_format", "url")
|
||||
if size != "" {
|
||||
_ = w.WriteField("size", size)
|
||||
}
|
||||
for i, r := range refs {
|
||||
fw, e := w.CreateFormFile("image[]", fmt.Sprintf("ref_%d.png", i+1))
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
_, _ = fw.Write(r)
|
||||
}
|
||||
_ = w.Close()
|
||||
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/edits", body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
} else {
|
||||
payload := map[string]any{"model": model, "prompt": prompt, "n": 1, "response_format": "url"}
|
||||
if size != "" {
|
||||
payload["size"] = size
|
||||
}
|
||||
if quality != "" {
|
||||
payload["quality"] = quality
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/generations", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if e := mapStatus(resp.StatusCode, body); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
return imageFromResponse(ctx, body, downloadResult)
|
||||
}
|
||||
|
||||
// GenerateVideo drives the upstream Sora-style async video API:
|
||||
// POST /v1/videos → poll GET /v1/videos/{id} → GET /v1/videos/{id}/content.
|
||||
// Reference frames (image-to-video / first-last frames) are sent as multipart
|
||||
// input_reference[] files, matching the OpenAI videos API. When downloadResult
|
||||
// is false it returns the upstream content URL instead.
|
||||
func (c *Client) GenerateVideo(ctx context.Context, baseURL, apiKey, model, prompt, size string, seconds int, frames [][]byte, downloadResult bool) ([]byte, string, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return nil, "", ErrAuth
|
||||
}
|
||||
var created map[string]any
|
||||
var err error
|
||||
if len(frames) > 0 {
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
_ = w.WriteField("model", model)
|
||||
_ = w.WriteField("prompt", prompt)
|
||||
if size != "" {
|
||||
_ = w.WriteField("size", size)
|
||||
}
|
||||
if seconds > 0 {
|
||||
_ = w.WriteField("seconds", fmt.Sprintf("%d", seconds))
|
||||
}
|
||||
for i, f := range frames {
|
||||
fw, e := w.CreateFormFile("input_reference[]", fmt.Sprintf("frame_%d.png", i+1))
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
_, _ = fw.Write(f)
|
||||
}
|
||||
_ = w.Close()
|
||||
created, err = c.doMultipart(ctx, baseURL+"/v1/videos", apiKey, body, w.FormDataContentType())
|
||||
} else {
|
||||
payload := map[string]any{"model": model, "prompt": prompt}
|
||||
if size != "" {
|
||||
payload["size"] = size
|
||||
}
|
||||
if seconds > 0 {
|
||||
payload["seconds"] = fmt.Sprintf("%d", seconds)
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
created, err = c.doJSON(ctx, http.MethodPost, baseURL+"/v1/videos", apiKey, raw)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
jobID := strings.TrimSpace(stringValue(created["id"]))
|
||||
if jobID == "" {
|
||||
return nil, "", fmt.Errorf("%w: video create missing id", ErrTemporaryUpstream)
|
||||
}
|
||||
// Poll until terminal.
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
job, err := c.doJSON(ctx, http.MethodGet, baseURL+"/v1/videos/"+jobID, apiKey, nil)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrTemporaryUpstream) {
|
||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
||||
return nil, "", ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(stringValue(job["status"]))) {
|
||||
case "completed", "succeeded", "success":
|
||||
contentURL := baseURL + "/v1/videos/" + jobID + "/content"
|
||||
if !downloadResult {
|
||||
return nil, contentURL, nil
|
||||
}
|
||||
data, err := c.download(ctx, contentURL, apiKey)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, contentURL, nil
|
||||
case "failed", "error", "canceled", "cancelled":
|
||||
reason := stringValue(job["error"])
|
||||
if isCreditError(reason) {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip([]byte(reason), 160))
|
||||
}
|
||||
return nil, "", fmt.Errorf("custom: video %s", clip([]byte(reason), 160))
|
||||
}
|
||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
||||
return nil, "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method, url, apiKey string, body []byte) (map[string]any, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if e := mapStatus(resp.StatusCode, 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: non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) doMultipart(ctx context.Context, url, apiKey string, body io.Reader, contentType string) (map[string]any, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if e := mapStatus(resp.StatusCode, 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: non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, url, apiKey string) ([]byte, error) {
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(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 download", ErrTemporaryUpstream)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// imageBytesFromResponse extracts image bytes from an OpenAI images response:
|
||||
// data[0].b64_json (preferred) or data[0].url (downloaded).
|
||||
// imageFromResponse parses an OpenAI image response and returns the upstream URL.
|
||||
// We always request response_format=url, so the response must carry a URL — a
|
||||
// base64-only response is treated as an error (no base64 pass-through). With
|
||||
// downloadResult=false the URL is returned directly (no download).
|
||||
func imageFromResponse(ctx context.Context, body []byte, downloadResult bool) ([]byte, string, error) {
|
||||
var out struct {
|
||||
Data []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil || len(out.Data) == 0 {
|
||||
return nil, "", fmt.Errorf("%w: bad image response: %s", ErrTemporaryUpstream, clip(body, 160))
|
||||
}
|
||||
url := strings.TrimSpace(out.Data[0].URL)
|
||||
if url == "" {
|
||||
return nil, "", fmt.Errorf("%w: image response had no url (upstream ignored response_format=url)", ErrTemporaryUpstream)
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, url, nil
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
return raw, url, err
|
||||
}
|
||||
|
||||
func mapStatus(status int, body []byte) error {
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
return nil
|
||||
case status == 401 || status == 403:
|
||||
return fmt.Errorf("%w: %d %s", ErrAuth, status, clip(body, 160))
|
||||
case status == 429:
|
||||
// Custom upstreams have NO "quota exhausted" lock — a 429 is just rate
|
||||
// limiting, treated as a temporary error (fail over, account stays active).
|
||||
return fmt.Errorf("%w: 429 %s", ErrTemporaryUpstream, clip(body, 160))
|
||||
case status >= 500:
|
||||
return fmt.Errorf("%w: %d %s", ErrTemporaryUpstream, status, clip(body, 160))
|
||||
default:
|
||||
if isCreditError(string(body)) {
|
||||
return fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip(body, 160))
|
||||
}
|
||||
return fmt.Errorf("custom: %d %s", status, clip(body, 160))
|
||||
}
|
||||
}
|
||||
|
||||
func isCreditError(s string) bool {
|
||||
s = strings.ToLower(s)
|
||||
return strings.Contains(s, "insufficient") || strings.Contains(s, "quota") ||
|
||||
strings.Contains(s, "credit") || strings.Contains(s, "balance")
|
||||
}
|
||||
|
||||
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 clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,935 +0,0 @@
|
||||
// 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; its value
|
||||
// is a 70-byte anti-bot record — header[49] | counter_le32 |
|
||||
// sha256("METHOD!path!counter"+suffix)[:16] | trailer — XOR-masked with one
|
||||
// random byte and base64-encoded; we reproduce it exactly in statsigID (the
|
||||
// build-specific header/suffix/trailer are env-overridable when grok rotates
|
||||
// them). Uses tls-client so the JA3/JA4 fingerprint matches Chrome.
|
||||
package grok
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
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.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.ensureChallenge(ctx, client, token)
|
||||
// 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.
|
||||
// Field #5 carries the credits' own reset timestamp (weekly grant refill) —
|
||||
// this is the 恢复时间 we surface, NOT the subscription's billing-period end.
|
||||
used, resetUnix, ok := parseCreditsConfig(raw)
|
||||
if !ok {
|
||||
return unknownBalance("unparsable credits config"), nil
|
||||
}
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
if used > fullCredits {
|
||||
used = fullCredits
|
||||
}
|
||||
remaining := fullCredits - used
|
||||
|
||||
// 恢复时间: the credits-config weekly reset (when the free grant refills).
|
||||
reset := strings.TrimSpace(resetUnix)
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": used,
|
||||
"total": fullCredits,
|
||||
"reset_after": emptyStringNil(reset),
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Subscription is the membership view parsed from GET /rest/subscriptions.
|
||||
type Subscription struct {
|
||||
Member bool // a subscription with status ACTIVE exists (INACTIVE entries don't count)
|
||||
Tier string // e.g. SUBSCRIPTION_TIER_GROK_PRO ("" for free)
|
||||
Status string // e.g. SUBSCRIPTION_STATUS_ACTIVE
|
||||
BillingPeriodEnd string // RFC3339; when the plan renews / credits reset
|
||||
FreeTrial bool // currently in a free-trial offer
|
||||
}
|
||||
|
||||
// FetchSubscription reads GET /rest/subscriptions and reports the account's
|
||||
// membership. Member is true only when an entry with SUBSCRIPTION_STATUS_ACTIVE
|
||||
// exists: a lapsed membership keeps its entry but flips to
|
||||
// SUBSCRIPTION_STATUS_INACTIVE, and an empty array means never subscribed —
|
||||
// both read as Member=false (the entry's tier/status are still surfaced).
|
||||
// A 401/403 maps to ErrAuth; other transport/HTTP errors are returned so callers
|
||||
// can treat them as best-effort (they already have the credit balance).
|
||||
func (c *Client) FetchSubscription(ctx context.Context, token string) (*Subscription, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.ensureChallenge(ctx, client, token)
|
||||
req, err := http.NewRequest(http.MethodGet, apiBase+"/rest/subscriptions", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
c.applyHeaders(req, token, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%w: subscriptions http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Subscriptions []struct {
|
||||
Tier string `json:"tier"`
|
||||
Status string `json:"status"`
|
||||
BillingPeriodEnd string `json:"billingPeriodEnd"`
|
||||
ActiveOffer struct {
|
||||
FreeTrial *struct {
|
||||
TrialDays int `json:"trialDays"`
|
||||
} `json:"freeTrial"`
|
||||
} `json:"activeOffer"`
|
||||
} `json:"subscriptions"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
return nil, fmt.Errorf("%w: subscriptions non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
out := &Subscription{}
|
||||
// Surface the ACTIVE subscription if any (falling back to the first entry
|
||||
// for tier/status info), but only an ACTIVE one sets Member.
|
||||
for i, s := range body.Subscriptions {
|
||||
active := strings.EqualFold(s.Status, "SUBSCRIPTION_STATUS_ACTIVE")
|
||||
if i == 0 || active {
|
||||
out.Member = active
|
||||
out.Tier = strings.TrimSpace(s.Tier)
|
||||
out.Status = strings.TrimSpace(s.Status)
|
||||
out.BillingPeriodEnd = strings.TrimSpace(s.BillingPeriodEnd)
|
||||
out.FreeTrial = s.ActiveOffer.FreeTrial != nil
|
||||
if active {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, 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.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
c.ensureChallenge(ctx, client, token)
|
||||
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 {
|
||||
Status string `json:"status"`
|
||||
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)
|
||||
}
|
||||
// A dead sso cookie answers 200 {"status":"unauthenticated"} rather than 401.
|
||||
if !strings.EqualFold(body.Status, "authenticated") {
|
||||
return "", "", ErrAuth
|
||||
}
|
||||
return strings.TrimSpace(body.Session.Email), strings.TrimSpace(body.Session.UserID), nil
|
||||
}
|
||||
|
||||
// grok's x-statsig-id is a per-session anti-bot token. Its 3-byte "F" tail is a
|
||||
// browser fingerprint the server recomputes from the homepage seed + curve set,
|
||||
// and the byte-indexing that derives it ROTATES on every grok web reship — so any
|
||||
// hand-ported algorithm goes stale within a day (403 anti-bot). The durable path
|
||||
// therefore runs grok's OWN signer in goja (statsig_engine.go): we fetch the seed
|
||||
// + curves from the homepage and let grok's code do all the (rotating) indexing.
|
||||
// statsigID prefers that engine; the hand-ported computeStatsigTail below and the
|
||||
// static env-overridable defaults are only a last-resort fallback. See the package
|
||||
// doc and statsig_engine.go for the full picture.
|
||||
// statsigEpoch is the challenge epoch (2023-05-01 00:00 UTC).
|
||||
const (
|
||||
statsigEpoch = 1682924400
|
||||
defaultStatsigHeader = "00a1adb5012bd32f844f4426c62680d91c6129361eb9459a759710e179a888a99b21678e1f0b1e8952de6a6b3ca019f74b"
|
||||
defaultStatsigSuffix = "obfiowerehiringd244100f5c28f5c28f5c047ae147ae147b047ae147ae147b0f5c28f5c28f5c00"
|
||||
defaultStatsigTrailer = 3
|
||||
statsigSaltPrefix = "obfiowerehiring"
|
||||
statsigAnimDuration = 4096
|
||||
statsigTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
statsigHeader = resolveStatsigHeader()
|
||||
statsigSuffix = envOr("GROK_STATSIG_SUFFIX", defaultStatsigSuffix)
|
||||
statsigTrailer = resolveStatsigTrailer()
|
||||
|
||||
statsigMetaRe = regexp.MustCompile(`name="grok[^"]*verification"[^>]*content="([^"]+)"`)
|
||||
|
||||
statsigMu sync.Mutex
|
||||
statsigCache = map[string]statsigChallenge{} // keyed by sso token
|
||||
)
|
||||
|
||||
// statsigChallenge is a resolved, self-consistent (header, salt) pair for one
|
||||
// grok session, derived from the homepage seed + curves.
|
||||
type statsigChallenge struct {
|
||||
header []byte
|
||||
suffix string
|
||||
trailer byte
|
||||
fetchedAt time.Time
|
||||
|
||||
// Inputs for the durable goja signer (statsig_js.go): the raw <meta> seed
|
||||
// content and the curves JSON. Empty when parsing failed (engine then skipped).
|
||||
seedB64 string
|
||||
curvesJSON string
|
||||
}
|
||||
|
||||
// statsigCurve is one entry of the per-load curve set injected via the Next.js
|
||||
// RSC stream; the server uses it (with the seed) to recompute F.
|
||||
type statsigCurve struct {
|
||||
Color []int `json:"color"`
|
||||
Deg int `json:"deg"`
|
||||
Bezier []int `json:"bezier"`
|
||||
}
|
||||
|
||||
func resolveStatsigHeader() []byte {
|
||||
h := envOr("GROK_STATSIG_HEADER_HEX", defaultStatsigHeader)
|
||||
b, err := hex.DecodeString(h)
|
||||
if err != nil || len(b) != 49 {
|
||||
b, _ = hex.DecodeString(defaultStatsigHeader)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func resolveStatsigTrailer() byte {
|
||||
if v := strings.TrimSpace(os.Getenv("GROK_STATSIG_TRAILER")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n <= 255 {
|
||||
return byte(n)
|
||||
}
|
||||
}
|
||||
return defaultStatsigTrailer
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// ensureChallenge refreshes the cached (header, salt) for the session if missing
|
||||
// or stale. Any failure is non-fatal: statsigID then falls back to the static
|
||||
// defaults. An explicit env override disables dynamic fetching entirely.
|
||||
func (c *Client) ensureChallenge(ctx context.Context, client tlsclient.HttpClient, token string) {
|
||||
if token == "" || client == nil {
|
||||
return
|
||||
}
|
||||
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
|
||||
return
|
||||
}
|
||||
statsigMu.Lock()
|
||||
cur, ok := statsigCache[token]
|
||||
fresh := ok && time.Since(cur.fetchedAt) < statsigTTL
|
||||
statsigMu.Unlock()
|
||||
if fresh {
|
||||
return
|
||||
}
|
||||
ch, err := fetchStatsigChallenge(ctx, client)
|
||||
if err != nil {
|
||||
// Silent fallback to static defaults is the #1 cause of a recurring
|
||||
// "403 anti-bot": the homepage structure changed and we never notice.
|
||||
// Surface it so the failure mode (fetch/parse broke vs. offsets rotated)
|
||||
// is diagnosable from logs instead of guessing.
|
||||
log.Printf("grok statsig: self-heal failed, using stale static defaults (403 likely): %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("grok statsig: self-heal ok header[:6]=%x suffix=%s", ch.header[:6], ch.suffix)
|
||||
statsigMu.Lock()
|
||||
statsigCache[token] = ch
|
||||
statsigMu.Unlock()
|
||||
}
|
||||
|
||||
// fetchStatsigChallenge does a browser-free homepage GET and derives a
|
||||
// self-consistent (header, salt) pair: header = 0x00 + seed, salt = prefix + F.
|
||||
//
|
||||
// The request is ANONYMOUS (no sso cookie): grok redirects a request carrying an
|
||||
// sso cookie to the sign-in page, whose HTML omits the anti-bot seed <meta> and
|
||||
// the curves array — so a cookied fetch silently loses the challenge inputs and
|
||||
// statsigID falls back to the stale static recipe (403 anti-bot). The seed/curves
|
||||
// are per-build and account-independent (the seed is transmitted inside the
|
||||
// x-statsig-id header the server re-verifies), so the anonymous landing page is
|
||||
// the reliable source for every session.
|
||||
func fetchStatsigChallenge(ctx context.Context, client tlsclient.HttpClient) (statsigChallenge, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, apiBase+"/", nil)
|
||||
if err != nil {
|
||||
return statsigChallenge{}, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {"accept", "accept-language", "user-agent"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return statsigChallenge{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return statsigChallenge{}, err
|
||||
}
|
||||
html := string(raw)
|
||||
|
||||
mm := statsigMetaRe.FindStringSubmatch(html)
|
||||
if mm == nil {
|
||||
return statsigChallenge{}, errors.New("statsig: seed meta not found")
|
||||
}
|
||||
curves, err := parseStatsigCurves(html)
|
||||
if err != nil {
|
||||
return statsigChallenge{}, err
|
||||
}
|
||||
curvesJSON, err := json.Marshal(curves)
|
||||
if err != nil {
|
||||
return statsigChallenge{}, err
|
||||
}
|
||||
|
||||
// Primary path is the goja signer (statsig_js.go); set it up / refresh it for
|
||||
// this build. Static (header, salt) below is only a last-resort fallback.
|
||||
ensureEngine(ctx, client, html)
|
||||
|
||||
ch := statsigChallenge{
|
||||
header: statsigHeader,
|
||||
suffix: statsigSuffix,
|
||||
trailer: statsigTrailer,
|
||||
fetchedAt: time.Now(),
|
||||
seedB64: mm[1],
|
||||
curvesJSON: string(curvesJSON),
|
||||
}
|
||||
// NOTE: the old hand-ported per-session derivation (0x00+seed header +
|
||||
// computeStatsigTail salt) is intentionally NOT applied here. Verified
|
||||
// 2026-07-13 against /rest/app-chat/conversations/new: that dynamic token is
|
||||
// rejected ("Request rejected by anti-bot rules.", 403), while the static
|
||||
// (header, salt) defaults are accepted (200) — so we keep the static values
|
||||
// as the fallback. When the goja signer (ensureEngine above) locates and
|
||||
// verifies grok's own chunk it still takes over via seedB64/curvesJSON in
|
||||
// statsigID; computeStatsigTail is retained only for reference/tests.
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func decodeStatsigSeed(s string) ([]byte, error) {
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 48 {
|
||||
return b, nil
|
||||
}
|
||||
b, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(s, "="))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("statsig: seed decode: %w", err)
|
||||
}
|
||||
if len(b) != 48 {
|
||||
return nil, fmt.Errorf("statsig: seed len %d", len(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// parseStatsigCurves extracts the [[{color,deg,bezier}...]...] array from the
|
||||
// RSC-escaped homepage HTML (the one immediately followed by color/bezier keys).
|
||||
func parseStatsigCurves(html string) ([][]statsigCurve, error) {
|
||||
marker := -1
|
||||
for from := 0; ; {
|
||||
i := strings.Index(html[from:], "curves")
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
i += from
|
||||
end := i + 160
|
||||
if end > len(html) {
|
||||
end = len(html)
|
||||
}
|
||||
w := html[i:end]
|
||||
if strings.Contains(w, "color") && strings.Contains(w, "bezier") {
|
||||
marker = i
|
||||
break
|
||||
}
|
||||
from = i + 6
|
||||
}
|
||||
if marker < 0 {
|
||||
return nil, errors.New("statsig: curves not found")
|
||||
}
|
||||
rel := strings.IndexByte(html[marker:], '[')
|
||||
if rel < 0 {
|
||||
return nil, errors.New("statsig: curves array start not found")
|
||||
}
|
||||
start := marker + rel
|
||||
depth, stop := 0, -1
|
||||
for k := start; k < len(html); k++ {
|
||||
switch html[k] {
|
||||
case '[':
|
||||
depth++
|
||||
case ']':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
stop = k + 1
|
||||
}
|
||||
}
|
||||
if stop > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if stop < 0 {
|
||||
return nil, errors.New("statsig: curves array end not found")
|
||||
}
|
||||
sub := strings.ReplaceAll(html[start:stop], `\`, "")
|
||||
var out [][]statsigCurve
|
||||
if err := json.Unmarshal([]byte(sub), &out); err != nil {
|
||||
return nil, fmt.Errorf("statsig: curves json: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// computeStatsigTail reproduces the browser's 3-byte color + 6-number transform
|
||||
// matrix "F" tail: it selects a curve by the seed, samples the curve's keyframe
|
||||
// animation (color lerp + rotate) at a seed-derived paused currentTime, and
|
||||
// serializes getComputedStyle(color)+getComputedStyle(transform) exactly as the
|
||||
// signer does (each number -> Number(v.toFixed(2)).toString(16), '.'/'-' stripped).
|
||||
func computeStatsigTail(seed []byte, curves [][]statsigCurve) (string, error) {
|
||||
if len(seed) < 46 {
|
||||
return "", errors.New("statsig: short seed")
|
||||
}
|
||||
if len(curves) == 0 {
|
||||
return "", errors.New("statsig: no curves")
|
||||
}
|
||||
group := int(seed[5]) % len(curves)
|
||||
if len(curves[group]) == 0 {
|
||||
return "", errors.New("statsig: empty curve group")
|
||||
}
|
||||
idx := int(seed[45]) % len(curves[group])
|
||||
cv := curves[group][idx]
|
||||
if len(cv.Color) < 6 || len(cv.Bezier) < 4 {
|
||||
return "", errors.New("statsig: malformed curve")
|
||||
}
|
||||
|
||||
n := (int(seed[1]) % 16) * (int(seed[6]) % 16) * (int(seed[13]) % 16)
|
||||
currentTime := jsRound(float64(n)/10) * 10
|
||||
progress := float64(currentTime) / statsigAnimDuration
|
||||
|
||||
x1 := toFixed2(float64(cv.Bezier[0]) / 255)
|
||||
y1 := toFixed2(float64(cv.Bezier[1])*2/255 - 1)
|
||||
x2 := toFixed2(float64(cv.Bezier[2]) / 255)
|
||||
y2 := toFixed2(float64(cv.Bezier[3])*2/255 - 1)
|
||||
eased := cubicBezierEase(x1, y1, x2, y2, progress)
|
||||
|
||||
nums := make([]float64, 0, 9)
|
||||
for k := 0; k < 3; k++ {
|
||||
v := jsRound(float64(cv.Color[k]) + (float64(cv.Color[k+3])-float64(cv.Color[k]))*eased)
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
if v > 255 {
|
||||
v = 255
|
||||
}
|
||||
nums = append(nums, float64(v))
|
||||
}
|
||||
theta := int(math.Floor(float64(cv.Deg)*300/255 + 60))
|
||||
rad := float64(theta) * eased * math.Pi / 180
|
||||
cos, sin := math.Cos(rad), math.Sin(rad)
|
||||
nums = append(nums, cos, sin, -sin, cos, 0, 0)
|
||||
|
||||
var b strings.Builder
|
||||
for _, v := range nums {
|
||||
b.WriteString(jsHex(v))
|
||||
}
|
||||
out := strings.NewReplacer(".", "", "-", "").Replace(b.String())
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsRound matches JavaScript Math.round (round half up toward +Inf).
|
||||
func jsRound(x float64) int {
|
||||
return int(math.Floor(x + 0.5))
|
||||
}
|
||||
|
||||
// toFixed2 matches JavaScript Number(v.toFixed(2)).
|
||||
func toFixed2(v float64) float64 {
|
||||
f, _ := strconv.ParseFloat(strconv.FormatFloat(v, 'f', 2, 64), 64)
|
||||
return f
|
||||
}
|
||||
|
||||
// jsHex matches JavaScript Number(v.toFixed(2)).toString(16).
|
||||
func jsHex(v float64) string {
|
||||
v = toFixed2(v)
|
||||
neg := ""
|
||||
if v < 0 {
|
||||
neg = "-"
|
||||
v = -v
|
||||
}
|
||||
ip := int64(math.Floor(v))
|
||||
frac := v - float64(ip)
|
||||
s := neg + strconv.FormatInt(ip, 16)
|
||||
if frac == 0 {
|
||||
return s
|
||||
}
|
||||
const digits = "0123456789abcdef"
|
||||
var b strings.Builder
|
||||
b.WriteString(s)
|
||||
b.WriteByte('.')
|
||||
for i := 0; i < 20 && frac != 0; i++ {
|
||||
frac *= 16
|
||||
d := int(frac)
|
||||
b.WriteByte(digits[d])
|
||||
frac -= float64(d)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cubicBezierEase evaluates a CSS cubic-bezier(x1,y1,x2,y2) easing at input
|
||||
// fraction p: solve X(t)=p for t (bisection), then return Y(t).
|
||||
func cubicBezierEase(x1, y1, x2, y2, p float64) float64 {
|
||||
bez := func(t, a, b float64) float64 {
|
||||
mt := 1 - t
|
||||
return 3*a*mt*mt*t + 3*b*mt*t*t + t*t*t
|
||||
}
|
||||
lo, hi := 0.0, 1.0
|
||||
for i := 0; i < 100; i++ {
|
||||
mid := (lo + hi) / 2
|
||||
if bez(mid, x1, x2) < p {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
return bez((lo+hi)/2, y1, y2)
|
||||
}
|
||||
|
||||
// statsigID reproduces grok.com's x-statsig-id anti-bot token for a request. The
|
||||
// token binds to the request METHOD and URL path and to a coarse timestamp, so
|
||||
// it must be regenerated per request. See the package doc for the layout. It uses
|
||||
// the session's self-healed (header, salt) when available, else static defaults.
|
||||
func statsigID(path, method, token string) string {
|
||||
header, suffix, trailer := statsigHeader, statsigSuffix, statsigTrailer
|
||||
statsigMu.Lock()
|
||||
ch, ok := statsigCache[token]
|
||||
statsigMu.Unlock()
|
||||
if ok {
|
||||
header, suffix, trailer = ch.header, ch.suffix, ch.trailer
|
||||
// Primary: run grok's own signer in goja (durable across reships). Falls
|
||||
// through to the static computation below on any failure.
|
||||
if ch.seedB64 != "" && ch.curvesJSON != "" {
|
||||
if id, err := signWithEngine(ch.seedB64, ch.curvesJSON, path, method); err == nil {
|
||||
return id
|
||||
} else if !errors.Is(err, errEngineNotReady) {
|
||||
log.Printf("grok statsig: js signer failed, using static fallback: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
counter := uint32(time.Now().Unix() - statsigEpoch)
|
||||
sig := fmt.Sprintf("%s!%s!%d%s", method, path, counter, suffix)
|
||||
hash := sha256.Sum256([]byte(sig))
|
||||
|
||||
raw := make([]byte, 0, 70)
|
||||
raw = append(raw, header...)
|
||||
raw = binary.LittleEndian.AppendUint32(raw, counter)
|
||||
raw = append(raw, hash[:16]...)
|
||||
raw = append(raw, trailer)
|
||||
|
||||
key := byte(rand.IntN(256))
|
||||
for i := range raw {
|
||||
raw[i] ^= key
|
||||
}
|
||||
return base64.RawStdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// 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(req.URL.Path, req.Method, token)},
|
||||
"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) { return c.newTLSClientP(true) }
|
||||
|
||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
||||
// reference-frame upload and result (video) download; only the generate submit
|
||||
// (/rest/app-chat/conversations/new) uses the proxy.
|
||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
||||
|
||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
// Video generation streams inline until progress=100; a 15s clip can take
|
||||
// several minutes, so allow up to 10m (caller's genCtx caps at 12m).
|
||||
tlsclient.WithTimeoutSeconds(600),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_133),
|
||||
tlsclient.WithRandomTLSExtensionOrder(),
|
||||
}
|
||||
if useProxy && 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) (used int, resetUnix string, ok bool) {
|
||||
var usedF float32
|
||||
seen := false
|
||||
for len(msg) > 0 {
|
||||
fn, wt, val, rest, good := readField(msg)
|
||||
if !good {
|
||||
break
|
||||
}
|
||||
msg = rest
|
||||
seen = true
|
||||
switch {
|
||||
case fn == 1 && wt == 5: // float32 credits USED this period
|
||||
usedF = float32FromLE(val)
|
||||
case fn == 5 && wt == 2: // reset timestamp message { #1 varint=seconds }
|
||||
if sec, sok := firstVarint(val); sok {
|
||||
resetUnix = strconv.FormatInt(sec, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A valid config message may OMIT field #1 when used == 0 (proto3 drops zero
|
||||
// scalars) — a full-quota account. So as long as the message had any field,
|
||||
// treat it as parsed with used defaulting to 0 (= 100 remaining).
|
||||
return int(usedF), resetUnix, seen
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,609 +0,0 @@
|
||||
package grok
|
||||
|
||||
// Grok Console (console.x.ai) is xAI's own product console. It serves the same
|
||||
// grok-imagine media models as grok.com, but over a plain JSON API that is NOT
|
||||
// anti-bot gated: no x-statsig-id, no media-post + streaming-conversation dance.
|
||||
// Auth reuses the very same website "sso" cookie — POST /v1/dpop/token exchanges
|
||||
// it for a short-lived access token bound to a locally generated P-256 key, and
|
||||
// every call then carries "Authorization: DPoP <token>" plus a per-request ES256
|
||||
// DPoP proof (RFC 9449) over (method, url, token hash).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
consoleBase = "https://console.x.ai"
|
||||
consoleOrigin = "https://console.x.ai"
|
||||
|
||||
// Upstream model ids of the Console media catalog. The -quality image model
|
||||
// is what the reference-image (edit) path uses.
|
||||
ConsoleVideoModel = "grok-imagine-video"
|
||||
ConsoleImageModel = "grok-imagine-image"
|
||||
ConsoleImageQualityModel = "grok-imagine-image-quality"
|
||||
|
||||
// consoleTokenSkew refreshes a DPoP token slightly before it expires.
|
||||
consoleTokenSkew = 20 * time.Second
|
||||
// consoleMaxEditImages is the upstream cap for image-to-image references
|
||||
// (video takes a single 首图).
|
||||
consoleMaxEditImages = 3
|
||||
|
||||
consoleVideoPollEvery = 2 * time.Second
|
||||
consoleVideoDeadline = 30 * time.Minute
|
||||
)
|
||||
|
||||
// consoleSession is one minted DPoP token plus the key it is bound to. skew is
|
||||
// (server time − local time) learned from the mint response Date header; the
|
||||
// proof iat is shifted by it so a drifting host clock doesn't fail every proof.
|
||||
type consoleSession struct {
|
||||
accessToken string
|
||||
key *ecdsa.PrivateKey
|
||||
jwk consoleJWK
|
||||
expiresAt time.Time
|
||||
skew time.Duration
|
||||
}
|
||||
|
||||
type consoleJWK struct {
|
||||
Crv string `json:"crv"`
|
||||
Kty string `json:"kty"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
}
|
||||
|
||||
var (
|
||||
consoleMu sync.Mutex
|
||||
consoleSessions = map[string]consoleSession{} // keyed by sso token hash
|
||||
)
|
||||
|
||||
// GenerateConsoleVideo runs Console's video pipeline: POST /v1/videos/generations
|
||||
// returns a request_id, GET /v1/videos/{id} is polled until the clip is rendered
|
||||
// and reports its vidgen.x.ai URL. frames is optional (image-to-video, one 首图
|
||||
// max) and is inlined as a data URL — Console takes the image in the request, so
|
||||
// there is no separate upload step. When downloadResult is false, returns nil
|
||||
// bytes and the artifact URL in meta["video_url"]; otherwise downloads the mp4.
|
||||
func (c *Client) GenerateConsoleVideo(ctx context.Context, token, prompt, aspectRatio, resolution string, seconds int, frames [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" && len(frames) == 0 {
|
||||
return nil, nil, fmt.Errorf("grok console: prompt required")
|
||||
}
|
||||
if seconds < 1 || seconds > 15 {
|
||||
seconds = 10
|
||||
}
|
||||
submitClient, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
directClient, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
payload := map[string]any{"model": ConsoleVideoModel, "duration": seconds}
|
||||
if prompt != "" {
|
||||
payload["prompt"] = prompt
|
||||
}
|
||||
if ratio := strings.TrimSpace(aspectRatio); ratio != "" {
|
||||
payload["aspect_ratio"] = ratio
|
||||
}
|
||||
if res := consoleVideoResolution(resolution); res != "" {
|
||||
payload["resolution"] = res
|
||||
}
|
||||
for _, f := range frames {
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
payload["image"] = map[string]any{"url": dataURL(f)}
|
||||
break // 上游只吃 1 张首图
|
||||
}
|
||||
|
||||
created, err := c.consoleJSON(ctx, submitClient, token, http.MethodPost, "/v1/videos/generations", payload)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
requestID := strings.TrimSpace(stringValue(created["request_id"]))
|
||||
if requestID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: video create missing request_id", ErrTemporaryUpstream)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(consoleVideoDeadline)
|
||||
videoURL := ""
|
||||
for {
|
||||
status, pollErr := c.consoleJSON(ctx, submitClient, token, http.MethodGet, "/v1/videos/"+url.PathEscape(requestID), nil)
|
||||
if pollErr != nil {
|
||||
return nil, nil, pollErr
|
||||
}
|
||||
url, done, sErr := parseConsoleVideoStatus(status)
|
||||
if sErr != nil {
|
||||
return nil, nil, sErr
|
||||
}
|
||||
if done {
|
||||
videoURL = url
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, nil, fmt.Errorf("%w: video render did not complete in time", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil, ctx.Err()
|
||||
case <-time.After(consoleVideoPollEvery):
|
||||
}
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"provider": "grok",
|
||||
"request_id": requestID,
|
||||
"video_url": videoURL,
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, directClient, token, videoURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// GenerateConsoleImage runs Console's image pipeline: /v1/images/generations on
|
||||
// grok-imagine-image for text-to-image, and /v1/images/edits on the
|
||||
// grok-imagine-image-quality model (up to 3 inlined reference images) as soon as
|
||||
// references are supplied — 带参考图自动走 quality 那个上游模型. Returns the
|
||||
// artifact URL in meta["image_url"]; the bytes are downloaded unless urlOnly.
|
||||
func (c *Client) GenerateConsoleImage(ctx context.Context, token, prompt, aspectRatio, resolution string, refs [][]byte, urlOnly bool) ([]byte, map[string]any, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return nil, nil, fmt.Errorf("grok console: prompt required")
|
||||
}
|
||||
submitClient, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
directClient, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"model": ConsoleImageModel, "prompt": strings.TrimSpace(prompt),
|
||||
"n": 1, "response_format": "url",
|
||||
}
|
||||
|
||||
if ratio := consoleImageAspectRatio(aspectRatio); ratio != "" {
|
||||
payload["aspect_ratio"] = ratio
|
||||
}
|
||||
if res := consoleImageResolution(resolution); res != "" {
|
||||
payload["resolution"] = res
|
||||
}
|
||||
path := "/v1/images/generations"
|
||||
var images []map[string]any
|
||||
for _, r := range refs {
|
||||
if len(r) == 0 || len(images) >= consoleMaxEditImages {
|
||||
continue
|
||||
}
|
||||
images = append(images, map[string]any{"type": "image_url", "url": dataURL(r)})
|
||||
}
|
||||
if len(images) > 0 {
|
||||
path = "/v1/images/edits"
|
||||
payload["model"] = ConsoleImageQualityModel
|
||||
if len(images) == 1 {
|
||||
payload["image"] = images[0]
|
||||
} else {
|
||||
payload["images"] = images
|
||||
}
|
||||
}
|
||||
|
||||
res, err := c.consoleJSON(ctx, submitClient, token, http.MethodPost, path, payload)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
items, _ := res["data"].([]any)
|
||||
imageURL := ""
|
||||
for _, item := range items {
|
||||
entry, _ := item.(map[string]any)
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
if v := strings.TrimSpace(stringValue(entry["url"])); v != "" {
|
||||
imageURL = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if imageURL == "" {
|
||||
return nil, nil, fmt.Errorf("%w: image response missing url", ErrTemporaryUpstream)
|
||||
}
|
||||
meta := map[string]any{"provider": "grok", "image_url": imageURL}
|
||||
if urlOnly {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, directClient, token, imageURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// consoleJSON does an authed Console JSON call and parses the response object. A
|
||||
// 401 means the DPoP token went stale (or the sso died): the cached session is
|
||||
// dropped and the call retried once with a freshly minted token.
|
||||
func (c *Client) consoleJSON(ctx context.Context, client tlsclient.HttpClient, token, method, path string, body any) (map[string]any, error) {
|
||||
var payload []byte
|
||||
if body != nil {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload = encoded
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
session, err := c.consoleSession(ctx, client, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var reader io.Reader
|
||||
if len(payload) > 0 {
|
||||
reader = strings.NewReader(string(payload))
|
||||
}
|
||||
req, err := http.NewRequest(method, consoleBase+path, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyConsoleHeaders(req, token, session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
raw, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 401 && attempt == 0 {
|
||||
consoleForget(token, session.accessToken)
|
||||
continue
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, readErr)
|
||||
}
|
||||
if e := consoleMapStatus(path, resp.StatusCode, raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out := map[string]any{}
|
||||
if len(raw) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: console retry state invalid", ErrTemporaryUpstream)
|
||||
}
|
||||
|
||||
// consoleSession returns a live DPoP session for the sso token, minting one when
|
||||
// the cache is empty or the token is about to expire.
|
||||
func (c *Client) consoleSession(ctx context.Context, client tlsclient.HttpClient, token string) (consoleSession, error) {
|
||||
key := consoleCacheKey(token)
|
||||
consoleMu.Lock()
|
||||
cached, ok := consoleSessions[key]
|
||||
consoleMu.Unlock()
|
||||
if ok && cached.expiresAt.After(time.Now().Add(consoleTokenSkew)) {
|
||||
return cached, nil
|
||||
}
|
||||
session, err := c.mintConsoleSession(ctx, client, token)
|
||||
if err != nil {
|
||||
return consoleSession{}, err
|
||||
}
|
||||
consoleMu.Lock()
|
||||
consoleSessions[key] = session
|
||||
consoleMu.Unlock()
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// consoleForget drops a cached session (only if it is still the one that failed,
|
||||
// so a concurrent refresh isn't thrown away).
|
||||
func consoleForget(token, accessToken string) {
|
||||
key := consoleCacheKey(token)
|
||||
consoleMu.Lock()
|
||||
if current, ok := consoleSessions[key]; ok && current.accessToken == accessToken {
|
||||
delete(consoleSessions, key)
|
||||
}
|
||||
consoleMu.Unlock()
|
||||
}
|
||||
|
||||
func consoleCacheKey(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return fmt.Sprintf("%x", sum[:])
|
||||
}
|
||||
|
||||
// mintConsoleSession posts the freshly generated public JWK to /v1/dpop/token
|
||||
// with the sso cookie and keeps the returned DPoP-bound access token.
|
||||
func (c *Client) mintConsoleSession(ctx context.Context, client tlsclient.HttpClient, token string) (consoleSession, error) {
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return consoleSession{}, err
|
||||
}
|
||||
jwk := consoleJWK{
|
||||
Crv: "P-256", Kty: "EC",
|
||||
X: base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.FillBytes(make([]byte, 32))),
|
||||
Y: base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.FillBytes(make([]byte, 32))),
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"jwk": jwk})
|
||||
if err != nil {
|
||||
return consoleSession{}, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, consoleBase+"/v1/dpop/token", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return consoleSession{}, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
c.applyConsoleBrowserHeaders(req, token, nil)
|
||||
before := time.Now().UTC()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return consoleSession{}, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
after := time.Now().UTC()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return consoleSession{}, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
if e := consoleMapStatus("/v1/dpop/token", resp.StatusCode, raw); e != nil {
|
||||
return consoleSession{}, e
|
||||
}
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return consoleSession{}, fmt.Errorf("%w: dpop token non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
||||
}
|
||||
if strings.TrimSpace(out.AccessToken) == "" || !strings.EqualFold(strings.TrimSpace(out.TokenType), "DPoP") {
|
||||
return consoleSession{}, fmt.Errorf("%w: dpop token response invalid", ErrTemporaryUpstream)
|
||||
}
|
||||
if out.ExpiresIn <= 0 || out.ExpiresIn > 3600 {
|
||||
out.ExpiresIn = 300
|
||||
}
|
||||
return consoleSession{
|
||||
accessToken: out.AccessToken,
|
||||
key: privateKey,
|
||||
jwk: jwk,
|
||||
expiresAt: time.Now().Add(time.Duration(out.ExpiresIn) * time.Second),
|
||||
skew: consoleClockSkew(resp.Header.Get("Date"), before, after),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// applyConsoleHeaders sets the browser-like header set plus the DPoP
|
||||
// Authorization header and its per-request proof.
|
||||
func (c *Client) applyConsoleHeaders(req *http.Request, token string, session consoleSession) error {
|
||||
proof, err := consoleProof(session, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyConsoleBrowserHeaders(req, token, map[string]string{
|
||||
"authorization": "DPoP " + session.accessToken,
|
||||
"dpop": proof,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) applyConsoleBrowserHeaders(req *http.Request, token string, extra map[string]string) {
|
||||
h := http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"content-type": {"application/json"},
|
||||
"origin": {consoleOrigin},
|
||||
"referer": {consoleOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
}
|
||||
for k, v := range extra {
|
||||
h[k] = []string{v}
|
||||
}
|
||||
h[http.HeaderOrderKey] = []string{
|
||||
"accept", "accept-language", "authorization", "content-type", "dpop",
|
||||
"origin", "referer", "user-agent", "sec-ch-ua", "sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "cookie",
|
||||
}
|
||||
req.Header = h
|
||||
}
|
||||
|
||||
// consoleProof builds the ES256 DPoP proof JWT for one request: it binds the
|
||||
// method + URL (without query) and the access token hash (ath), signed with the
|
||||
// key the token was issued against.
|
||||
func consoleProof(session consoleSession, req *http.Request) (string, error) {
|
||||
if session.key == nil || strings.TrimSpace(session.accessToken) == "" || req == nil || req.URL == nil {
|
||||
return "", errors.New("grok console: dpop session invalid")
|
||||
}
|
||||
path := req.URL.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
ath := sha256.Sum256([]byte(session.accessToken))
|
||||
header := map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": session.jwk}
|
||||
claims := map[string]any{
|
||||
"jti": uuid.NewString(),
|
||||
"htm": strings.ToUpper(req.Method),
|
||||
"htu": req.URL.Scheme + "://" + req.URL.Host + path,
|
||||
"iat": time.Now().Add(session.skew).UTC().Unix(),
|
||||
"ath": base64.RawURLEncoding.EncodeToString(ath[:]),
|
||||
}
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claimsJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
digest := sha256.Sum256([]byte(signingInput))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, session.key, digest[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
signature := make([]byte, 64)
|
||||
r.FillBytes(signature[:32])
|
||||
s.FillBytes(signature[32:])
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
// consoleClockSkew mirrors the console.x.ai frontend: proof iat is local time
|
||||
// corrected by (server Date − local time) so host clock drift doesn't break
|
||||
// every proof. before/after bound the RTT around the mint response.
|
||||
func consoleClockSkew(dateHeader string, before, after time.Time) time.Duration {
|
||||
dateHeader = strings.TrimSpace(dateHeader)
|
||||
if dateHeader == "" {
|
||||
return 0
|
||||
}
|
||||
serverTime, err := http.ParseTime(dateHeader)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if after.Before(before) {
|
||||
after = before
|
||||
}
|
||||
mid := before.Add(after.Sub(before) / 2)
|
||||
return serverTime.UTC().Sub(mid.UTC()).Round(time.Second)
|
||||
}
|
||||
|
||||
// parseConsoleVideoStatus reads one poll response: (url, done, error).
|
||||
func parseConsoleVideoStatus(res map[string]any) (string, bool, error) {
|
||||
status := strings.ToLower(strings.TrimSpace(stringValue(res["status"])))
|
||||
switch status {
|
||||
case "done", "completed", "succeeded", "success", "ready":
|
||||
video, _ := res["video"].(map[string]any)
|
||||
url := ""
|
||||
if video != nil {
|
||||
url = strings.TrimSpace(stringValue(video["url"]))
|
||||
}
|
||||
if url == "" {
|
||||
return "", false, fmt.Errorf("%w: video done without content url", ErrTemporaryUpstream)
|
||||
}
|
||||
return url, true, nil
|
||||
case "pending", "processing", "in_progress", "queued", "":
|
||||
return "", false, nil
|
||||
default:
|
||||
message := consoleErrorMessage(res["error"])
|
||||
if isCreditError(message) {
|
||||
return "", false, fmt.Errorf("%w: %s", ErrQuotaExhausted, message)
|
||||
}
|
||||
if message == "" {
|
||||
message = status
|
||||
}
|
||||
return "", false, fmt.Errorf("grok console: video generation failed: %s", message)
|
||||
}
|
||||
}
|
||||
|
||||
// consoleMapStatus maps a Console HTTP status to the shared provider sentinels.
|
||||
// Console is not anti-bot gated, so a 403 is a real rejection (dead sso / no
|
||||
// access) unless the body is a Cloudflare interstitial.
|
||||
func consoleMapStatus(path string, status int, raw []byte) error {
|
||||
body := string(raw)
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
return nil
|
||||
case status == 401:
|
||||
return fmt.Errorf("%w: %s 401 %s", ErrAuth, path, clip(raw, 160))
|
||||
case status == 403:
|
||||
if isBotChallenge(body) {
|
||||
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||
}
|
||||
return fmt.Errorf("%w: %s 403 %s", ErrAuth, path, clip(raw, 160))
|
||||
case status == 429:
|
||||
if isCreditError(body) {
|
||||
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
|
||||
}
|
||||
return fmt.Errorf("%w: %s 429 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||
case status >= 500:
|
||||
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, status, clip(raw, 160))
|
||||
default:
|
||||
if isCreditError(body) {
|
||||
return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 160))
|
||||
}
|
||||
return fmt.Errorf("grok console: %s %d %s", path, status, clip(raw, 160))
|
||||
}
|
||||
}
|
||||
|
||||
func consoleErrorMessage(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case map[string]any:
|
||||
for _, key := range []string{"message", "code", "type"} {
|
||||
if text := strings.TrimSpace(stringValue(v[key])); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// consoleImageAspectRatio keeps only the ratios Console's image models accept;
|
||||
// anything else is dropped so the upstream picks its default instead of 400ing.
|
||||
func consoleImageAspectRatio(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "2:1", "1:2":
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// consoleImageResolution maps our tier to the Console enum (only 1k / 2k exist).
|
||||
func consoleImageResolution(value string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "1K":
|
||||
return "1k"
|
||||
case "2K", "4K":
|
||||
return "2k"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// consoleVideoResolution clamps to the two tiers Console accepts.
|
||||
func consoleVideoResolution(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "480p":
|
||||
return "480p"
|
||||
default:
|
||||
return "720p"
|
||||
}
|
||||
}
|
||||
|
||||
// dataURL inlines reference image bytes; Console takes images in the request
|
||||
// body, so no upload endpoint is involved.
|
||||
func dataURL(img []byte) string {
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(img)
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
package grok
|
||||
|
||||
// This file makes x-statsig-id durable across grok web reships by executing grok's
|
||||
// OWN obfuscated signer (a Turbopack chunk) inside an embedded JS engine (goja),
|
||||
// under a synthesized DOM + Web-Animations getComputedStyle shim (statsig_shim.js).
|
||||
// grok's code does all the per-build byte-indexing / curve-selection; we only supply
|
||||
// the stable browser primitives. This replaces the brittle hand-ported byte-offset
|
||||
// algorithm in computeStatsigTail (kept as a last-resort fallback). See the package
|
||||
// doc and the grok-statsig-signer memory for the reverse-engineering details.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
//go:embed statsig_shim.js
|
||||
var statsigShimJS string
|
||||
|
||||
const sigPoolSize = 4
|
||||
|
||||
// errEngineNotReady means the durable signer engine has not been built yet (no
|
||||
// homepage fetched, or chunk location failed). Callers fall back to the static path.
|
||||
var errEngineNotReady = errors.New("statsig engine not ready")
|
||||
|
||||
// locateConcurrency bounds parallel chunk fetches during signer discovery.
|
||||
const locateConcurrency = 24
|
||||
|
||||
var (
|
||||
// chunkPathRe matches chunk URLs in the homepage; allChunkRefRe additionally
|
||||
// matches the loader-manifest's lazy refs (which drop the /_next/ prefix).
|
||||
// The Turbopack content-hash alphabet includes '~' (e.g. 0~4v1h2zpw7n0.js) -
|
||||
// the signer chunk is frequently one of these, so the class MUST list it or
|
||||
// the signer is never fetched/located (403 anti-bot, static fallback goes stale).
|
||||
chunkPathRe = regexp.MustCompile(`/_next/static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
|
||||
allChunkRefRe = regexp.MustCompile(`(?:/_next/)?static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
|
||||
// goja's parser tries to fetch //# sourceMappingURL=... from disk and errors.
|
||||
sourceMapRe = regexp.MustCompile(`(?m)//[#@]\s*sourceMappingURL=\S*`)
|
||||
|
||||
sigMgrMu sync.Mutex
|
||||
sigBuildKey string // hash of the homepage chunk list; changes on reship
|
||||
sigChunkSrc string // current signer chunk source
|
||||
sigPool chan *sigEngine // pool of ready engines for sigChunkSrc
|
||||
)
|
||||
|
||||
// sigEngine wraps one goja runtime with grok's signer chunk loaded. A goja runtime
|
||||
// is not safe for concurrent use; the pool hands each engine to one goroutine at a
|
||||
// time so no per-engine locking is needed.
|
||||
type sigEngine struct {
|
||||
rt *goja.Runtime
|
||||
fire goja.Callable // __grokSignInto
|
||||
}
|
||||
|
||||
func newSigEngine(chunkSrc string) (*sigEngine, error) {
|
||||
rt := goja.New()
|
||||
// SHA-256 bridge for crypto.subtle.digest.
|
||||
if err := rt.Set("__goSha256", func(call goja.FunctionCall) goja.Value {
|
||||
data := jsBytes(rt, call.Argument(0))
|
||||
sum := sha256.Sum256(data)
|
||||
return rt.ToValue(rt.NewArrayBuffer(sum[:]))
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := rt.RunString(statsigShimJS); err != nil {
|
||||
return nil, fmt.Errorf("shim: %w", err)
|
||||
}
|
||||
if _, err := rt.RunString(sourceMapRe.ReplaceAllString(chunkSrc, "")); err != nil {
|
||||
return nil, fmt.Errorf("chunk eval: %w", err)
|
||||
}
|
||||
if _, err := rt.RunString("__grokBootstrap()"); err != nil {
|
||||
return nil, fmt.Errorf("bootstrap: %w", err)
|
||||
}
|
||||
fire, ok := goja.AssertFunction(rt.Get("__grokSignInto"))
|
||||
if !ok {
|
||||
return nil, errors.New("statsig js: __grokSignInto missing")
|
||||
}
|
||||
return &sigEngine{rt: rt, fire: fire}, nil
|
||||
}
|
||||
|
||||
// statsigID runs grok's signer for one request. seedB64 is the raw <meta> content;
|
||||
// curvesJSON is [[{color,deg,bezier}...]...].
|
||||
func (e *sigEngine) statsigID(seedB64, curvesJSON, path, method string) (string, error) {
|
||||
_ = e.rt.Set("__SEED", seedB64)
|
||||
_ = e.rt.Set("__CURVES", curvesJSON)
|
||||
_ = e.rt.Set("__PATH", path)
|
||||
_ = e.rt.Set("__METHOD", method)
|
||||
// RunString drains goja's microtask queue, settling the async signer's promise.
|
||||
if _, err := e.fire(goja.Undefined()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if errv := e.rt.Get("__grokErr"); errv != nil && !goja.IsNull(errv) && !goja.IsUndefined(errv) {
|
||||
return "", fmt.Errorf("statsig js: %s", errv.String())
|
||||
}
|
||||
res := e.rt.Get("__grokResult")
|
||||
if res == nil || goja.IsNull(res) || goja.IsUndefined(res) {
|
||||
return "", errors.New("statsig js: promise did not settle")
|
||||
}
|
||||
id := res.String()
|
||||
if id == "" {
|
||||
return "", errors.New("statsig js: empty id")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// jsBytes extracts the byte contents of a JS Uint8Array / ArrayBuffer value.
|
||||
func jsBytes(rt *goja.Runtime, v goja.Value) []byte {
|
||||
if ab, ok := v.Export().(goja.ArrayBuffer); ok {
|
||||
return ab.Bytes()
|
||||
}
|
||||
obj := v.ToObject(rt)
|
||||
if buf := obj.Get("buffer"); buf != nil {
|
||||
if ab, ok := buf.Export().(goja.ArrayBuffer); ok {
|
||||
return ab.Bytes()
|
||||
}
|
||||
}
|
||||
n := int(obj.Get("length").ToInteger())
|
||||
out := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = byte(obj.Get(strconv.Itoa(i)).ToInteger())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// signWithEngine borrows an engine from the pool (building one on demand), signs,
|
||||
// and returns it. Returns an error if the engine subsystem is not ready.
|
||||
func signWithEngine(seedB64, curvesJSON, path, method string) (string, error) {
|
||||
sigMgrMu.Lock()
|
||||
src, pool := sigChunkSrc, sigPool
|
||||
sigMgrMu.Unlock()
|
||||
if src == "" || pool == nil {
|
||||
return "", errEngineNotReady
|
||||
}
|
||||
var eng *sigEngine
|
||||
select {
|
||||
case eng = <-pool:
|
||||
default:
|
||||
var err error
|
||||
if eng, err = newSigEngine(src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
id, err := eng.statsigID(seedB64, curvesJSON, path, method)
|
||||
select {
|
||||
case pool <- eng:
|
||||
default:
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// ensureEngine refreshes the global engine pool when the homepage's chunk set
|
||||
// changes (i.e. grok reshipped). It locates the signer chunk build-agnostically and
|
||||
// rebuilds the pool. Cheap no-op when the build is unchanged.
|
||||
func ensureEngine(ctx context.Context, client tlsclient.HttpClient, homeHTML string) {
|
||||
paths := chunkPathRe.FindAllString(homeHTML, -1)
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
key := hashStrings(paths)
|
||||
|
||||
sigMgrMu.Lock()
|
||||
unchanged := key == sigBuildKey && sigPool != nil
|
||||
sigMgrMu.Unlock()
|
||||
if unchanged {
|
||||
return
|
||||
}
|
||||
|
||||
// Inputs for build-agnostic behavioral verification of candidate chunks.
|
||||
mm := statsigMetaRe.FindStringSubmatch(homeHTML)
|
||||
if mm == nil {
|
||||
log.Printf("grok statsig: no seed meta in homepage; cannot locate signer")
|
||||
return
|
||||
}
|
||||
seed, err := decodeStatsigSeed(mm[1])
|
||||
if err != nil {
|
||||
log.Printf("grok statsig: seed decode failed: %v", err)
|
||||
return
|
||||
}
|
||||
curves, err := parseStatsigCurves(homeHTML)
|
||||
if err != nil {
|
||||
log.Printf("grok statsig: curves parse failed: %v", err)
|
||||
return
|
||||
}
|
||||
cj, err := json.Marshal(curves)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
src, err := locateSignerChunk(ctx, client, dedupe(paths), mm[1], string(cj), seed)
|
||||
if err != nil {
|
||||
log.Printf("grok statsig: locate signer chunk failed (will use static fallback): %v", err)
|
||||
return
|
||||
}
|
||||
// smoke-test: a build must produce a loadable engine before we commit to it.
|
||||
eng, err := newSigEngine(src)
|
||||
if err != nil {
|
||||
log.Printf("grok statsig: signer chunk did not load in goja (static fallback): %v", err)
|
||||
return
|
||||
}
|
||||
pool := make(chan *sigEngine, sigPoolSize)
|
||||
pool <- eng // reuse the smoke-test engine instead of discarding it
|
||||
sigMgrMu.Lock()
|
||||
sigBuildKey = key
|
||||
sigChunkSrc = src
|
||||
sigPool = pool
|
||||
sigMgrMu.Unlock()
|
||||
log.Printf("grok statsig: self-heal engine ready (build %s..)", key[:8])
|
||||
}
|
||||
|
||||
// locateSignerChunk finds grok's obfuscated anti-bot signer chunk build-agnostically,
|
||||
// with no dependency on rotating literals (header name, class names, module ids).
|
||||
// It screens every reachable chunk with a cheap obfuscator.io fingerprint, then
|
||||
// confirms the true signer by RUNNING it in goja and checking its output embeds the
|
||||
// homepage seed (signerEmbedsSeed). The signer is lazily loaded (not referenced in
|
||||
// the HTML directly), so candidates also include the chunk paths named inside the
|
||||
// homepage's Turbopack loader manifest.
|
||||
func locateSignerChunk(ctx context.Context, client tlsclient.HttpClient, homeChunks []string, seedB64, curvesJSON string, seed []byte) (string, error) {
|
||||
seen := map[string]bool{}
|
||||
var lazy []string
|
||||
for _, p := range homeChunks {
|
||||
seen[p] = true
|
||||
}
|
||||
|
||||
// Pass 1 (sequential): fetch the homepage chunks, verify them directly, and
|
||||
// harvest every chunk path they reference (the loader manifest lists the signer).
|
||||
for _, p := range homeChunks {
|
||||
body, err := fetchChunk(ctx, client, p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if src, ok := verifySignerChunk(body, seedB64, curvesJSON, seed); ok {
|
||||
return src, nil
|
||||
}
|
||||
for _, ref := range allChunkRefRe.FindAllString(body, -1) {
|
||||
np := normalizeChunkPath(ref)
|
||||
if !seen[np] {
|
||||
seen[np] = true
|
||||
lazy = append(lazy, np)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 (concurrent): fetch the lazily-referenced chunks, cheap-fingerprint each,
|
||||
// behaviorally verify the matches, and stop at the first chunk that round-trips seed.
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
found := make(chan string, 1)
|
||||
sem := make(chan struct{}, locateConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, p := range lazy {
|
||||
if ctx2.Err() != nil {
|
||||
break
|
||||
}
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(p string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if ctx2.Err() != nil {
|
||||
return
|
||||
}
|
||||
body, err := fetchChunk(ctx2, client, p)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if src, ok := verifySignerChunk(body, seedB64, curvesJSON, seed); ok {
|
||||
select {
|
||||
case found <- src:
|
||||
cancel()
|
||||
default:
|
||||
}
|
||||
}
|
||||
}(p)
|
||||
}
|
||||
go func() { wg.Wait(); close(found) }()
|
||||
if src, ok := <-found; ok {
|
||||
return src, nil
|
||||
}
|
||||
return "", fmt.Errorf("statsig signer chunk not found among %d candidates", len(homeChunks)+len(lazy))
|
||||
}
|
||||
|
||||
// verifySignerChunk returns the goja-ready source if body is grok's signer: it must
|
||||
// carry the obfuscator.io fingerprint AND, when executed, produce an x-statsig-id
|
||||
// whose decoded record embeds the homepage seed.
|
||||
func verifySignerChunk(body, seedB64, curvesJSON string, seed []byte) (string, bool) {
|
||||
if !isObfuscatedSigner(body) {
|
||||
return "", false
|
||||
}
|
||||
clean := sourceMapRe.ReplaceAllString(body, "")
|
||||
eng, err := newSigEngine(clean)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
id, err := eng.statsigID(seedB64, curvesJSON, "/rest/app-chat/conversations/new", "POST")
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !signerEmbedsSeed(id, seed) {
|
||||
return "", false
|
||||
}
|
||||
return clean, true
|
||||
}
|
||||
|
||||
// normalizeChunkPath turns a bare or /_next/-prefixed chunk ref into a fetch path.
|
||||
func normalizeChunkPath(ref string) string {
|
||||
return "/_next/" + strings.TrimPrefix(ref, "/_next/")
|
||||
}
|
||||
|
||||
// isObfuscatedSigner cheaply screens a chunk for the obfuscator.io string-array
|
||||
// decoder that grok applies ONLY to its anti-bot signer (the rest of the app is
|
||||
// plain Turbopack output). The RC4-style byte decoder (`...^...%256`) is the stable
|
||||
// tell; it matches a handful of chunks, which behavioral verification then narrows
|
||||
// to exactly one. This is deliberately build-agnostic (no rotating class/id/header).
|
||||
func isObfuscatedSigner(src string) bool {
|
||||
return strings.Contains(src, "%256") &&
|
||||
strings.Contains(src, "String.fromCharCode") &&
|
||||
strings.Contains(src, "charCodeAt")
|
||||
}
|
||||
|
||||
// signerEmbedsSeed decodes a candidate x-statsig-id, strips the per-call XOR mask
|
||||
// (plaintext[0] is 0x00, so masked[0] is the key), and checks the plaintext record
|
||||
// begins with 0x00 + the exact homepage seed. Only grok's real signer round-trips
|
||||
// OUR seed, so this uniquely identifies the signer chunk regardless of obfuscation.
|
||||
func signerEmbedsSeed(id string, seed []byte) bool {
|
||||
raw, err := base64.RawStdEncoding.DecodeString(id)
|
||||
if err != nil || len(raw) < 1+len(seed) {
|
||||
return false
|
||||
}
|
||||
key := raw[0] // plaintext[0] is 0x00, so the mask key == masked byte 0
|
||||
for i := 0; i < len(seed); i++ {
|
||||
if raw[1+i]^key != seed[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fetchChunk(ctx context.Context, client tlsclient.HttpClient, path string) (string, error) {
|
||||
if !strings.HasPrefix(path, "http") {
|
||||
path = apiBase + path
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {"accept", "user-agent"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("chunk http %d", resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func hashStrings(ss []string) string {
|
||||
uniq := dedupe(ss)
|
||||
h := sha512.New()
|
||||
for _, s := range uniq {
|
||||
_, _ = io.WriteString(h, s)
|
||||
_, _ = io.WriteString(h, "\n")
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
func dedupe(ss []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := ss[:0:0]
|
||||
for _, s := range ss {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package grok
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// The x-statsig-id recipe (49-byte header + salt suffix) encodes grok's per-load
|
||||
// browser fingerprint "F". grok recomputes F server-side from the homepage
|
||||
// seed+curves and the byte-indexing that derives it ROTATES on every web reship,
|
||||
// so any hand-ported or baked-in recipe goes stale (403 "Request rejected by
|
||||
// anti-bot rules.") within days. The durable fix is to let grok's OWN signer
|
||||
// produce the recipe: we drive a headless Chrome to grok.com, read the exact
|
||||
// string grok feeds to crypto.subtle.digest (reveals the current suffix) and one
|
||||
// of the x-statsig-id headers its client attaches (reveals the current 49-byte
|
||||
// header + trailer), then feed those into statsigID's existing per-request
|
||||
// computation. Because we read grok's live output we never reverse-engineer the
|
||||
// (rotating) obfuscation; a reship just needs a re-capture, which the auto-refresh
|
||||
// loop does on a timer and whenever a live request hits an anti-bot 403.
|
||||
//
|
||||
// The captured recipe is persisted (site settings) so a restart keeps the last
|
||||
// good values, and every failure is non-fatal — statsigID keeps using whatever
|
||||
// recipe is currently live (persisted → last capture → static defaults).
|
||||
|
||||
// hookJS is installed before grok's own scripts run. It records (a) every
|
||||
// crypto.subtle.digest input that contains the statsig salt marker — the plaintext
|
||||
// "METHOD!/path!counter<suffix>" — and (b) every 70-byte x-statsig-id header grok
|
||||
// attaches to its API calls. Both are only produced once grok's signer succeeds
|
||||
// (after its keyframe animation settles), so a short wait yields valid values.
|
||||
const statsigHookJS = `
|
||||
(() => {
|
||||
window.__statsigDigests = []; window.__statsigCaps = [];
|
||||
try {
|
||||
const td = new TextDecoder('utf-8', {fatal:false});
|
||||
const orig = crypto.subtle.digest.bind(crypto.subtle);
|
||||
crypto.subtle.digest = function(alg, data) {
|
||||
try {
|
||||
let buf = data; if (data && data.buffer) buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||
const txt = td.decode(new Uint8Array(buf.slice ? buf.slice(0) : buf));
|
||||
if (txt.indexOf('` + statsigSaltPrefix + `') >= 0) window.__statsigDigests.push(txt);
|
||||
} catch (e) {}
|
||||
return orig(alg, data);
|
||||
};
|
||||
const of = window.fetch;
|
||||
window.fetch = function(input, init) {
|
||||
try {
|
||||
let hdrs = (init && init.headers); let sid = null;
|
||||
if (hdrs) { if (typeof hdrs.get === 'function') sid = hdrs.get('x-statsig-id'); else for (const k in hdrs) if (k.toLowerCase() === 'x-statsig-id') sid = hdrs[k]; }
|
||||
if (sid && sid.length === 94) window.__statsigCaps.push(sid);
|
||||
} catch (e) {}
|
||||
return of.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
})();
|
||||
`
|
||||
|
||||
var (
|
||||
statsigRefreshTrigger = make(chan struct{}, 1)
|
||||
statsigRefreshStarted sync.Once
|
||||
statsigLastCapture time.Time
|
||||
statsigLastCaptureMu sync.Mutex
|
||||
)
|
||||
|
||||
// SetStatsigRecipe atomically replaces the live (header, suffix, trailer) used by
|
||||
// statsigID and drops the per-session challenge cache so the next request adopts
|
||||
// the new values. headerHex must decode to 49 bytes; suffix must be non-empty.
|
||||
func SetStatsigRecipe(headerHex, suffix string, trailer int) error {
|
||||
h, err := hex.DecodeString(strings.TrimSpace(headerHex))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(h) != 49 {
|
||||
return errors.New("statsig: header must be 49 bytes")
|
||||
}
|
||||
if strings.TrimSpace(suffix) == "" {
|
||||
return errors.New("statsig: empty suffix")
|
||||
}
|
||||
if trailer < 0 || trailer > 255 {
|
||||
return errors.New("statsig: trailer out of range")
|
||||
}
|
||||
statsigMu.Lock()
|
||||
statsigHeader = h
|
||||
statsigSuffix = suffix
|
||||
statsigTrailer = byte(trailer)
|
||||
statsigCache = map[string]statsigChallenge{}
|
||||
statsigMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// chromeExecPath resolves the Chrome/Chromium binary for headless capture, from
|
||||
// GROK_STATSIG_CHROME or the usual names on PATH. Empty means none available.
|
||||
func chromeExecPath() string {
|
||||
if p := strings.TrimSpace(os.Getenv("GROK_STATSIG_CHROME")); p != "" {
|
||||
return p
|
||||
}
|
||||
for _, name := range []string{"google-chrome-stable", "google-chrome", "chromium", "chromium-browser"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type statsigCapResult struct {
|
||||
Digs []string `json:"digs"`
|
||||
Caps []string `json:"caps"`
|
||||
}
|
||||
|
||||
// CaptureStatsigRecipe drives a headless Chrome to grok.com and reads grok's own
|
||||
// signer output, returning the current (headerHex, suffix, trailer). It never
|
||||
// mutates global state. Returns an error if Chrome is unavailable or grok's
|
||||
// signer output could not be observed (e.g. blocked before the signer ran).
|
||||
func CaptureStatsigRecipe(ctx context.Context) (headerHex, suffix string, trailer int, err error) {
|
||||
chromePath := chromeExecPath()
|
||||
if chromePath == "" {
|
||||
return "", "", 0, errors.New("statsig: no chrome binary (set GROK_STATSIG_CHROME)")
|
||||
}
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.ExecPath(chromePath),
|
||||
chromedp.Flag("headless", "new"),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
chromedp.UserAgent(userAgent),
|
||||
)
|
||||
if proxy := strings.TrimSpace(os.Getenv("GROK_STATSIG_PROXY")); proxy != "" {
|
||||
opts = append(opts, chromedp.ProxyServer(proxy))
|
||||
}
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...)
|
||||
defer cancel()
|
||||
bctx, cancel2 := chromedp.NewContext(allocCtx)
|
||||
defer cancel2()
|
||||
|
||||
var res statsigCapResult
|
||||
runErr := chromedp.Run(bctx,
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
_, err := page.AddScriptToEvaluateOnNewDocument(statsigHookJS).Do(ctx)
|
||||
return err
|
||||
}),
|
||||
chromedp.Navigate(apiBase+"/"),
|
||||
chromedp.Sleep(14*time.Second),
|
||||
chromedp.Evaluate(`({digs: window.__statsigDigests || [], caps: window.__statsigCaps || []})`, &res),
|
||||
)
|
||||
if runErr != nil {
|
||||
return "", "", 0, runErr
|
||||
}
|
||||
return parseStatsigCapture(res)
|
||||
}
|
||||
|
||||
// parseStatsigCapture extracts (headerHex, suffix, trailer) from the observed
|
||||
// digest plaintexts and x-statsig-id headers of one page load. Both must be
|
||||
// present and are self-consistent (same per-load fingerprint F).
|
||||
func parseStatsigCapture(res statsigCapResult) (headerHex, suffix string, trailer int, err error) {
|
||||
for _, d := range res.Digs {
|
||||
if i := strings.Index(d, statsigSaltPrefix); i >= 0 {
|
||||
suffix = d[i:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if suffix == "" {
|
||||
return "", "", 0, errors.New("statsig: no signer digest observed (blocked before signer ran?)")
|
||||
}
|
||||
var header []byte
|
||||
for _, sid := range res.Caps {
|
||||
raw, derr := base64.RawStdEncoding.DecodeString(sid)
|
||||
if derr != nil || len(raw) != 70 {
|
||||
continue
|
||||
}
|
||||
key := raw[0] // header[0] is 0x00, so the XOR mask key == byte 0
|
||||
r := make([]byte, 70)
|
||||
for i := range raw {
|
||||
r[i] = raw[i] ^ key
|
||||
}
|
||||
if r[0] != 0x00 {
|
||||
continue
|
||||
}
|
||||
header = r[0:49]
|
||||
trailer = int(r[69])
|
||||
break
|
||||
}
|
||||
if header == nil {
|
||||
return "", "", 0, errors.New("statsig: no valid x-statsig-id header observed")
|
||||
}
|
||||
return hex.EncodeToString(header), suffix, trailer, nil
|
||||
}
|
||||
|
||||
// TriggerStatsigRefresh asks the auto-refresh loop to re-capture soon (coalesced).
|
||||
// Called when a live request hits an anti-bot 403 — the recipe likely went stale.
|
||||
func TriggerStatsigRefresh() {
|
||||
select {
|
||||
case statsigRefreshTrigger <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// StartStatsigAutoRefresh launches the headless capture loop once. It is
|
||||
// event-driven, NOT a polling loop: it seeds the live recipe from persisted
|
||||
// values (load), captures once at startup so the process starts fresh, and then
|
||||
// re-captures ONLY when a live request hits an anti-bot 403 (TriggerStatsigRefresh)
|
||||
// — i.e. exactly when the recipe actually went stale on a grok reship. The recipe
|
||||
// has no fixed clock expiry, so there is nothing useful to poll on. An optional
|
||||
// safetyInterval>0 adds a slow backstop re-capture; pass 0 to disable it (default).
|
||||
// Captured recipes are handed to `save` for persistence. No-op when a manual
|
||||
// GROK_STATSIG_* override is set or no Chrome binary is available.
|
||||
func StartStatsigAutoRefresh(ctx context.Context, safetyInterval time.Duration,
|
||||
load func(context.Context) (headerHex, suffix string, trailer int, ok bool),
|
||||
save func(ctx context.Context, headerHex, suffix string, trailer int)) {
|
||||
|
||||
statsigRefreshStarted.Do(func() {
|
||||
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
|
||||
log.Printf("grok statsig: manual GROK_STATSIG_* override set — headless auto-refresh disabled")
|
||||
return
|
||||
}
|
||||
if chromeExecPath() == "" {
|
||||
log.Printf("grok statsig: no chrome binary — headless auto-refresh disabled, using static recipe (set GROK_STATSIG_CHROME to enable)")
|
||||
return
|
||||
}
|
||||
if load != nil {
|
||||
if h, s, t, ok := load(ctx); ok {
|
||||
if err := SetStatsigRecipe(h, s, t); err != nil {
|
||||
log.Printf("grok statsig: persisted recipe invalid, ignoring: %v", err)
|
||||
} else {
|
||||
log.Printf("grok statsig: loaded persisted recipe header[:6]=%s", safePrefix(h, 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
go statsigRefreshLoop(ctx, safetyInterval, save)
|
||||
})
|
||||
}
|
||||
|
||||
func statsigRefreshLoop(ctx context.Context, safetyInterval time.Duration, save func(context.Context, string, string, int)) {
|
||||
refresh := func(reason string) {
|
||||
statsigLastCaptureMu.Lock()
|
||||
if time.Since(statsigLastCapture) < 30*time.Second {
|
||||
statsigLastCaptureMu.Unlock()
|
||||
return // debounce bursts (e.g. a 403 storm across concurrent requests)
|
||||
}
|
||||
statsigLastCapture = time.Now()
|
||||
statsigLastCaptureMu.Unlock()
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
h, s, t, err := CaptureStatsigRecipe(cctx)
|
||||
if err != nil {
|
||||
log.Printf("grok statsig: headless capture (%s) failed, keeping current recipe: %v", reason, err)
|
||||
return
|
||||
}
|
||||
if err := SetStatsigRecipe(h, s, t); err != nil {
|
||||
log.Printf("grok statsig: captured recipe invalid: %v", err)
|
||||
return
|
||||
}
|
||||
if save != nil {
|
||||
save(ctx, h, s, t)
|
||||
}
|
||||
log.Printf("grok statsig: headless recipe refreshed (%s) header[:6]=%s suffix[:24]=%s", reason, safePrefix(h, 12), safePrefix(s, 24))
|
||||
}
|
||||
|
||||
initial := time.NewTimer(10 * time.Second)
|
||||
defer initial.Stop()
|
||||
|
||||
// Optional slow backstop only; disabled (nil channel blocks forever) when
|
||||
// safetyInterval<=0 so the loop stays purely event-driven.
|
||||
var tickC <-chan time.Time
|
||||
if safetyInterval > 0 {
|
||||
ticker := time.NewTicker(safetyInterval)
|
||||
defer ticker.Stop()
|
||||
tickC = ticker.C
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-initial.C:
|
||||
refresh("startup")
|
||||
case <-tickC:
|
||||
refresh("safety-interval")
|
||||
case <-statsigRefreshTrigger:
|
||||
refresh("anti-bot 403")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func safePrefix(s string, n int) string {
|
||||
if len(s) < n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package grok
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ground-truth (seed, curves, F) triples. The first row is server-verified
|
||||
// (captured from a real grok browser token that returned 200); the remainder
|
||||
// exercise every curve group / currentTime branch and are checked against the
|
||||
// reference implementation the algorithm was reverse-engineered against.
|
||||
var statsigTriples = []struct {
|
||||
seedHex string
|
||||
wantM string
|
||||
}{
|
||||
{seedHex: "732c9800d181e47c9b2a2705713306f42a51a10d69c6a6c32e53b26d1599b0b75399035a9953fedf05903cb9eb70a989", wantM: "ff75470a3d70a3d70a3d80c51eb851eb8520c51eb851eb8520a3d70a3d70a3d800"},
|
||||
{seedHex: "732c9800d1802530bb1d6d057133064d2aa5a1ca69c6a6c32e53b26d1599b0b75399035a9953fe1805903cb9eb70a989", wantM: "27fa10fd70a3d70a3d7028f5c28f5c28f6028f5c28f5c28f60fd70a3d70a3d700"},
|
||||
{seedHex: "732c9800d180237b2ed91e057133062c2a13a1de69c6a6c32e53b26d1599b0b75399035a9953fed605903cb9eb70a989", wantM: "079ff0d1eb851eb851e80947ae147ae14780947ae147ae14780d1eb851eb851e800"},
|
||||
{seedHex: "732c9800d180197117449405713306722a3fa11f69c6a6c32e53b26d1599b0b75399035a9953fecb05903cb9eb70a989", wantM: "195fe0fae147ae147ae035c28f5c28f5c2035c28f5c28f5c20fae147ae147ae00"},
|
||||
{seedHex: "732c9800d1815c3460be3105713306492ad6a13c69c6a6c32e53b26d1599b0b75399035a9953fe9d05903cb9eb70a989", wantM: "ff6ab5035c28f5c28f5c20fae147ae147ae0fae147ae147ae035c28f5c28f5c200"},
|
||||
{seedHex: "732c9800d181daa0eee8b9057133061e2a20a16969c6a6c32e53b26d1599b0b75399035a9953fefe05903cb9eb70a989", wantM: "3da0d70e3d70a3d70a3d8075c28f5c28f5c4075c28f5c28f5c40e3d70a3d70a3d800"},
|
||||
{seedHex: "732c9800d1812999fdafe5057133067f2a99a15c69c6a6c32e53b26d1599b0b75399035a9953fe7c05903cb9eb70a989", wantM: "1cac4c0451eb851eb8520f5c28f5c28f5c0f5c28f5c28f5c0451eb851eb85200"},
|
||||
{seedHex: "732c9800d18254af4dfad705713306252a93a13c69c6a6c32e53b26d1599b0b75399035a9953fed605903cb9eb70a989", wantM: "b40480d70a3d70a3d70808a3d70a3d70a408a3d70a3d70a40d70a3d70a3d70800"},
|
||||
{seedHex: "732c9800d182b3fee9232f05713306272a14a1a069c6a6c32e53b26d1599b0b75399035a9953feae05903cb9eb70a989", wantM: "13757a100100"},
|
||||
{seedHex: "732c9800d1829ee491c5b105713306f22a8aa12169c6a6c32e53b26d1599b0b75399035a9953fe1f05903cb9eb70a989", wantM: "eb4fb9100a3d70a3d70a3d800a3d70a3d70a3d8100"},
|
||||
{seedHex: "732c9800d1833bfc1e6f9305713306ec2a0ba1b569c6a6c32e53b26d1599b0b75399035a9953fe5605903cb9eb70a989", wantM: "8a56650fae147ae147ae02b851eb851eb8602b851eb851eb860fae147ae147ae00"},
|
||||
{seedHex: "732c9800d183fe2955e5cd057133067e2a42a1cb69c6a6c32e53b26d1599b0b75399035a9953fec805903cb9eb70a989", wantM: "b242950deb851eb851eb807d70a3d70a3d707d70a3d70a3d70deb851eb851eb800"},
|
||||
{seedHex: "732c9800d183d4b7c2764d05713306462a8ea1dc69c6a6c32e53b26d1599b0b75399035a9953fe8e05903cb9eb70a989", wantM: "1e70d8070a3d70a3d70a40e66666666666680e6666666666668070a3d70a3d70a400"}}
|
||||
|
||||
const statsigTestCurves = `[[{"color":[48,44,6,37,198,15],"deg":192,"bezier":[118,76,158,16]},{"color":[224,216,196,111,43,97],"deg":119,"bezier":[67,167,95,219]},{"color":[90,235,250,5,223,64],"deg":104,"bezier":[100,57,106,204]},{"color":[6,109,253,44,29,224],"deg":151,"bezier":[204,60,142,122]},{"color":[81,0,121,208,228,133],"deg":181,"bezier":[182,89,105,123]},{"color":[31,3,160,181,226,184],"deg":98,"bezier":[207,150,215,136]},{"color":[231,243,81,28,109,131],"deg":175,"bezier":[17,103,7,81]},{"color":[222,250,130,169,55,247],"deg":141,"bezier":[21,238,12,84]},{"color":[74,62,116,145,209,185],"deg":109,"bezier":[0,74,58,233]},{"color":[204,168,203,138,107,125],"deg":214,"bezier":[41,13,239,45]},{"color":[246,116,162,162,84,234],"deg":127,"bezier":[160,202,52,76]},{"color":[138,202,210,36,61,195],"deg":234,"bezier":[31,50,177,229]},{"color":[47,46,196,176,79,255],"deg":221,"bezier":[39,14,9,76]},{"color":[245,164,227,71,201,110],"deg":190,"bezier":[193,208,125,9]},{"color":[199,253,44,33,130,240],"deg":191,"bezier":[168,247,61,37]},{"color":[19,91,5,180,202,243],"deg":216,"bezier":[96,152,42,123]}],[{"color":[42,154,230,170,197,128],"deg":108,"bezier":[160,52,34,184]},{"color":[224,132,83,0,231,162],"deg":238,"bezier":[227,37,122,102]},{"color":[24,82,237,199,100,74],"deg":130,"bezier":[186,106,188,209]},{"color":[47,50,169,65,124,44],"deg":228,"bezier":[198,22,146,225]},{"color":[111,131,95,195,131,73],"deg":207,"bezier":[134,146,202,172]},{"color":[192,10,166,28,236,3],"deg":219,"bezier":[85,77,157,235]},{"color":[236,139,199,72,233,250],"deg":197,"bezier":[180,9,79,147]},{"color":[146,195,78,16,231,211],"deg":93,"bezier":[123,18,87,27]},{"color":[168,211,49,42,124,18],"deg":172,"bezier":[232,171,120,118]},{"color":[43,146,96,11,203,53],"deg":146,"bezier":[11,4,83,198]},{"color":[183,97,101,38,115,41],"deg":159,"bezier":[69,223,197,236]},{"color":[78,73,207,132,109,134],"deg":49,"bezier":[59,18,133,168]},{"color":[239,153,225,207,127,157],"deg":194,"bezier":[13,206,154,182]},{"color":[19,146,127,191,68,199],"deg":165,"bezier":[148,212,7,16]},{"color":[93,225,249,144,88,255],"deg":215,"bezier":[62,30,77,69]},{"color":[203,79,164,145,3,20],"deg":81,"bezier":[228,28,93,130]}],[{"color":[171,53,4,125,232,43],"deg":122,"bezier":[136,9,128,97]},{"color":[143,97,205,57,63,69],"deg":192,"bezier":[232,20,219,189]},{"color":[28,173,18,107,158,44],"deg":255,"bezier":[93,187,184,198]},{"color":[170,17,62,142,200,32],"deg":29,"bezier":[14,31,12,97]},{"color":[19,117,122,173,239,66],"deg":74,"bezier":[117,176,139,212]},{"color":[213,151,230,112,224,255],"deg":25,"bezier":[1,223,72,233]},{"color":[153,131,51,105,69,47],"deg":108,"bezier":[123,177,126,140]},{"color":[120,114,44,151,88,83],"deg":165,"bezier":[16,104,134,75]},{"color":[245,145,194,75,120,26],"deg":142,"bezier":[79,235,38,43]},{"color":[147,63,50,255,239,106],"deg":190,"bezier":[122,143,160,150]},{"color":[233,78,184,130,25,123],"deg":54,"bezier":[87,18,184,226]},{"color":[162,180,233,70,57,249],"deg":87,"bezier":[123,238,61,124]},{"color":[146,95,56,171,38,240],"deg":239,"bezier":[241,134,228,44]},{"color":[111,11,149,62,208,177],"deg":70,"bezier":[103,149,4,37]},{"color":[159,128,118,21,197,153],"deg":175,"bezier":[246,215,172,236]},{"color":[194,131,68,247,215,108],"deg":30,"bezier":[23,91,151,231]}],[{"color":[239,129,141,243,85,208],"deg":38,"bezier":[252,248,245,195]},{"color":[205,56,138,49,126,99],"deg":107,"bezier":[72,85,228,91]},{"color":[185,54,148,122,170,158],"deg":192,"bezier":[33,88,51,136]},{"color":[7,14,75,26,23,41],"deg":93,"bezier":[153,21,55,147]},{"color":[245,211,213,64,5,253],"deg":49,"bezier":[253,19,106,155]},{"color":[144,232,165,21,114,130],"deg":200,"bezier":[193,179,133,226]},{"color":[34,196,100,42,114,0],"deg":52,"bezier":[4,4,71,65]},{"color":[195,8,130,102,201,141],"deg":210,"bezier":[8,173,23,33]},{"color":[86,136,44,95,223,62],"deg":249,"bezier":[220,98,68,113]},{"color":[205,48,9,247,236,71],"deg":75,"bezier":[163,240,28,25]},{"color":[43,190,29,239,55,135],"deg":146,"bezier":[109,245,34,188]},{"color":[146,91,92,2,3,251],"deg":97,"bezier":[183,188,95,157]},{"color":[40,213,196,70,81,174],"deg":120,"bezier":[153,197,61,201]},{"color":[131,92,180,68,131,214],"deg":251,"bezier":[94,191,198,89]},{"color":[47,113,219,96,115,228],"deg":238,"bezier":[22,35,60,63]},{"color":[246,244,203,196,78,136],"deg":44,"bezier":[88,23,205,184]}]]`
|
||||
|
||||
// TestComputeStatsigTail was the offline regression for the hand-ported F
|
||||
// derivation (computeStatsigTail). That algorithm is now only a last-resort
|
||||
// fallback: the durable path runs grok's own signer in goja (statsig_engine.go),
|
||||
// which self-heals across web reships. grok rotates the F algorithm on every
|
||||
// reship, so these browser-captured (seed,F) triples go stale and CANNOT be
|
||||
// regenerated offline. TestSelfHealStatsigE2E (live, via goja) is the real
|
||||
// regression now. Kept skipped so the reference vectors aren't lost.
|
||||
func TestComputeStatsigTail(t *testing.T) {
|
||||
t.Skip("static F derivation is a deprecated fallback; see TestSelfHealStatsigE2E (goja)")
|
||||
var curves [][]statsigCurve
|
||||
if err := json.Unmarshal([]byte(statsigTestCurves), &curves); err != nil {
|
||||
t.Fatalf("curves: %v", err)
|
||||
}
|
||||
for i, tc := range statsigTriples {
|
||||
seed, err := hex.DecodeString(tc.seedHex)
|
||||
if err != nil {
|
||||
t.Fatalf("[%d] seed hex: %v", i, err)
|
||||
}
|
||||
got, err := computeStatsigTail(seed, curves)
|
||||
if err != nil {
|
||||
t.Fatalf("[%d] computeStatsigTail: %v", i, err)
|
||||
}
|
||||
if got != tc.wantM {
|
||||
t.Errorf("[%d] group=%d\n got=%s\nwant=%s", i, int(seed[5])%len(curves), got, tc.wantM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfHealStatsigE2E exercises the full browser-free self-healing path:
|
||||
// fetch the homepage, derive seed+F, cache the challenge, then hit the
|
||||
// anti-bot-gated conversations/new endpoint. Requires a live GROK_TOK and no
|
||||
// GROK_STATSIG_* env overrides.
|
||||
func TestSelfHealStatsigE2E(t *testing.T) {
|
||||
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
|
||||
if token == "" {
|
||||
t.Skip("no GROK_TOK")
|
||||
}
|
||||
c := NewClient("")
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c.ensureChallenge(ctx, client, token)
|
||||
statsigMu.Lock()
|
||||
ch, ok := statsigCache[token]
|
||||
statsigMu.Unlock()
|
||||
if !ok {
|
||||
t.Fatal("challenge not cached (homepage fetch/derive failed)")
|
||||
}
|
||||
t.Logf("dynamic header[:6]=%x suffix=%s", ch.header[:6], ch.suffix)
|
||||
|
||||
body, err := c.postStream(ctx, client, token, "/rest/app-chat/conversations/new", map[string]any{
|
||||
"temporary": true,
|
||||
"modelName": "grok-3",
|
||||
"message": "hi",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("conversations/new: %v", err)
|
||||
}
|
||||
t.Logf("OK bytes=%d head=%.80s", len(body), strings.ReplaceAll(body, "\n", " "))
|
||||
}
|
||||
|
||||
// TestStatsigEngineConcurrent fires many concurrent goja signs through the pool to
|
||||
// catch data races / engine cross-talk (run with -race). Requires a live GROK_TOK
|
||||
// so the engine is built and the challenge cached.
|
||||
func TestStatsigEngineConcurrent(t *testing.T) {
|
||||
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
|
||||
if token == "" {
|
||||
t.Skip("no GROK_TOK")
|
||||
}
|
||||
c := NewClient("")
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
c.ensureChallenge(ctx, client, token)
|
||||
statsigMu.Lock()
|
||||
ch, ok := statsigCache[token]
|
||||
statsigMu.Unlock()
|
||||
if !ok || ch.seedB64 == "" || ch.curvesJSON == "" {
|
||||
t.Fatal("engine inputs not cached")
|
||||
}
|
||||
|
||||
const n = 40
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
id, err := signWithEngine(ch.seedB64, ch.curvesJSON, "/rest/app-chat/conversations/new", "POST")
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if len(id) < 40 {
|
||||
errs <- errTooShort
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for e := range errs {
|
||||
t.Fatalf("concurrent sign: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
var errTooShort = errors.New("statsig id too short")
|
||||
|
||||
// TestGenerateVideoE2E generates a real grok video using only the dynamic
|
||||
// self-healed statsig (no env overrides). Requires a live GROK_TOK.
|
||||
func TestGenerateVideoE2E(t *testing.T) {
|
||||
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
|
||||
if token == "" {
|
||||
t.Skip("no GROK_TOK")
|
||||
}
|
||||
c := NewClient("")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
data, meta, err := c.GenerateVideo(ctx, token, "a cat playing piano", "16:9", "720p", 6, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVideo: %v", err)
|
||||
}
|
||||
t.Logf("video bytes=%d meta=%v", len(data), meta)
|
||||
if len(data) < 1<<20 {
|
||||
t.Fatalf("video too small: %d bytes", len(data))
|
||||
}
|
||||
if !strings.Contains(string(data[:16]), "ftyp") {
|
||||
t.Fatalf("not an mp4: % x", data[:16])
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
// statsig_shim.js — browser-environment shim that lets grok.com's own obfuscated
|
||||
// x-statsig-id signer run inside goja. grok's code does all the (per-build,
|
||||
// rotating) byte-indexing/curve-selection; we only provide the STABLE browser
|
||||
// primitives it reads from: the seed <meta>, the .r-aufz1o SVG curve DOM, and a
|
||||
// standard Web-Animations getComputedStyle sampler. Inputs arrive via globals set
|
||||
// by Go before each sign: __SEED (base64 str), __CURVES (JSON [[{color,deg,bezier}]]),
|
||||
// __PATH, __METHOD. Go also injects __goSha256(Uint8Array)->ArrayBuffer.
|
||||
(function () {
|
||||
'use strict';
|
||||
var g = globalThis;
|
||||
|
||||
// ---- base64 (goja has no atob/btoa) ----
|
||||
var B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
g.atob = function (s) {
|
||||
s = String(s).replace(/=+$/, '');
|
||||
var out = '', bits = 0, val = 0;
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
var c = B64.indexOf(s.charAt(i));
|
||||
if (c < 0) continue;
|
||||
val = (val << 6) | c; bits += 6;
|
||||
if (bits >= 8) { bits -= 8; out += String.fromCharCode((val >> bits) & 0xff); }
|
||||
}
|
||||
return out;
|
||||
};
|
||||
g.btoa = function (s) {
|
||||
s = String(s); var out = '';
|
||||
for (var i = 0; i < s.length; i += 3) {
|
||||
var b0 = s.charCodeAt(i), b1 = s.charCodeAt(i + 1), b2 = s.charCodeAt(i + 2);
|
||||
var h0 = b0 >> 2, h1 = ((b0 & 3) << 4) | (b1 >> 4);
|
||||
var h2 = ((b1 & 15) << 2) | (b2 >> 6), h3 = b2 & 63;
|
||||
out += B64[h0] + B64[h1];
|
||||
out += isNaN(b1) ? '=' : B64[h2];
|
||||
out += isNaN(b2) ? '=' : B64[h3];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---- TextEncoder (goja has no TextEncoder) ----
|
||||
if (typeof g.TextEncoder === 'undefined') {
|
||||
g.TextEncoder = function () {};
|
||||
g.TextEncoder.prototype.encode = function (str) {
|
||||
str = String(str);
|
||||
var bytes = [];
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var c = str.charCodeAt(i);
|
||||
if (c < 0x80) bytes.push(c);
|
||||
else if (c < 0x800) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); }
|
||||
else if (c >= 0xd800 && c <= 0xdbff) { // surrogate pair
|
||||
var c2 = str.charCodeAt(++i);
|
||||
var cp = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff);
|
||||
bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
|
||||
} else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); }
|
||||
}
|
||||
return Uint8Array.from(bytes);
|
||||
};
|
||||
}
|
||||
|
||||
// ---- crypto.subtle.digest, backed by Go SHA-256 ----
|
||||
g.crypto = g.crypto || {};
|
||||
g.crypto.subtle = g.crypto.subtle || {};
|
||||
g.crypto.subtle.digest = function (algo, data) {
|
||||
// grok only ever asks for sha-256; g.__goSha256 returns an ArrayBuffer
|
||||
var bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
return Promise.resolve(g.__goSha256(bytes));
|
||||
};
|
||||
|
||||
// ---- Web Animations getComputedStyle sampler (the only real math we own) ----
|
||||
var K = 4096;
|
||||
function cubicBezier(x1, y1, x2, y2, p) {
|
||||
if (p <= 0) return 0; if (p >= 1) return 1;
|
||||
function bez(t, a, b) { var mt = 1 - t; return 3 * a * mt * mt * t + 3 * b * mt * t * t + t * t * t; }
|
||||
var lo = 0, hi = 1;
|
||||
for (var i = 0; i < 100; i++) { var m = (lo + hi) / 2; if (bez(m, x1, x2) < p) lo = m; else hi = m; }
|
||||
return bez((lo + hi) / 2, y1, y2);
|
||||
}
|
||||
function hexToRgb(h) { h = h.replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
|
||||
function sample(anim) {
|
||||
var kf = anim.keyframes, dur = anim.duration || K;
|
||||
var frac = anim.currentTime / dur; if (frac < 0) frac = 0; if (frac > 1) frac = 1;
|
||||
var bm = /cubic-bezier\(([^)]+)\)/.exec(kf.easing || '');
|
||||
var eased = frac;
|
||||
if (bm) { var p = bm[1].split(',').map(Number); eased = cubicBezier(p[0], p[1], p[2], p[3], frac); }
|
||||
var c0 = hexToRgb(kf.color[0]), c1 = hexToRgb(kf.color[1]);
|
||||
var col = [0, 1, 2].map(function (i) { return Math.round(c0[i] + (c1[i] - c0[i]) * eased); });
|
||||
var d0 = parseFloat(/rotate\(([-\d.]+)deg\)/.exec(kf.transform[0])[1]);
|
||||
var d1 = parseFloat(/rotate\(([-\d.]+)deg\)/.exec(kf.transform[1])[1]);
|
||||
var ang = (d0 + (d1 - d0) * eased) * Math.PI / 180;
|
||||
var cos = Math.cos(ang), sin = Math.sin(ang);
|
||||
return { color: 'rgb(' + col[0] + ', ' + col[1] + ', ' + col[2] + ')',
|
||||
transform: 'matrix(' + cos + ', ' + sin + ', ' + (-sin) + ', ' + cos + ', 0, 0)' };
|
||||
}
|
||||
|
||||
// ---- minimal DOM ----
|
||||
function makeEl(props) {
|
||||
var e = {
|
||||
nodeName: 'DIV', style: {}, childNodes: [], attrs: {}, _anim: null, _parent: null,
|
||||
setAttribute: function (k, v) { e.attrs[k] = v; },
|
||||
getAttribute: function (k) { return (k in e.attrs) ? e.attrs[k] : (props && props.attrs && k in props.attrs ? props.attrs[k] : null); },
|
||||
appendChild: function (c) { e.childNodes.push(c); return c; },
|
||||
append: function (c) { e.childNodes.push(c); return c; },
|
||||
removeChild: function (c) { return c; },
|
||||
remove: function () {},
|
||||
animate: function (keyframes, opts) {
|
||||
var anim = {
|
||||
keyframes: keyframes,
|
||||
duration: (opts && opts.duration) || (typeof opts === 'number' ? opts : K),
|
||||
currentTime: 0, pause: function () {}, play: function () {}, cancel: function () {},
|
||||
effect: { getKeyframes: function () { return Array.isArray(keyframes) ? keyframes : [keyframes]; } },
|
||||
};
|
||||
e._anim = anim; return anim;
|
||||
},
|
||||
getAnimations: function () { return e._anim ? [e._anim] : []; },
|
||||
};
|
||||
if (props) for (var k in props) if (k !== 'attrs') e[k] = props[k];
|
||||
Object.defineProperty(e, 'parentElement', { get: function () { return e._parent; } });
|
||||
Object.defineProperty(e, 'innerHTML', { set: function (v) { e._html = v; }, get: function () { return e._html; } });
|
||||
Object.defineProperty(e, 'textContent', { set: function (v) { e._text = v; }, get: function () { return e._text; } });
|
||||
return e;
|
||||
}
|
||||
|
||||
// .r-aufz1o group: g.childNodes[0].childNodes[1].getAttribute('d') = svg path whose
|
||||
// numbers (after substring(9), split('C')) decode back to each curve [c0..c5,deg,b0..b3].
|
||||
function groupEl(flatCurves) {
|
||||
var d = '_________' + flatCurves.map(function (c) { return c.join(' '); }).join('C');
|
||||
var path = makeEl({ attrs: { d: d } });
|
||||
var inner = makeEl(); inner.childNodes = [makeEl(), path];
|
||||
var outer = makeEl(); outer.childNodes = [inner]; outer._parent = makeEl();
|
||||
return outer;
|
||||
}
|
||||
|
||||
var docBody = makeEl();
|
||||
g.document = {
|
||||
currentScript: null, body: docBody, head: makeEl(),
|
||||
createElement: function (tag) { return makeEl({ nodeName: String(tag || 'div').toUpperCase() }); },
|
||||
querySelectorAll: function (sel) {
|
||||
sel = String(sel);
|
||||
// The seed <meta> is selected by a name/verification attribute selector
|
||||
// (e.g. [name^=gr] or [name*=verification]); the curve group container is
|
||||
// selected by a per-build hashed CLASS selector (e.g. .r-aufz1o, .r-3nqkqc)
|
||||
// which rotates on every reship — so match by shape, not literal class.
|
||||
if (/verification|name/i.test(sel)) {
|
||||
var seed = g.__SEED;
|
||||
return [{ nodeName: 'META', getAttribute: function (a) { return a === 'content' ? seed : null; },
|
||||
get content() { return seed; } }];
|
||||
}
|
||||
if (/(^|\s|,)\./.test(sel)) {
|
||||
var curves = JSON.parse(g.__CURVES);
|
||||
return curves.map(function (grp) {
|
||||
return groupEl(grp.map(function (cv) { return cv.color.concat([cv.deg], cv.bezier); }));
|
||||
});
|
||||
}
|
||||
return [];
|
||||
},
|
||||
querySelector: function (sel) { var r = this.querySelectorAll(sel); return r[0] || null; },
|
||||
};
|
||||
g.window = g;
|
||||
g.self = g;
|
||||
g.getComputedStyle = function (el) { return el && el._anim ? sample(el._anim) : { color: 'rgb(0, 0, 0)', transform: 'none' }; };
|
||||
g.navigator = g.navigator || { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36' };
|
||||
g.location = g.location || { href: 'https://grok.com/', origin: 'https://grok.com', pathname: '/' };
|
||||
|
||||
// ---- Turbopack capture + bootstrap ----
|
||||
var TP = []; TP.push = function (entry) { TP._entry = entry; return 0; };
|
||||
g.TURBOPACK = TP;
|
||||
|
||||
// Called by Go AFTER the signer chunk is eval'd: run the module factory, grab default.
|
||||
g.__grokBootstrap = function () {
|
||||
var entry = TP._entry;
|
||||
if (!entry) throw new Error('turbopack entry not registered');
|
||||
var factory = entry[2];
|
||||
var exports = {};
|
||||
var ctx = {
|
||||
s: function () {
|
||||
var flat = Array.prototype.slice.call(arguments).flat(Infinity);
|
||||
var name = null;
|
||||
for (var i = 0; i < flat.length; i++) {
|
||||
var x = flat[i];
|
||||
if (typeof x === 'string') name = x;
|
||||
else if (typeof x === 'function' && name != null) {
|
||||
(function (nm, getter) { Object.defineProperty(exports, nm, { get: getter, configurable: true, enumerable: true }); })(name, x);
|
||||
name = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
factory(ctx);
|
||||
// exports.default is a GETTER that invokes the module factory on every read,
|
||||
// returning a FRESH async signer (path,method)=>Promise<id> with a fresh internal
|
||||
// curve cache. Keep the exports object; read .default fresh per sign so different
|
||||
// sessions' curves never leak through the closure cache.
|
||||
g.__grokExports = exports;
|
||||
if (typeof exports.default !== 'function') throw new Error('no default export');
|
||||
};
|
||||
|
||||
// Called by Go per sign. Fresh signer each time via the re-invoking getter.
|
||||
g.__grokSign = function () {
|
||||
var signer = g.__grokExports.default; // fresh async signer
|
||||
return signer(g.__PATH, g.__METHOD); // returns Promise<string>
|
||||
};
|
||||
|
||||
// Synchronous bridge: Go calls this via RunString (which drains goja's job queue),
|
||||
// then reads __grokResult / __grokErr. Works because crypto.subtle.digest resolves
|
||||
// synchronously (Promise.resolve over a Go SHA-256), so the whole await chain settles
|
||||
// within the microtask drain.
|
||||
g.__grokResult = null;
|
||||
g.__grokErr = null;
|
||||
g.__grokSignInto = function () {
|
||||
g.__grokResult = null; g.__grokErr = null;
|
||||
try {
|
||||
g.__grokSign().then(
|
||||
function (r) { g.__grokResult = r; },
|
||||
function (e) { g.__grokErr = (e && e.stack) ? String(e.stack) : String(e); }
|
||||
);
|
||||
} catch (e) { g.__grokErr = (e && e.stack) ? String(e.stack) : String(e); }
|
||||
};
|
||||
})();
|
||||
@@ -1,528 +0,0 @@
|
||||
package grok
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 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 != 15 {
|
||||
seconds = 10
|
||||
}
|
||||
|
||||
// Only the conversations/new submit egresses via the proxy; reference-frame
|
||||
// upload and the mp4 download run on the local IP.
|
||||
submitClient, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
directClient, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Self-heal the x-statsig-id anti-bot challenge for THIS session before the
|
||||
// gated submit. Without it the header falls back to the static defaults,
|
||||
// which go stale on a new grok web build and make conversations/new answer
|
||||
// 403 (anti-bot). Fetch on the local IP (the proxy is rotating, so pinning
|
||||
// the seed to a proxy egress IP is pointless). Failure is non-fatal
|
||||
// (statsigID then uses defaults).
|
||||
c.ensureChallenge(ctx, directClient, token)
|
||||
|
||||
// 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, directClient, token, f)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
imageRefs = append(imageRefs, url)
|
||||
}
|
||||
|
||||
// grok streams progress inline, but the stream is fragile: it regularly dies
|
||||
// mid-body ("unexpected EOF") after only the conversation object / an early
|
||||
// progress event — while grok keeps rendering the clip server-side. The
|
||||
// rendered clip always lands at the deterministic URL
|
||||
// assets.grok.com/users/{userId}/generated/{postId}/generated_video.mp4, so a
|
||||
// dead stream is NOT a failed generation: poll that URL until the clip is
|
||||
// ready instead of failing the job (which would also fail over and burn
|
||||
// another account's credits for a video that already exists).
|
||||
postID, userID, err := c.createPost(ctx, submitClient, 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 may be partial when psErr != nil — still worth scanning.
|
||||
body, psErr := c.postStream(ctx, submitClient, token, "/rest/app-chat/conversations/new", payload)
|
||||
if dir := strings.TrimSpace(os.Getenv("GROK_DUMP")); dir != "" {
|
||||
_ = os.WriteFile(dir+"/grok_body_"+postID+".txt", []byte(body), 0o644)
|
||||
}
|
||||
// Out-of-credits surfaces as a stream error (HTTP is still 200) — not retryable.
|
||||
if strings.Contains(body, "usagePoolExhausted") || strings.Contains(body, "media generation credits") {
|
||||
return nil, nil, fmt.Errorf("%w: media generation credits exhausted", ErrQuotaExhausted)
|
||||
}
|
||||
if psErr != nil && (errors.Is(psErr, ErrAuth) || errors.Is(psErr, ErrQuotaExhausted)) {
|
||||
return nil, nil, psErr
|
||||
}
|
||||
// A fatal stream error means grok definitively rejected this generation
|
||||
// (content moderation, an unsupported parameter, etc.). Retrying — on this
|
||||
// or any other account — fails identically and only burns the pool, so fail
|
||||
// fast with a non-temporary error the pool won't fail over on.
|
||||
if strings.Contains(body, "STREAM_ERROR_SEVERITY_FATAL") {
|
||||
return nil, nil, fmt.Errorf("grok: video generation rejected by upstream (fatal stream error): %s", clip([]byte(body), 200))
|
||||
}
|
||||
// Fast path: the artifact appears as "videoUrl":"users/.../generated_video.mp4".
|
||||
artifact := ""
|
||||
for _, m := range videoURLRe.FindAllStringSubmatch(body, -1) {
|
||||
if v := strings.TrimSpace(m[1]); v != "" {
|
||||
artifact = v // keep the last (progress=100) one
|
||||
}
|
||||
}
|
||||
if artifact == "" && userID != "" {
|
||||
// Stream ended without the artifact (dropped connection or early close):
|
||||
// the render continues upstream — wait for the deterministic asset URL.
|
||||
url := fmt.Sprintf("%susers/%s/generated/%s/generated_video.mp4", assetBase, userID, postID)
|
||||
if werr := c.waitForAsset(ctx, directClient, token, url); werr == nil {
|
||||
artifact = url
|
||||
} else if errors.Is(werr, ErrAuth) || errors.Is(werr, context.Canceled) || errors.Is(werr, context.DeadlineExceeded) {
|
||||
return nil, nil, werr
|
||||
}
|
||||
}
|
||||
if artifact == "" {
|
||||
if psErr != nil {
|
||||
return nil, nil, psErr
|
||||
}
|
||||
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, directClient, token, fullURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// uploadImage uploads one reference frame via /http/upload-file-v2/direct and
|
||||
// returns its asset content URL for imageReferences. Cloudflare's bot score is
|
||||
// per-request, so retry transient failures with backoff instead of failing the
|
||||
// whole task.
|
||||
func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (string, error) {
|
||||
var res map[string]any
|
||||
var err error
|
||||
backoffs := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second}
|
||||
for _, wait := range backoffs {
|
||||
if wait > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
res, err = c.uploadFileV2(ctx, client, token, img)
|
||||
if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
meta, _ := res["fileMetadata"].(map[string]any)
|
||||
fileURI := ""
|
||||
if meta != nil {
|
||||
fileURI = strings.TrimSpace(stringValue(meta["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
|
||||
}
|
||||
|
||||
func (c *Client) uploadFileV2(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (map[string]any, error) {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
partHeader := textproto.MIMEHeader{}
|
||||
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, uuid.NewString()+".png"))
|
||||
partHeader.Set("Content-Type", "image/png")
|
||||
part, err := mw.CreatePart(partHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := part.Write(img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mw.WriteField("file_source", "IMAGINE_SELF_UPLOAD_FILE_SOURCE"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, apiBase+"/http/upload-file-v2/direct", bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
c.applyHeaders(req, token, map[string]string{"content-type": mw.FormDataContentType()})
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
if e := mapStatus("/http/upload-file-v2/direct", resp.StatusCode, raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%w: upload non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// createPost registers a video media post and returns its id (parentPostId).
|
||||
// createPost creates the parent media post. grok uses its id as the videoId,
|
||||
// and the rendered clip is stored under users/{userId}/generated/{postId}/.
|
||||
func (c *Client) createPost(ctx context.Context, client tlsclient.HttpClient, token, prompt string) (postID, userID string, err 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)
|
||||
postID = strings.TrimSpace(stringValue(post["id"]))
|
||||
userID = strings.TrimSpace(stringValue(post["userId"]))
|
||||
if postID == "" {
|
||||
return "", "", fmt.Errorf("%w: media post missing id", ErrTemporaryUpstream)
|
||||
}
|
||||
return postID, userID, nil
|
||||
}
|
||||
|
||||
// waitForAsset polls a generated-asset URL (ranged GET on the local IP —
|
||||
// assets.grok.com is not anti-bot gated) until grok finishes rendering it.
|
||||
// 404 means "still rendering"; auth failures abort.
|
||||
func (c *Client) waitForAsset(ctx context.Context, client tlsclient.HttpClient, token, url string) error {
|
||||
deadline := time.Now().Add(30 * time.Minute)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"user-agent": {userAgent},
|
||||
"referer": {origin + "/"},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
"range": {"bytes=0-0"},
|
||||
}
|
||||
if resp, derr := client.Do(req); derr == nil {
|
||||
code := resp.StatusCode
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
switch {
|
||||
case code == http.StatusOK || code == http.StatusPartialContent:
|
||||
return nil
|
||||
case code == http.StatusUnauthorized || code == http.StatusForbidden:
|
||||
return fmt.Errorf("%w: asset %d", ErrAuth, code)
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("%w: video render did not complete in time", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(4 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 string(raw), err
|
||||
}
|
||||
if e := mapStatus(path, status, raw); e != nil {
|
||||
return string(raw), 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 {
|
||||
// Mid-body stream drops ("unexpected EOF", HTTP/2 resets) are common on
|
||||
// long renders — return what was received so callers can still use it.
|
||||
return raw, resp.StatusCode, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
return raw, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// OpenAsset streams a grok asset (e.g. a generated video) authenticated with the
|
||||
// account token — used by the async /v1/videos /content proxy. The caller MUST
|
||||
// close the returned ReadCloser.
|
||||
func (c *Client) OpenAsset(ctx context.Context, token, url string) (io.ReadCloser, string, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, "", ErrAuth
|
||||
}
|
||||
// Asset streaming is pure bandwidth (no anti-bot) — egress on the local IP.
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
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)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return nil, "", fmt.Errorf("%w: asset %d", ErrAuth, resp.StatusCode)
|
||||
}
|
||||
return nil, "", fmt.Errorf("%w: asset %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = "video/mp4"
|
||||
}
|
||||
return resp.Body, ct, nil
|
||||
}
|
||||
|
||||
// download fetches the rendered artifact. The clip is already generated at this
|
||||
// point, so a transient failure here (HTTP/2 stream reset, CF hiccup) must not
|
||||
// fail the whole task — retry with backoff.
|
||||
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) {
|
||||
var data []byte
|
||||
var err error
|
||||
for _, wait := range []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second} {
|
||||
if wait > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
data, err = c.downloadOnce(ctx, client, token, url)
|
||||
if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (c *Client) downloadOnce(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, fmt.Errorf("%w: %v", ErrTemporaryUpstream, 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 == 403 && isBotChallenge(string(raw)):
|
||||
// grok bot-detection or a Cloudflare challenge page ("Just a moment…"),
|
||||
// NOT a dead token — transient, so a good account isn't killed by an
|
||||
// IP/anti-bot hiccup. A statsig anti-bot rejection means the recipe likely
|
||||
// went stale on a reship; kick the headless refresher to re-capture.
|
||||
TriggerStatsigRefresh()
|
||||
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||
case status == 401 || status == 403:
|
||||
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
|
||||
case status == 429:
|
||||
// 429 is grok RATE-LIMITING ("Too many requests") — a transient error that
|
||||
// must NOT kill the account. Only a body that names a credit/usage-pool
|
||||
// exhaustion is a real quota wall.
|
||||
if isCreditError(string(raw)) {
|
||||
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
|
||||
}
|
||||
return fmt.Errorf("%w: %s 429 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||
case status >= 500:
|
||||
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, status, clip(raw, 160))
|
||||
default:
|
||||
if isCreditError(string(raw)) {
|
||||
return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 160))
|
||||
}
|
||||
return fmt.Errorf("grok: %s %d %s", path, status, clip(raw, 160))
|
||||
}
|
||||
}
|
||||
|
||||
// isBotChallenge reports whether a 403 body is an anti-bot interstitial rather
|
||||
// than a real auth rejection: grok's own "anti-bot" marker or a Cloudflare
|
||||
// challenge page ("Just a moment…" / cf-chl / challenge-platform).
|
||||
func isBotChallenge(s string) bool {
|
||||
s = strings.ToLower(s)
|
||||
return strings.Contains(s, "anti-bot") ||
|
||||
strings.Contains(s, "just a moment") ||
|
||||
strings.Contains(s, "cf-chl") ||
|
||||
strings.Contains(s, "challenge-platform") ||
|
||||
strings.Contains(s, "cf_chl")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package grok
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
var innerChunkRe = regexp.MustCompile(`static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
|
||||
|
||||
func hnew(ctx context.Context, url, token string) (*http.Request, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"user-agent": {userAgent},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
http.HeaderOrderKey: {"accept", "accept-language", "user-agent", "cookie"},
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func TestGrokCacheChunks(t *testing.T) {
|
||||
token := os.Getenv("DIAG_SSO")
|
||||
if token == "" {
|
||||
t.Skip("no DIAG_SSO")
|
||||
}
|
||||
dir := os.Getenv("DIAG_CACHE")
|
||||
if dir == "" {
|
||||
dir = `f:\ai-gateway\vivid-ai\backend\_chunks`
|
||||
}
|
||||
os.MkdirAll(dir, 0o755)
|
||||
logf, _ := os.Create(filepath.Join(dir, "_log.txt"))
|
||||
defer logf.Close()
|
||||
lg := func(f string, a ...any) {
|
||||
fmt.Fprintf(logf, f+"\n", a...)
|
||||
logf.Sync()
|
||||
}
|
||||
|
||||
c := NewClient(os.Getenv("DIAG_PROXY"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3000*time.Second)
|
||||
defer cancel()
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hreq, _ := hnew(ctx, apiBase+"/", token)
|
||||
hresp, err := client.Do(hreq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(hresp.Body)
|
||||
hresp.Body.Close()
|
||||
html := string(body)
|
||||
os.WriteFile(filepath.Join(dir, "_home.html"), body, 0o644)
|
||||
|
||||
seen := map[string]bool{}
|
||||
queue := dedupe(chunkPathRe.FindAllString(html, -1))
|
||||
lg("home chunk refs: %d", len(queue))
|
||||
|
||||
saved := 0
|
||||
for len(queue) > 0 && saved < 3000 {
|
||||
p := queue[0]
|
||||
queue = queue[1:]
|
||||
norm := p
|
||||
if !strings.HasPrefix(norm, "/_next/") {
|
||||
norm = "/_next/" + strings.TrimPrefix(norm, "/")
|
||||
}
|
||||
if seen[norm] {
|
||||
continue
|
||||
}
|
||||
seen[norm] = true
|
||||
src, err := fetchChunk(ctx, client, norm)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
saved++
|
||||
base := norm[strings.LastIndex(norm, "/")+1:]
|
||||
fn := fmt.Sprintf("%x_%s", sha1.Sum([]byte(norm)), base)
|
||||
if len(fn) > 120 {
|
||||
fn = fn[:120]
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, fn), []byte(src), 0o644)
|
||||
for _, m := range innerChunkRe.FindAllString(src, -1) {
|
||||
nn := "/_next/" + m
|
||||
if !seen[nn] {
|
||||
queue = append(queue, nn)
|
||||
}
|
||||
}
|
||||
if saved%50 == 0 {
|
||||
lg("saved=%d queue=%d", saved, len(queue))
|
||||
}
|
||||
}
|
||||
lg("CRAWL DONE saved=%d queue_left=%d", saved, len(queue))
|
||||
fmt.Printf("CRAWL DONE saved=%d\n", saved)
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
// Package imagine implements the Imagine.art (vyro.ai) provider client. The
|
||||
// durable credential is a JSON blob {"token","refreshToken"}: `token` is a ~6h
|
||||
// access JWT used as Authorization: Bearer for the API, and `refreshToken` is a
|
||||
// ~7d JWT that mints a fresh pair via /apis/v1/auth/other/refresh/web when the
|
||||
// access token expires. Both rotate on refresh, so the new pair MUST be saved.
|
||||
// tls-client gives a Chrome JA3/JA4 so vyro's edge doesn't flag the requests.
|
||||
package imagine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://imagine.vyro.ai"
|
||||
teamsBase = "https://teams-imagine.vyro.ai"
|
||||
authBase = "https://auth.vyro.ai"
|
||||
webOrigin = "https://www.imagine.art"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("imagine auth failed")
|
||||
ErrQuotaExhausted = errors.New("imagine quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("imagine upstream temporary error")
|
||||
)
|
||||
|
||||
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
|
||||
// (proactive, not lazy at expiry) — the maintenance sweep keeps tokens fresh so a
|
||||
// dormant account's rotating refreshToken never lapses.
|
||||
const refreshLeadSeconds = 600 // 10 minutes
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
// freshest credential per account (key: user id) + a per-account refresh lock,
|
||||
// so concurrent callers don't each spend the rotating refresh_token — the first
|
||||
// refreshes, the rest reuse the cached fresh credential.
|
||||
mu sync.Mutex
|
||||
creds map[string]string
|
||||
locks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy), creds: map[string]string{}, locks: map[string]*sync.Mutex{}}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
func (c *Client) userLock(userID string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.locks[userID]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
c.locks[userID] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type credential struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
// Email is the real account email — supplied at import, used for display and
|
||||
// (pool,email) dedup. It is NOT in the JWT (which only carries userId), so it
|
||||
// must be carried across refreshes (the refresh response omits it).
|
||||
Email string `json:"email,omitempty"`
|
||||
// ParentID is a canvas node the account OWNS, used as the generation's
|
||||
// parent_id. Imagine rejects any parent the account doesn't own ("user does
|
||||
// not have access to parent asset") and silently orphans a parent-less
|
||||
// generation (charged but never produced) — so it's supplied at import and
|
||||
// carried across refreshes.
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
}
|
||||
|
||||
func parseCred(s string) (credential, bool) {
|
||||
var cr credential
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(s)), &cr) != nil {
|
||||
return cr, false
|
||||
}
|
||||
if strings.TrimSpace(cr.Token) == "" || strings.TrimSpace(cr.RefreshToken) == "" {
|
||||
return cr, false
|
||||
}
|
||||
return cr, true
|
||||
}
|
||||
|
||||
func buildCred(token, refresh, email, parentID string) string {
|
||||
b, _ := json.Marshal(credential{
|
||||
Token: strings.TrimSpace(token),
|
||||
RefreshToken: strings.TrimSpace(refresh),
|
||||
Email: strings.TrimSpace(email),
|
||||
ParentID: strings.TrimSpace(parentID),
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ParentIDFromCred returns the canvas parent node id supplied at import.
|
||||
func ParentIDFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cr.ParentID)
|
||||
}
|
||||
|
||||
func looksLikeJWT(s string) bool {
|
||||
return len(strings.Split(strings.TrimSpace(s), ".")) == 3
|
||||
}
|
||||
|
||||
// IsImagineToken reports whether a pasted credential is an Imagine.art account:
|
||||
// a JSON object carrying a non-empty token + refreshToken that both look like
|
||||
// JWTs. Distinguishes it from adobe/leonardo/krea cookies.
|
||||
func IsImagineToken(value string) bool {
|
||||
cr, ok := parseCred(value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return looksLikeJWT(cr.Token) && looksLikeJWT(cr.RefreshToken)
|
||||
}
|
||||
|
||||
// jwtClaims base64url-decodes the JWT payload (segment 1) into a claims map.
|
||||
func jwtClaims(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
// tolerate padded variants
|
||||
if raw, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func userIDFromToken(token string) string {
|
||||
claims := jwtClaims(token)
|
||||
if claims == nil {
|
||||
return ""
|
||||
}
|
||||
if v := strings.TrimSpace(stringValue(claims["userId"])); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(stringValue(claims["sub"]))
|
||||
}
|
||||
|
||||
func tokenExp(token string) int64 {
|
||||
claims := jwtClaims(token)
|
||||
if claims == nil {
|
||||
return 0
|
||||
}
|
||||
return toInt64(claims["exp"])
|
||||
}
|
||||
|
||||
// EmailFromCred returns the real account email supplied at import; if absent it
|
||||
// falls back to the JWT userId so (pool,email) dedup still has a stable key.
|
||||
func EmailFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if e := strings.TrimSpace(cr.Email); e != "" {
|
||||
return e
|
||||
}
|
||||
return userIDFromToken(cr.Token)
|
||||
}
|
||||
|
||||
// UserIDFromCred returns the JWT userId (== org_id used for credit/generation).
|
||||
func UserIDFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return userIDFromToken(cr.Token)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RefreshIfNeeded returns a credential whose access token is still valid: if the
|
||||
// stored one is (near) expired it spends the refreshToken to mint a fresh pair
|
||||
// and rebuilds the credential. Returns (cred, changed, err); changed=true means
|
||||
// the caller must persist the new credential (both tokens rotate). ErrAuth means
|
||||
// the refreshToken is dead → the account is gone.
|
||||
func (c *Client) RefreshIfNeeded(ctx context.Context, cred string) (string, bool, error) {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return cred, false, nil // unparseable — let the downstream call surface the error
|
||||
}
|
||||
userID := userIDFromToken(cr.Token)
|
||||
now := time.Now().Unix()
|
||||
|
||||
lk := c.userLock(userID)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
|
||||
// A concurrent caller may already have refreshed this account.
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
cached := c.creds[userID]
|
||||
c.mu.Unlock()
|
||||
if cc, ok := parseCred(cached); ok && tokenExp(cc.Token)-refreshLeadSeconds > now {
|
||||
return cached, cached != cred, nil
|
||||
}
|
||||
}
|
||||
if tokenExp(cr.Token)-refreshLeadSeconds > now {
|
||||
return cred, false, nil // still valid
|
||||
}
|
||||
|
||||
respBody, status, err := c.refreshPost(ctx, cr.RefreshToken)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 400 || status == 401 || status == 403 {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120))
|
||||
}
|
||||
var rb struct {
|
||||
Result struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if json.Unmarshal(respBody, &rb) != nil || strings.TrimSpace(rb.Result.SessionToken) == "" {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
newRefresh := rb.Result.RefreshToken
|
||||
if strings.TrimSpace(newRefresh) == "" {
|
||||
newRefresh = cr.RefreshToken // some responses may omit it — keep the old one
|
||||
}
|
||||
newCred := buildCred(rb.Result.SessionToken, newRefresh, cr.Email, cr.ParentID)
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
c.creds[userID] = newCred
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return newCred, true, nil
|
||||
}
|
||||
|
||||
func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) {
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, authBase+"/apis/v1/auth/other/refresh/web", nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json, text/plain, */*"},
|
||||
"authorization": {"Bearer " + refreshToken},
|
||||
"origin": {webOrigin},
|
||||
"referer": {webOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "authorization", "origin", "referer", "user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FetchCreditsBalance reads the account's credit balance via /v1/credit.
|
||||
// remaining is the `total` field. 401/403 → ErrAuth (token dead). Returns the
|
||||
// normalized map shared by all providers.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, cred string) (map[string]any, error) {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return unknownBalance("bad credential"), nil
|
||||
}
|
||||
userID := userIDFromToken(cr.Token)
|
||||
body, status, err := c.apiGetP(ctx, cr.Token, apiBase+"/v1/credit?org_id="+userID, false)
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
|
||||
}
|
||||
var cb struct {
|
||||
Status string `json:"status"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &cb); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": cb.Total,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
"email": emptyStringNil(EmailFromCred(cred)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c *Client) apiGet(ctx context.Context, token, url string) ([]byte, int, error) {
|
||||
return c.apiGetP(ctx, token, url, true)
|
||||
}
|
||||
|
||||
// apiGetP picks the egress: polling runs direct (local IP).
|
||||
func (c *Client) apiGetP(ctx context.Context, token, url string, useProxy bool) ([]byte, int, error) {
|
||||
client, err := c.newTLSClientP(useProxy)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json, text/plain, */*"},
|
||||
"authorization": {"Bearer " + token},
|
||||
"origin": {webOrigin},
|
||||
"referer": {webOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "authorization", "origin", "referer", "user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
||||
|
||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
||||
// polling and result download.
|
||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
||||
|
||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small util
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func uuid4() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
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 toInt64(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x)
|
||||
case int64:
|
||||
return x
|
||||
case int:
|
||||
return int64(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package imagine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
// GenerateImage runs the full Imagine.art pipeline: submit the txt2img job, poll
|
||||
// the org objects feed until the batch finishes, then download the produced
|
||||
// image. styleID picks the model (41001 = 1.5 / 2K, 41004 = 1.5pro / 4K).
|
||||
// HTTP 402 → ErrQuotaExhausted. These models are pure text2img (no refs).
|
||||
func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, resolution, aspectRatio, prompt string, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
userID := userIDFromToken(cr.Token)
|
||||
|
||||
metadata, _ := json.Marshal(map[string]any{
|
||||
"placeholderUuid": uuid4(),
|
||||
"promptWithoutManipulation": prompt,
|
||||
"modeId": 0,
|
||||
})
|
||||
// parent_id MUST be a canvas node this account owns — the server rejects a
|
||||
// foreign id ("user does not have access to parent asset") and silently
|
||||
// orphans a parent-less generation (charged but never produced). It's supplied
|
||||
// at import (credential.parentId).
|
||||
fields := map[string]string{
|
||||
"style_id": strconv.Itoa(styleID),
|
||||
"aspect_ratio": aspectRatio,
|
||||
"resolution": resolution,
|
||||
"variation": "txt2img",
|
||||
"prompt": prompt,
|
||||
"is_enhance": "0",
|
||||
"count": "1",
|
||||
"clientVersion": "1",
|
||||
"org_id": userID,
|
||||
"use_plugin": "false",
|
||||
"metadata": string(metadata),
|
||||
}
|
||||
if pid := strings.TrimSpace(cr.ParentID); pid != "" {
|
||||
fields["parent_id"] = pid
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
_ = w.WriteField(k, v)
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
body, status, err := c.apiPost(ctx, cr.Token, apiBase+"/v1/image/generations/upload", w.FormDataContentType(), buf.Bytes())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if status == 402 {
|
||||
return nil, nil, ErrQuotaExhausted
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200))
|
||||
}
|
||||
var jobs []struct {
|
||||
BatchID string `json:"batchId"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].BatchID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: no batch id: %s", ErrTemporaryUpstream, clip(body, 200))
|
||||
}
|
||||
batchID := jobs[0].BatchID
|
||||
|
||||
imageURL, err := c.pollImage(ctx, cr.Token, userID, batchID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meta := map[string]any{"batch_id": batchID, "image_url": imageURL, "org_id": userID}
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, imageURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// pollImage polls the org objects feed until the entry for our batch finishes,
|
||||
// then extracts its asset URL (image_url is a JSON-encoded array string).
|
||||
func (c *Client) pollImage(ctx context.Context, token, userID, batchID string) (string, error) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Poll for the full generation budget (caller's genCtx), leaving headroom for
|
||||
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
|
||||
deadline := time.Now().Add(4 * time.Minute)
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
deadline = dl.Add(-60 * time.Second)
|
||||
}
|
||||
|
||||
url := teamsBase + "/v1/org/" + userID + "/objects?batch=true&limit=50&service=image,chat-image"
|
||||
for {
|
||||
body, status, err := c.apiGetP(ctx, token, url, false)
|
||||
if err == nil && status == 200 {
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
Status string `json:"status"`
|
||||
Code int `json:"code"`
|
||||
// Current shape: the produced asset lives at url.generation[0].
|
||||
URL struct {
|
||||
Generation []string `json:"generation"`
|
||||
} `json:"url"`
|
||||
ImageURL string `json:"image_url"` // legacy fallback
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(body, &resp) == nil {
|
||||
for _, o := range resp.Data {
|
||||
if o.BatchID != batchID {
|
||||
continue
|
||||
}
|
||||
st := strings.ToLower(strings.TrimSpace(o.Status))
|
||||
switch {
|
||||
case st == "finished" || o.Code == 2:
|
||||
if u := firstNonEmpty(o.URL.Generation); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
if u := firstImageURL(o.ImageURL); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
case st == "failed" || st == "error":
|
||||
return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, st)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-blank string in a slice.
|
||||
func firstNonEmpty(ss []string) string {
|
||||
for _, s := range ss {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstImageURL parses the image_url field — a JSON-encoded array of URLs — and
|
||||
// returns the first one. Tolerates a bare string too.
|
||||
func firstImageURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
var urls []string
|
||||
if json.Unmarshal([]byte(raw), &urls) == nil {
|
||||
for _, u := range urls {
|
||||
if strings.TrimSpace(u) != "" {
|
||||
return strings.TrimSpace(u)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "http") {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, url string) ([]byte, error) {
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
|
||||
"user-agent": {userAgent},
|
||||
"referer": {webOrigin + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// apiPost issues a POST with a raw body + content-type, carrying the bearer token.
|
||||
func (c *Client) apiPost(ctx context.Context, token, url, contentType string, body []byte) ([]byte, int, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json, text/plain, */*"},
|
||||
"authorization": {"Bearer " + token},
|
||||
"content-type": {contentType},
|
||||
"origin": {webOrigin},
|
||||
"referer": {webOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "authorization", "content-type", "origin", "referer", "user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
// Package krea implements the Krea.ai (krea.ai) provider client. The durable
|
||||
// credential is the browser cookie (Supabase "sb-superb-auth-token"); Krea's own
|
||||
// Next.js backend reads it directly, so quota and generation just forward the
|
||||
// cookie — there's no separate token-exchange step. tls-client gives a Chrome
|
||||
// JA3/JA4 so Krea's Cloudflare edge doesn't flag the requests.
|
||||
package krea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
// kreaAnonKey is Krea's public Supabase anon key (fixed, embedded in their
|
||||
// frontend) — required as the apikey/bearer when refreshing a session token.
|
||||
const kreaAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1Mjc4ODU3LCJleHAiOjE5MzI5NTg4NTd9.NUiqEOd__QsCCMjo3D1zrCAda5dLV2F5p6Kf584sZKc"
|
||||
|
||||
const (
|
||||
apiBase = "https://www.krea.ai"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("krea auth failed")
|
||||
ErrQuotaExhausted = errors.New("krea quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("krea upstream temporary error")
|
||||
)
|
||||
|
||||
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
|
||||
// (not lazily at expiry). The maintenance sweep refreshes proactively, so a
|
||||
// dormant account's token never lapses — once the rotating refresh_token is gone
|
||||
// (expired/consumed) the account can't recover, so we keep it perpetually fresh.
|
||||
const refreshLeadSeconds = 600 // 10 minutes
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
// freshest cookie per account (key: user id) + a per-account refresh lock, so
|
||||
// concurrent callers don't each spend the single-use (rotating) refresh_token —
|
||||
// the first refreshes, the rest reuse the cached fresh cookie.
|
||||
mu sync.Mutex
|
||||
cookies map[string]string
|
||||
locks map[string]*sync.Mutex
|
||||
// actAt = last /app activation time per account (key: user id); actLocks gives
|
||||
// a per-account lock so concurrent generations wait for the first to finish the
|
||||
// (once-per-daily-reset) activation instead of each loading /app.
|
||||
actAt map[string]int64
|
||||
actLocks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{
|
||||
proxy: strings.TrimSpace(proxy),
|
||||
cookies: map[string]string{},
|
||||
locks: map[string]*sync.Mutex{},
|
||||
actAt: map[string]int64{},
|
||||
actLocks: map[string]*sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) userLock(userID string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.locks[userID]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
c.locks[userID] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// RefreshIfNeeded returns a cookie whose access_token is still valid: if the
|
||||
// stored one is (near) expired it spends the refresh_token to mint a new session
|
||||
// and rebuilds the cookie. Returns (cookie, changed, err); changed=true means the
|
||||
// caller must persist the new cookie (the refresh_token rotated). ErrAuth means
|
||||
// the refresh_token is dead → the account is gone.
|
||||
func (c *Client) RefreshIfNeeded(ctx context.Context, cookie string) (string, bool, error) {
|
||||
authVal := authCookieValue(cookie)
|
||||
sess, ok := decodeSession(authVal)
|
||||
if !ok {
|
||||
return cookie, false, nil // unparseable — let the downstream call surface the error
|
||||
}
|
||||
userID := nestedStr(sess, "user", "id")
|
||||
now := time.Now().Unix()
|
||||
|
||||
lk := c.userLock(userID)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
|
||||
// A concurrent caller may already have refreshed this account.
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
cached := c.cookies[userID]
|
||||
c.mu.Unlock()
|
||||
if cs, ok := decodeSession(authCookieValue(cached)); ok && toInt64(cs["expires_at"])-refreshLeadSeconds > now {
|
||||
return cached, cached != cookie, nil
|
||||
}
|
||||
}
|
||||
if toInt64(sess["expires_at"])-refreshLeadSeconds > now {
|
||||
return cookie, false, nil // still valid
|
||||
}
|
||||
refreshTok := strings.TrimSpace(stringValue(sess["refresh_token"]))
|
||||
if refreshTok == "" {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
|
||||
respBody, status, err := c.refreshPost(ctx, refreshTok)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 400 || status == 401 || status == 403 {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120))
|
||||
}
|
||||
var ns map[string]any
|
||||
if json.Unmarshal(respBody, &ns) != nil || strings.TrimSpace(stringValue(ns["access_token"])) == "" {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
newCookie := replaceAuthCookie(cookie, "base64-"+base64.StdEncoding.EncodeToString(respBody))
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
c.cookies[userID] = newCookie
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return newCookie, true, nil
|
||||
}
|
||||
|
||||
func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) {
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken})
|
||||
req, err := http.NewRequest(http.MethodPost, apiBase+"/auth/v1/token?grant_type=refresh_token", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"content-type": {"application/json;charset=UTF-8"},
|
||||
"apikey": {kreaAnonKey},
|
||||
"authorization": {"Bearer " + kreaAnonKey},
|
||||
"x-client-info": {"supabase-ssr/0.6.1 createBrowserClient"},
|
||||
"x-supabase-api-version": {"2024-01-01"},
|
||||
"origin": {apiBase},
|
||||
"referer": {apiBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "content-type", "apikey", "authorization", "x-client-info",
|
||||
"x-supabase-api-version", "origin", "referer", "user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
|
||||
// chunkSize is supabase-ssr's per-cookie chunk limit; larger sessions (e.g.
|
||||
// Google-OAuth accounts) are split into sb-superb-auth-token.0/.1/...
|
||||
const chunkSize = 3600
|
||||
|
||||
func cookieVal(cookie, name string) string {
|
||||
for _, p := range strings.Split(cookie, ";") {
|
||||
p = strings.TrimSpace(p)
|
||||
if v, ok := strings.CutPrefix(p, name+"="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// authCookieValue returns the full auth value, transparently reassembling a
|
||||
// chunked cookie (sb-superb-auth-token.0 + .1 + ...) or returning the single one.
|
||||
func authCookieValue(cookie string) string {
|
||||
if v := cookieVal(cookie, "sb-superb-auth-token"); v != "" {
|
||||
return v
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; ; i++ {
|
||||
v := cookieVal(cookie, fmt.Sprintf("sb-superb-auth-token.%d", i))
|
||||
if v == "" {
|
||||
break
|
||||
}
|
||||
b.WriteString(v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// replaceAuthCookie drops every sb-superb-auth-token[.N] cookie and re-adds the
|
||||
// new value (chunked the same way supabase-ssr would if it's large), preserving
|
||||
// all other cookies (krea-workspace-id, etc.).
|
||||
func replaceAuthCookie(cookie, newValue string) string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(cookie, ";") {
|
||||
t := strings.TrimSpace(p)
|
||||
if t == "" || strings.HasPrefix(t, "sb-superb-auth-token=") || strings.HasPrefix(t, "sb-superb-auth-token.") {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if len(newValue) <= chunkSize {
|
||||
out = append(out, "sb-superb-auth-token="+newValue)
|
||||
} else {
|
||||
for i, off := 0, 0; off < len(newValue); i++ {
|
||||
end := off + chunkSize
|
||||
if end > len(newValue) {
|
||||
end = len(newValue)
|
||||
}
|
||||
out = append(out, fmt.Sprintf("sb-superb-auth-token.%d=%s", i, newValue[off:end]))
|
||||
off = end
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
// decodeSession base64-decodes the auth cookie value into the session JSON map.
|
||||
func decodeSession(authValue string) (map[string]any, bool) {
|
||||
v := strings.TrimPrefix(strings.TrimSpace(authValue), "base64-")
|
||||
if v == "" {
|
||||
return nil, false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
raw, err = base64.RawURLEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return nil, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
func nestedStr(m map[string]any, k1, k2 string) string {
|
||||
if sub, ok := m[k1].(map[string]any); ok {
|
||||
return strings.TrimSpace(stringValue(sub[k2]))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toInt64(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x)
|
||||
case int64:
|
||||
return x
|
||||
case int:
|
||||
return int64(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
// IsKreaCookie reports whether a pasted credential is a Krea cookie: it carries
|
||||
// the Supabase auth cookie. Distinguishes it from adobe/leonardo cookies.
|
||||
func IsKreaCookie(value string) bool {
|
||||
return strings.Contains(value, "sb-superb-auth-token")
|
||||
}
|
||||
|
||||
// EmailFromCookie decodes the account email straight out of the cookie's embedded
|
||||
// Supabase session (no network), handling chunked cookies too.
|
||||
func EmailFromCookie(cookie string) string {
|
||||
sess, ok := decodeSession(authCookieValue(cookie))
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return nestedStr(sess, "user", "email")
|
||||
}
|
||||
|
||||
// FetchCreditsBalance reads the account's free-credit balance via /api/billing-data.
|
||||
// remaining is the integer floor of balance.free (per spec: 17.94 → 17). 401 →
|
||||
// ErrAuth (cookie dead). Returns the normalized map shared by all providers.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) {
|
||||
// Detach from the request ctx so a page refresh can't cancel the probe
|
||||
// mid-flight (which left accounts stuck at "—").
|
||||
probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
// NOTE: no /app here — the heavy SSR activation is done separately (on recovery
|
||||
// and at generation via Activate). This probe just reads the current balance.
|
||||
body, status, err := c.apiGetP(probeCtx, cookie, "/api/billing-data", false)
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
|
||||
}
|
||||
// 余额在 balance.free(真实剩余,小数,随用量递减)。krea 的这个字段一直都在,
|
||||
// 只是排在很长的 entitlements 之后 —— 只读它,不用套餐配额兜底。
|
||||
var bd struct {
|
||||
Balance struct {
|
||||
Free float64 `json:"free"`
|
||||
Total float64 `json:"total"`
|
||||
} `json:"balance"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &bd); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
remaining := int(bd.Balance.Free) // floor
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": nil,
|
||||
"total": int(bd.Balance.Total),
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
"email": emptyStringNil(EmailFromCookie(cookie)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Activate loads the authenticated SSR app page (/app), which is what makes krea
|
||||
// grant the account's DAILY free balance — a cold API-only call (billing-data /
|
||||
// generate) otherwise sees balance.free=0 and 402s. Done at most ONCE per account
|
||||
// per daily reset, under a per-account lock: the first caller loads /app while
|
||||
// concurrent callers wait, then everyone proceeds (no redundant /app). Called
|
||||
// before each generation and by the daily activation sweep. Best-effort.
|
||||
func (c *Client) Activate(ctx context.Context, cookie string) {
|
||||
key := accountKey(cookie)
|
||||
lastReset := (time.Now().Unix() / 86400) * 86400
|
||||
c.mu.Lock()
|
||||
doneToday := key != "" && c.actAt[key] >= lastReset
|
||||
c.mu.Unlock()
|
||||
if doneToday {
|
||||
return
|
||||
}
|
||||
lk := c.actLock(key)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
// Re-check after acquiring the lock — another caller may have just activated.
|
||||
c.mu.Lock()
|
||||
doneToday = key != "" && c.actAt[key] >= lastReset
|
||||
c.mu.Unlock()
|
||||
if doneToday {
|
||||
return
|
||||
}
|
||||
actCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
|
||||
defer cancel()
|
||||
_, _, _ = c.apiGetP(actCtx, cookie, "/app", false)
|
||||
c.mu.Lock()
|
||||
c.actAt[key] = time.Now().Unix()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) actLock(key string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.actLocks[key]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
c.actLocks[key] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// accountKey is the stable per-account id (the Supabase user id from the session
|
||||
// cookie) used to key activation state — survives cookie rotation.
|
||||
func accountKey(cookie string) string {
|
||||
if sess, ok := decodeSession(authCookieValue(cookie)); ok {
|
||||
return nestedStr(sess, "user", "id")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// apiGet issues a GET to a krea.ai API path carrying the account cookie.
|
||||
func (c *Client) apiGet(ctx context.Context, cookie, path string) ([]byte, int, error) {
|
||||
return c.apiGetP(ctx, cookie, path, true)
|
||||
}
|
||||
|
||||
// apiGetP picks the egress: polling / asset resolution run direct (local IP).
|
||||
func (c *Client) apiGetP(ctx context.Context, cookie, path string, useProxy bool) ([]byte, int, error) {
|
||||
client, err := c.newTLSClientP(useProxy)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, apiBase+path, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"cookie": {cookie},
|
||||
"referer": {apiBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "cookie", "referer", "user-agent",
|
||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
||||
|
||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
||||
// reference-image upload, polling and result download.
|
||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
||||
|
||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
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 intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case float64:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
package krea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
genModel = "flux2-klein4b" // the single exposed Krea model
|
||||
genEndpoint = "/api/jobs/v2/new/fluxKlein4b"
|
||||
refStrength = 0.4
|
||||
)
|
||||
|
||||
// ensureProject returns a flux project id for the account: the first existing
|
||||
// project, or a freshly created one. Generation requires a project.
|
||||
func (c *Client) ensureProject(ctx context.Context, cookie string) (string, error) {
|
||||
body, status, err := c.apiGetP(ctx, cookie, "/api/flux-projects", false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: list projects: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if status == 200 {
|
||||
var projs []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(body, &projs) == nil {
|
||||
for _, p := range projs {
|
||||
if strings.TrimSpace(p.ID) != "" {
|
||||
return p.ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No project on this account (e.g. brand-new). Try to create one; if that
|
||||
// fails, fall back to generating WITHOUT a project (Krea assigns a default).
|
||||
cb, cs, cerr := c.apiPostJSON(ctx, cookie, "/api/flux-projects", map[string]any{"title": "vivid"})
|
||||
if cerr == nil && (cs == 200 || cs == 201) {
|
||||
var pr struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(cb, &pr) == nil && pr.ID != "" {
|
||||
return pr.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil // generate without an explicit project
|
||||
}
|
||||
|
||||
// uploadImage uploads a reference image (i2i) and returns its app-uploads URL.
|
||||
func (c *Client) uploadImage(ctx context.Context, cookie string, img []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", "image.png")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fw.Write(img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = w.Close()
|
||||
body, status, err := c.apiPostP(ctx, cookie, "/api/upload?", w.FormDataContentType(), buf.Bytes(), false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: upload: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return "", fmt.Errorf("%w: upload http %d: %s", ErrTemporaryUpstream, status, clip(body, 160))
|
||||
}
|
||||
var ur struct {
|
||||
ImageURL string `json:"imageUrl"`
|
||||
}
|
||||
if json.Unmarshal(body, &ur) != nil || ur.ImageURL == "" {
|
||||
return "", fmt.Errorf("%w: no imageUrl", ErrTemporaryUpstream)
|
||||
}
|
||||
return ur.ImageURL, nil
|
||||
}
|
||||
|
||||
// GenerateImage runs the full Krea image pipeline: ensure a project, (for i2i)
|
||||
// upload reference images, submit the job, poll until done, then resolve and
|
||||
// download the produced image. 402 INSUFFICIENT_BALANCE → ErrQuotaExhausted.
|
||||
func (c *Client) GenerateImage(ctx context.Context, cookie, prompt string, width, height int, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
// Ensure the daily free balance is granted (load /app) before generating, so a
|
||||
// not-yet-activated account doesn't 402 INSUFFICIENT_BALANCE. Lock-guarded and
|
||||
// once-per-daily-reset — concurrent gens wait for the first activation, already
|
||||
// activated ones skip straight through.
|
||||
c.Activate(ctx, cookie)
|
||||
|
||||
projectID, err := c.ensureProject(ctx, cookie)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var styleImages []map[string]any
|
||||
for _, img := range refImages {
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
url, upErr := c.uploadImage(ctx, cookie, img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
styleImages = append(styleImages, map[string]any{"url": url, "strength": refStrength, "source": "upload"})
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"provider": genModel,
|
||||
"prompt": prompt,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"strength": 1,
|
||||
"steps": 28,
|
||||
"guidance_scale_flux": 3.5,
|
||||
"presetStyles": []any{},
|
||||
"batchSize": 2,
|
||||
"guidance": 3.5,
|
||||
}
|
||||
if projectID != "" {
|
||||
payload["project"] = projectID
|
||||
}
|
||||
if len(styleImages) > 0 {
|
||||
payload["styleImages"] = styleImages
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
|
||||
// Submit (multipart with a single "payload" field).
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("payload", string(payloadJSON))
|
||||
_ = w.Close()
|
||||
body, status, err := c.apiPost(ctx, cookie, genEndpoint, w.FormDataContentType(), buf.Bytes())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if status == 402 || strings.Contains(string(body), "INSUFFICIENT_BALANCE") {
|
||||
return nil, nil, ErrQuotaExhausted
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200))
|
||||
}
|
||||
var jobs []struct {
|
||||
JobID string `json:"job_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].JobID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: no job id: %s", ErrTemporaryUpstream, clip(body, 200))
|
||||
}
|
||||
// batchSize=2 returns two jobs; keep the SECOND image and discard the first
|
||||
// (per spec). Fall back to the first if only one came back.
|
||||
jobID := jobs[0].JobID
|
||||
if len(jobs) >= 2 && jobs[1].JobID != "" {
|
||||
jobID = jobs[1].JobID
|
||||
}
|
||||
|
||||
// Poll until terminal, then resolve the produced image.
|
||||
imageURL, err := c.pollImage(ctx, cookie, jobID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meta := map[string]any{"job_id": jobID, "image_url": imageURL, "project": projectID}
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, imageURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// pollImage polls job-status until the job leaves the queue, then matches the
|
||||
// produced asset by generation_job_id and returns its image URL.
|
||||
func (c *Client) pollImage(ctx context.Context, cookie, jobID string) (string, error) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Poll for the full generation budget (caller's genCtx), leaving headroom for
|
||||
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
|
||||
deadline := time.Now().Add(4 * time.Minute)
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
deadline = dl.Add(-60 * time.Second)
|
||||
}
|
||||
|
||||
for {
|
||||
body, status, err := c.apiGetP(ctx, cookie, "/api/job-status?id="+jobID, false)
|
||||
if err == nil && status == 200 {
|
||||
var js struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if json.Unmarshal(body, &js) == nil {
|
||||
switch strings.ToLower(js.Status) {
|
||||
case "complete", "completed", "succeeded", "success", "done", "finished":
|
||||
if url, e := c.assetForJob(ctx, cookie, jobID); e == nil && url != "" {
|
||||
return url, nil
|
||||
}
|
||||
case "failed", "error", "cancelled", "canceled":
|
||||
return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, js.Status)
|
||||
}
|
||||
}
|
||||
} else if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assetForJob finds the generated asset produced by a job and returns its URL.
|
||||
func (c *Client) assetForJob(ctx context.Context, cookie, jobID string) (string, error) {
|
||||
body, status, err := c.apiGetP(ctx, cookie, "/api/assets?filter=generated&offset=0", false)
|
||||
if err != nil || status != 200 {
|
||||
return "", fmt.Errorf("assets http %d", status)
|
||||
}
|
||||
var assets []struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
Metadata struct {
|
||||
GenerationJobID string `json:"generation_job_id"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
if json.Unmarshal(body, &assets) != nil {
|
||||
return "", fmt.Errorf("assets non-json")
|
||||
}
|
||||
for _, a := range assets {
|
||||
if a.Metadata.GenerationJobID == jobID && strings.TrimSpace(a.ImageURL) != "" {
|
||||
return a.ImageURL, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("asset not found yet")
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, url string) ([]byte, error) {
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
|
||||
"user-agent": {userAgent},
|
||||
"referer": {apiBase + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// apiPost issues a POST with a raw body + content-type, carrying the cookie.
|
||||
func (c *Client) apiPost(ctx context.Context, cookie, path, contentType string, body []byte) ([]byte, int, error) {
|
||||
return c.apiPostP(ctx, cookie, path, contentType, body, true)
|
||||
}
|
||||
|
||||
// apiPostP picks the egress: reference-image upload runs direct (local IP), the
|
||||
// generate submit uses the proxy.
|
||||
func (c *Client) apiPostP(ctx context.Context, cookie, path, contentType string, body []byte, useProxy bool) ([]byte, int, error) {
|
||||
client, err := c.newTLSClientP(useProxy)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, apiBase+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"content-type": {contentType},
|
||||
"cookie": {cookie},
|
||||
"origin": {apiBase},
|
||||
"referer": {apiBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "content-type", "cookie", "origin",
|
||||
"referer", "user-agent", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
|
||||
func (c *Client) apiPostJSON(ctx context.Context, cookie, path string, payload any) ([]byte, int, error) {
|
||||
// project bootstrap runs on the local IP; only the generate submit uses the proxy.
|
||||
b, _ := json.Marshal(payload)
|
||||
return c.apiPostP(ctx, cookie, path, "application/json", b, false)
|
||||
}
|
||||
@@ -1,732 +0,0 @@
|
||||
// Package leonardo implements the Leonardo.ai (app.leonardo.ai) provider client.
|
||||
// Unlike chatgpt/runway (whose JWT IS the stored credential), Leonardo's durable
|
||||
// credential is the browser COOKIE (better-auth session): the bearer access token
|
||||
// it mints lives only ~1h. So every call here takes the cookie and derives a
|
||||
// fresh JWT on the fly via /api/auth/get-session — there is no long-lived token to
|
||||
// store or a separate refresh profile to maintain. tls-client gives a Chrome
|
||||
// JA3/JA4 fingerprint so the requests aren't flagged.
|
||||
package leonardo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
const (
|
||||
appBase = "https://app.leonardo.ai"
|
||||
graphqlURL = "https://api.leonardo.ai/v1/graphql"
|
||||
schemaVersion = "1.255.2"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
||||
// sec-ch-ua must agree with userAgent's major version — a mismatch is itself a
|
||||
// bot signal.
|
||||
secChUA = `"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"`
|
||||
// get-session only answers with a token once better-auth's cookie cache has
|
||||
// been refreshed by a cross-origin-cookie call; called cold it returns 200
|
||||
// null, which looks exactly like a dead cookie. It also sits behind Vercel's
|
||||
// checkpoint, which 429s machine requests from some exit IPs — so a failed
|
||||
// attempt is retried, falling back to the proxy for a different exit IP.
|
||||
getSessionAttempts = 10
|
||||
getSessionRetryDelay = 2 * time.Second
|
||||
// A warmed 200 null usually means the session is gone server-side, but the
|
||||
// same answer also comes back when the checkpoint silently serves a session-
|
||||
// less page to an exit IP — so retry it a few times (later attempts through
|
||||
// the proxy) before calling the account dead. Fewer attempts than the 429
|
||||
// budget: each one costs two requests and a truly dead cookie never recovers.
|
||||
getSessionNullAttempts = 3
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("leonardo auth failed")
|
||||
ErrQuotaExhausted = errors.New("leonardo quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("leonardo upstream temporary error")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
// sessions caches the short-lived access token per cookie so we don't hit
|
||||
// /api/auth/get-session on every call — Leonardo rate-limits that endpoint
|
||||
// (429) hard, so re-using the ~1h JWT is essential.
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
// rotated maps a stored cookie to the freshest value Leonardo handed back via
|
||||
// Set-Cookie (better-auth rotates its session_data cookie cache). The service
|
||||
// persists it; keeping it here means an unpersisted rotation still works for
|
||||
// the rest of the process's life.
|
||||
rotated map[string]string
|
||||
// refreshing serialises get-session per cookie. Two concurrent refreshes hand
|
||||
// Cognito the same refresh token twice and its reuse detection revokes the
|
||||
// whole session — the account then answers 401 forever.
|
||||
refreshing map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}, rotated: map[string]string{}, refreshing: map[string]*sync.Mutex{}}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
// IsLeonardoCookie reports whether a pasted credential is a Leonardo cookie: it
|
||||
// carries the better-auth session cookie name. This is what disambiguates it from
|
||||
// an Adobe cookie at import time.
|
||||
func IsLeonardoCookie(value string) bool {
|
||||
return strings.Contains(value, "__Secure-better-auth.session_token") ||
|
||||
strings.Contains(value, "better-auth.session_data")
|
||||
}
|
||||
|
||||
// HasSessionData reports whether the cookie carries better-auth's session_data
|
||||
// cache. Leonardo authenticates get-session off THAT cookie: session_token alone
|
||||
// answers 200 null (no bearer), which looks exactly like a dead account — so a
|
||||
// cookie without it must be rejected at import instead of dying later.
|
||||
func HasSessionData(value string) bool {
|
||||
return strings.Contains(value, "better-auth.session_data")
|
||||
}
|
||||
|
||||
// RotatedCookie returns the freshest value for a stored cookie when Leonardo
|
||||
// rotated its session_data cache, so the caller can persist it.
|
||||
func (c *Client) RotatedCookie(cookie string) (string, bool) {
|
||||
key := strings.TrimSpace(cookie)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
fresh, ok := c.rotated[key]
|
||||
return fresh, ok && fresh != key
|
||||
}
|
||||
|
||||
// mergeCookies applies a response's Set-Cookie pairs onto a request cookie
|
||||
// string, keeping the original order and appending new names.
|
||||
func mergeCookies(cookie string, setCookies []string) string {
|
||||
if len(setCookies) == 0 {
|
||||
return cookie
|
||||
}
|
||||
updates := map[string]string{}
|
||||
order := []string{}
|
||||
for _, sc := range setCookies {
|
||||
pair := strings.TrimSpace(strings.Split(sc, ";")[0])
|
||||
name, value, ok := strings.Cut(pair, "=")
|
||||
name = strings.TrimSpace(name)
|
||||
if !ok || name == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := updates[name]; !seen {
|
||||
order = append(order, name)
|
||||
}
|
||||
updates[name] = value
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return cookie
|
||||
}
|
||||
var out []string
|
||||
used := map[string]bool{}
|
||||
for _, part := range strings.Split(cookie, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(part, "=")
|
||||
name = strings.TrimSpace(name)
|
||||
if v, ok := updates[name]; ok {
|
||||
used[name] = true
|
||||
if v == "" { // a cleared cookie drops out
|
||||
continue
|
||||
}
|
||||
out = append(out, name+"="+v)
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
for _, name := range order {
|
||||
if used[name] || updates[name] == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, name+"="+updates[name])
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
// keepsSession reports whether a merged cookie still carries BOTH components
|
||||
// get-session needs: the session token and better-auth's session_data cache.
|
||||
// A merge that loses either one (a Set-Cookie clearing a cache chunk) must be
|
||||
// discarded — sending it would answer 200 null, i.e. look like a dead account.
|
||||
func keepsSession(cookie string) bool {
|
||||
return strings.Contains(cookie, "__Secure-better-auth.session_token") && HasSessionData(cookie)
|
||||
}
|
||||
|
||||
// Session is the result of /api/auth/get-session: the short-lived bearer plus the
|
||||
// ids the GraphQL API needs (cognitoSub for the quota query, userId for the feed
|
||||
// and the CDN image path) and the human-facing account fields.
|
||||
type Session struct {
|
||||
AccessToken string
|
||||
CognitoSub string
|
||||
UserID string
|
||||
Email string
|
||||
Name string
|
||||
ExpiresAt int64
|
||||
// Cookie is the cookie that produced this session, with any Set-Cookie
|
||||
// rotation applied — persist it so the account keeps authenticating.
|
||||
Cookie string
|
||||
}
|
||||
|
||||
// GetSession exchanges the cookie for a fresh access token + account ids. Only a
|
||||
// 401 or a 200 carrying no access token means the session is dead → ErrAuth;
|
||||
// everything else (notably the 403/429 人机校验 page) is a temporary error.
|
||||
func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error) {
|
||||
cookie = strings.TrimSpace(cookie)
|
||||
if cookie == "" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
// Re-use a cached, still-valid access token (keep a 60s safety margin) instead
|
||||
// of hitting the heavily rate-limited get-session endpoint again.
|
||||
c.mu.Lock()
|
||||
if cs, ok := c.sessions[cookie]; ok && cs.ExpiresAt-60 > time.Now().Unix() {
|
||||
c.mu.Unlock()
|
||||
return cs, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Only one refresh per cookie at a time; the others wait and then re-use the
|
||||
// token it minted.
|
||||
gate := c.refreshGate(cookie)
|
||||
gate.Lock()
|
||||
defer gate.Unlock()
|
||||
c.mu.Lock()
|
||||
if cs, ok := c.sessions[cookie]; ok && cs.ExpiresAt-60 > time.Now().Unix() {
|
||||
c.mu.Unlock()
|
||||
return cs, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Use the freshest known value (an earlier response may have rotated the
|
||||
// better-auth cookie cache) rather than the possibly stale stored cookie.
|
||||
send := cookie
|
||||
c.mu.Lock()
|
||||
if fresh, ok := c.rotated[cookie]; ok && fresh != "" {
|
||||
send = fresh
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
var body []byte
|
||||
var status int
|
||||
var setCookies []string
|
||||
var err error
|
||||
var warmed bool
|
||||
// 401 from cross-origin-cookie means Leonardo已经作废了这个 session(不是人机校验),
|
||||
// 记下来好把日志写成"会话被吊销"而不是含糊的 get-session null。
|
||||
warmStatus := 0
|
||||
for attempt := 0; attempt < getSessionAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(getSessionRetryDelay):
|
||||
}
|
||||
}
|
||||
// The local IP normally passes the checkpoint; only once it doesn't is the
|
||||
// proxy worth its latency (and its own share of 429s).
|
||||
useProxy := attempt > 0 && c.proxy != ""
|
||||
client, cerr := c.newTLSClientP(useProxy)
|
||||
if cerr != nil {
|
||||
err = cerr
|
||||
continue
|
||||
}
|
||||
// Warm up on the same connection: cross-origin-cookie hands back a fresh
|
||||
// better-auth cookie cache (and CF_Access_Token). Without it get-session
|
||||
// answers 200 null even for a perfectly healthy cookie.
|
||||
warmed = false
|
||||
warmCookies, wstatus, werr := c.warmSession(ctx, client, send)
|
||||
warmStatus = wstatus
|
||||
if werr == nil {
|
||||
warmed = true
|
||||
if merged := mergeCookies(send, warmCookies); merged != send && keepsSession(merged) {
|
||||
send = merged
|
||||
}
|
||||
}
|
||||
status, body, setCookies, err = c.fetchSession(ctx, client, send)
|
||||
if err == nil && warmed && status != 429 && status != 403 {
|
||||
if status == 200 && sessionAccessToken(body) == "" && attempt < getSessionNullAttempts-1 {
|
||||
continue // retry a null session on another exit IP before giving up
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
// Only a real app answer may rotate the stored cookie. The 403/429 人机校验 页
|
||||
// also sends Set-Cookie (often CLEARING better-auth cookies), and persisting
|
||||
// that would strip the session_data cache — after which get-session answers
|
||||
// 200 null and a perfectly healthy account looks dead.
|
||||
if status == 200 {
|
||||
if merged := mergeCookies(send, setCookies); keepsSession(merged) {
|
||||
send = merged
|
||||
}
|
||||
if send != cookie {
|
||||
c.mu.Lock()
|
||||
c.rotated[cookie] = send
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
if status == 401 {
|
||||
return nil, fmt.Errorf("%w: get-session http 401: %s", ErrAuth, clip(body, 160))
|
||||
}
|
||||
if status != 200 {
|
||||
// 403 / 429 here is the Vercel / Cloudflare 人机校验 页,不是 cookie 失效 —
|
||||
// 当成临时错误,否则健康的号会被误判死。
|
||||
return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, status, clip(body, 160))
|
||||
}
|
||||
var raw struct {
|
||||
Session struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
CognitoSub string `json:"cognitoSub"`
|
||||
UserID string `json:"userId"`
|
||||
HasuraUserID string `json:"hasuraUserId"`
|
||||
TokenExpiry int64 `json:"accessTokenExpiry"`
|
||||
} `json:"session"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("%w: get-session non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
if strings.TrimSpace(raw.Session.AccessToken) == "" {
|
||||
if warmStatus == 401 {
|
||||
// Leonardo 明确拒了这份 session_token:服务端已把会话吊销,重新导入 cookie 才能恢复。
|
||||
return nil, fmt.Errorf("%w: session revoked (cross-origin-cookie 401)", ErrAuth)
|
||||
}
|
||||
if !warmed {
|
||||
// A cold get-session (the cookie cache was never refreshed) answers null
|
||||
// for healthy accounts too — temporary, never a reason to kill the account.
|
||||
return nil, fmt.Errorf("%w: get-session 200 without accessToken (no warmup): %s", ErrTemporaryUpstream, clip(body, 160))
|
||||
}
|
||||
// No bearer despite 200 → the cookie no longer authenticates. Carry the body
|
||||
// so the log says WHICH shape it was (null session vs a session without a
|
||||
// token) instead of a bare "auth failed".
|
||||
return nil, fmt.Errorf("%w: get-session 200 without accessToken: %s", ErrAuth, clip(body, 160))
|
||||
}
|
||||
uid := raw.Session.UserID
|
||||
if uid == "" {
|
||||
uid = raw.Session.HasuraUserID
|
||||
}
|
||||
if uid == "" {
|
||||
uid = raw.User.ID
|
||||
}
|
||||
sess := &Session{
|
||||
AccessToken: raw.Session.AccessToken,
|
||||
CognitoSub: raw.Session.CognitoSub,
|
||||
UserID: uid,
|
||||
Email: strings.TrimSpace(raw.User.Email),
|
||||
Name: strings.TrimSpace(raw.User.Name),
|
||||
ExpiresAt: raw.Session.TokenExpiry,
|
||||
Cookie: send,
|
||||
}
|
||||
if sess.ExpiresAt > time.Now().Unix() {
|
||||
c.mu.Lock()
|
||||
c.sessions[cookie] = sess
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// refreshGate returns the per-cookie lock that serialises get-session refreshes.
|
||||
func (c *Client) refreshGate(cookie string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
gate, ok := c.refreshing[cookie]
|
||||
if !ok {
|
||||
gate = &sync.Mutex{}
|
||||
c.refreshing[cookie] = gate
|
||||
}
|
||||
return gate
|
||||
}
|
||||
|
||||
// warmSession calls cross-origin-cookie, whose response refreshes better-auth's
|
||||
// cookie cache and CF_Access_Token. It returns the Set-Cookie headers; a
|
||||
// checkpoint answer (403/429) is an error, since get-session would then be cold.
|
||||
func (c *Client) warmSession(ctx context.Context, client tlsclient.HttpClient, cookie string) ([]string, int, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/cross-origin-cookie", nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = sessionHeader(cookie)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 429 {
|
||||
return nil, resp.StatusCode, fmt.Errorf("cross-origin-cookie http %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Header["Set-Cookie"], resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// fetchSession performs one get-session call and returns its status, body and
|
||||
// Set-Cookie headers.
|
||||
func (c *Client) fetchSession(ctx context.Context, client tlsclient.HttpClient, cookie string) (int, []byte, []string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/get-session", nil)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = sessionHeader(cookie)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, body, resp.Header["Set-Cookie"], nil
|
||||
}
|
||||
|
||||
// sessionAccessToken pulls the bearer out of a get-session body; "" means the
|
||||
// answer carried none (a null session, or a session without a token).
|
||||
func sessionAccessToken(body []byte) string {
|
||||
var raw struct {
|
||||
Session struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(raw.Session.AccessToken)
|
||||
}
|
||||
|
||||
// sessionHeader is the auth endpoints' request shape, copied from a real
|
||||
// browser's call (HAR): a same-origin GET carries NO origin header and DOES
|
||||
// carry the ua client hints + priority — sending origin while omitting the
|
||||
// hints is exactly the shape Vercel's checkpoint 429s.
|
||||
func sessionHeader(cookie string) http.Header {
|
||||
return http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"cache-control": {"no-cache"},
|
||||
"cookie": {cookie},
|
||||
"pragma": {"no-cache"},
|
||||
"priority": {"u=1, i"},
|
||||
"referer": {appBase + "/"},
|
||||
"sec-ch-ua": {secChUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "cache-control", "cookie", "pragma",
|
||||
"priority", "referer", "sec-ch-ua", "sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode",
|
||||
"sec-fetch-site", "user-agent",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// session returns the cookie's access token, optionally forcing a fresh mint
|
||||
// (dropping the cache) — used when the upstream rejected the current bearer.
|
||||
func (c *Client) session(ctx context.Context, cookie string, force bool) (*Session, error) {
|
||||
if force {
|
||||
c.mu.Lock()
|
||||
delete(c.sessions, strings.TrimSpace(cookie))
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return c.GetSession(ctx, cookie)
|
||||
}
|
||||
|
||||
// ProbeSession force-mints a session from the cookie, bypassing the cached
|
||||
// bearer. Callers use it to double-check an auth failure before killing an
|
||||
// account: a rejected bearer (rotation race / expired token) still yields a
|
||||
// working cookie here, only a genuinely dead cookie returns ErrAuth.
|
||||
func (c *Client) ProbeSession(ctx context.Context, cookie string) (*Session, error) {
|
||||
return c.session(ctx, cookie, true)
|
||||
}
|
||||
|
||||
// callGraphQL runs one GraphQL call for an account cookie. The bearer only lives
|
||||
// ~1h, so a rejected token (401/403 or a JWTExpired GraphQL error) is re-minted
|
||||
// from the cookie and the call retried once. Only a cookie that itself stops
|
||||
// authenticating yields ErrAuth — an upstream bearer rejection stays temporary so
|
||||
// the account is never killed for it.
|
||||
func (c *Client) callGraphQL(ctx context.Context, cookie string, payload []byte, useProxy bool, label string) ([]byte, error) {
|
||||
var lastStatus int
|
||||
var lastBody []byte
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
sess, err := c.session(ctx, cookie, attempt > 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := c.graphqlP(ctx, sess.AccessToken, payload, useProxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s: %s", ErrTemporaryUpstream, label, err.Error())
|
||||
}
|
||||
lastStatus, lastBody = status, body
|
||||
stale := status == 401 || status == 403
|
||||
var gqlErr error
|
||||
if !stale && status == 200 {
|
||||
gqlErr = graphqlError(body)
|
||||
stale = errors.Is(gqlErr, ErrAuth)
|
||||
}
|
||||
if stale {
|
||||
if attempt == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if status != 200 {
|
||||
return nil, fmt.Errorf("%w: %s http %d: %s", ErrTemporaryUpstream, label, status, clip(body, 160))
|
||||
}
|
||||
if gqlErr != nil {
|
||||
return nil, gqlErr
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s rejected a freshly minted bearer (http %d): %s",
|
||||
ErrTemporaryUpstream, label, lastStatus, clip(lastBody, 160))
|
||||
}
|
||||
|
||||
const qGetTokens = `query GetUserTokensFromSub($sub: String) {
|
||||
user_details(where: {cognitoId: {_eq: $sub}}) {
|
||||
id
|
||||
plan
|
||||
subscriptionTokens
|
||||
paidTokens
|
||||
rolloverTokens
|
||||
tokenRenewalDate
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// FetchCreditsBalance derives a JWT from the cookie then reads the account's image
|
||||
// token balance. Returns a normalized map mirroring the other providers so the
|
||||
// TokenService quota plumbing is uniform. remaining = subscription+paid+rollover
|
||||
// (the spendable image tokens); available_until carries the daily renewal time so
|
||||
// the maintenance sweep can auto-recover a 限额 account.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) {
|
||||
sess, err := c.GetSession(ctx, cookie)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
return unknownBalance(err.Error()), nil
|
||||
}
|
||||
if sess.CognitoSub == "" {
|
||||
return unknownBalance("no cognitoSub"), nil
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "GetUserTokensFromSub",
|
||||
"variables": map[string]any{"sub": sess.CognitoSub},
|
||||
"query": qGetTokens,
|
||||
})
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "credits")
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
return unknownBalance(err.Error()), nil
|
||||
}
|
||||
var result struct {
|
||||
Data struct {
|
||||
UserDetails []struct {
|
||||
Plan string `json:"plan"`
|
||||
SubscriptionTokens int `json:"subscriptionTokens"`
|
||||
PaidTokens int `json:"paidTokens"`
|
||||
RolloverTokens int `json:"rolloverTokens"`
|
||||
TokenRenewalDate string `json:"tokenRenewalDate"`
|
||||
} `json:"user_details"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
if len(result.Data.UserDetails) == 0 {
|
||||
return unknownBalance("no user_details"), nil
|
||||
}
|
||||
ud := result.Data.UserDetails[0]
|
||||
remaining := ud.SubscriptionTokens + ud.PaidTokens + ud.RolloverTokens
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
"plan": ud.Plan,
|
||||
"available_until": strings.TrimSpace(ud.TokenRenewalDate),
|
||||
"email": emptyStringNil(sess.Email),
|
||||
"display_name": emptyStringNil(sess.Name),
|
||||
"user_id": emptyStringNil(sess.UserID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// graphqlP runs a GraphQL call; callers pick the egress: only the generate submit
|
||||
// uses the proxy; reference-image upload and polling run direct (local IP).
|
||||
func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byte, useProxy bool) ([]byte, int, error) {
|
||||
client, err := c.newTLSClientP(useProxy)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, graphqlURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"content-type": {"application/json"},
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"origin": {appBase},
|
||||
"priority": {"u=1, i"},
|
||||
"referer": {appBase + "/"},
|
||||
"sec-ch-ua": {secChUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"user-agent": {userAgent},
|
||||
"authorization": {"Bearer " + accessToken},
|
||||
"x-leo-schema-version": {schemaVersion},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-site"},
|
||||
http.HeaderOrderKey: {
|
||||
"content-type", "accept", "accept-language", "origin", "priority",
|
||||
"referer", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
|
||||
"user-agent", "authorization", "x-leo-schema-version",
|
||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": true,
|
||||
"error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
||||
|
||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
||||
// reference-image upload, polling and result download.
|
||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
||||
|
||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
||||
// Match the fingerprint proven to work against Leonardo's Cloudflare edge:
|
||||
// Chrome_120, fixed extension order. A randomized JA3 (Chrome_133 +
|
||||
// WithRandomTLSExtensionOrder) gets flagged and 429'd at get-session.
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
// downloadImage fetches a generated image (cdn.leonardo.ai) and returns the bytes.
|
||||
func (c *Client) downloadImage(ctx context.Context, imageURL string) ([]byte, error) {
|
||||
if _, err := url.Parse(imageURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, imageURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
|
||||
"user-agent": {userAgent},
|
||||
"referer": {appBase + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
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 intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
package leonardo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
// defaultStyleID is the "Dynamic" style applied when the caller doesn't specify
|
||||
// one — Leonardo's Generate mutation expects a style_ids entry.
|
||||
const defaultStyleID = "111dc692-d470-4eec-b791-3475abac4c46"
|
||||
|
||||
const mGenerate = `mutation Generate($request: CreateGenerationRequest!) {
|
||||
generate(request: $request) {
|
||||
apiCreditCost
|
||||
generationId
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// qGenerationImages polls one generation's status AND its produced images in a
|
||||
// single round-trip (where: id _in [genId]).
|
||||
const qGenerationImages = `query GenerationImages($where: generations_bool_exp = {}) {
|
||||
generations(where: $where) {
|
||||
id
|
||||
status
|
||||
generated_images {
|
||||
id
|
||||
url
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
const mUploadImage = `mutation UploadImage($uploadImageInput: UploadImageInput!) {
|
||||
uploadImage(arg1: $uploadImageInput) {
|
||||
uploadId
|
||||
url
|
||||
fields
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
const mUploadInitImage = `mutation UploadInitImage($arg1: InitImageUploadInput!) {
|
||||
uploadInitImage(arg1: $arg1) {
|
||||
id
|
||||
url
|
||||
fields
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// initImageExtension narrows a sniffed extension to what uploadInitImage accepts
|
||||
// (png / jpg / jpeg / webp); anything else is sent as png.
|
||||
func initImageExtension(extension string) string {
|
||||
switch strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".") {
|
||||
case "jpg":
|
||||
return "jpg"
|
||||
case "jpeg":
|
||||
return "jpeg"
|
||||
case "webp":
|
||||
return "webp"
|
||||
default:
|
||||
return "png"
|
||||
}
|
||||
}
|
||||
|
||||
// uploadInitImage uploads a reference image and returns the init image id to put
|
||||
// in a Generate request's image_reference guidance. It has to go through
|
||||
// uploadInitImage (permanent init-image bucket): the uploadImage mutation only
|
||||
// hands out temporary-bucket ids, which the generation service can't resolve.
|
||||
func (c *Client) uploadInitImage(ctx context.Context, cookie, extension string, img []byte) (string, error) {
|
||||
extension = initImageExtension(extension)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "UploadInitImage",
|
||||
"query": mUploadInitImage,
|
||||
"variables": map[string]any{"arg1": map[string]any{"extension": extension}},
|
||||
})
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init-image")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var ur struct {
|
||||
Data struct {
|
||||
UploadInitImage struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Fields string `json:"fields"`
|
||||
} `json:"uploadInitImage"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &ur); err != nil {
|
||||
return "", fmt.Errorf("%w: upload-init-image non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
up := ur.Data.UploadInitImage
|
||||
if up.ID == "" || up.URL == "" {
|
||||
return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream)
|
||||
}
|
||||
if err := c.putPresigned(ctx, up.URL, up.Fields, "asset."+extension, img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return up.ID, nil
|
||||
}
|
||||
|
||||
// putPresigned performs the presigned S3 POST: all policy fields first, the file
|
||||
// part LAST.
|
||||
func (c *Client) putPresigned(ctx context.Context, url, fieldsJSON, filename string, asset []byte) error {
|
||||
var fields map[string]string
|
||||
if err := json.Unmarshal([]byte(fieldsJSON), &fields); err != nil {
|
||||
return fmt.Errorf("%w: bad upload fields", ErrTemporaryUpstream)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
_ = w.WriteField(k, v)
|
||||
}
|
||||
fw, err := w.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fw.Write(asset); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"content-type": {w.FormDataContentType()},
|
||||
"user-agent": {userAgent},
|
||||
"origin": {appBase},
|
||||
"referer": {appBase + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: s3 upload: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 204 && resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
return fmt.Errorf("%w: s3 upload http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploadAsset uploads one reference asset (extension png / mp3 / mp4 …) through
|
||||
// the same UploadImage presigned-S3 flow images use, and returns its upload id.
|
||||
func (c *Client) uploadAsset(ctx context.Context, cookie, extension string, asset []byte) (string, error) {
|
||||
extension = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".")
|
||||
if extension == "" {
|
||||
extension = "png"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "UploadImage",
|
||||
"query": mUploadImage,
|
||||
// originalFilename is mandatory for audio uploads and harmless otherwise.
|
||||
"variables": map[string]any{"uploadImageInput": map[string]any{
|
||||
"uploadType": "INIT",
|
||||
"extension": extension,
|
||||
"originalFilename": "asset." + extension,
|
||||
}},
|
||||
})
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var ur struct {
|
||||
Data struct {
|
||||
UploadImage struct {
|
||||
UploadID string `json:"uploadId"`
|
||||
URL string `json:"url"`
|
||||
Fields string `json:"fields"`
|
||||
} `json:"uploadImage"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &ur); err != nil {
|
||||
return "", fmt.Errorf("%w: upload-init non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
up := ur.Data.UploadImage
|
||||
if up.UploadID == "" || up.URL == "" {
|
||||
return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream)
|
||||
}
|
||||
if err := c.putPresigned(ctx, up.URL, up.Fields, "asset."+extension, asset); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return up.UploadID, nil
|
||||
}
|
||||
|
||||
// GenerateImage runs the full Leonardo image pipeline against one account cookie:
|
||||
// mint a JWT, (for image-to-image) upload each reference image, submit the
|
||||
// Generate mutation, poll until COMPLETE, then download the first produced image.
|
||||
// Returns the image bytes, an info map, and a classified error.
|
||||
func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string, width, height int, styleIDs []string, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
sess, err := c.GetSession(ctx, cookie)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(styleIDs) == 0 {
|
||||
styleIDs = []string{defaultStyleID}
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "seedream-4.5"
|
||||
}
|
||||
|
||||
// Image-to-image: upload each reference and collect its guidance entry.
|
||||
var imageRefs []map[string]any
|
||||
for _, img := range refImages {
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadInitImage(ctx, cookie, assetExtension(img, "png"), img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
imageRefs = append(imageRefs, map[string]any{
|
||||
"image": map[string]any{"id": uploadID, "type": "UPLOADED"},
|
||||
"strength": "MID",
|
||||
})
|
||||
}
|
||||
|
||||
promptEnhance := "AUTO"
|
||||
parameters := map[string]any{
|
||||
"height": height,
|
||||
"width": width,
|
||||
"prompt_enhance": promptEnhance,
|
||||
"quantity": 1,
|
||||
"style_ids": styleIDs,
|
||||
"prompt": prompt,
|
||||
}
|
||||
if len(imageRefs) > 0 {
|
||||
// Preserve the reference when image-guided (matches the web app).
|
||||
parameters["prompt_enhance"] = "OFF"
|
||||
parameters["guidances"] = map[string]any{"image_reference": imageRefs}
|
||||
}
|
||||
|
||||
// 1. submit
|
||||
genReq := map[string]any{
|
||||
"operationName": "Generate",
|
||||
"query": mGenerate,
|
||||
"variables": map[string]any{
|
||||
"request": map[string]any{
|
||||
"model": model,
|
||||
"public": true,
|
||||
"parameters": parameters,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, _ := json.Marshal(genReq)
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, true, "generate")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
Generate struct {
|
||||
GenerationID string `json:"generationId"`
|
||||
} `json:"generate"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &genResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: generate non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
genID := strings.TrimSpace(genResp.Data.Generate.GenerationID)
|
||||
if genID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: no generationId: %s", ErrTemporaryUpstream, clip(body, 200))
|
||||
}
|
||||
|
||||
// 2. poll until COMPLETE, then read the image url.
|
||||
imageURL, err := c.pollImage(ctx, cookie, genID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
info := map[string]any{
|
||||
"generation_id": genID,
|
||||
"image_url": imageURL,
|
||||
"user_id": sess.UserID,
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, info, nil
|
||||
}
|
||||
// 3. download bytes
|
||||
data, err := c.downloadImage(ctx, imageURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, info, nil
|
||||
}
|
||||
|
||||
// pollImage polls one generation until it reports COMPLETE (returning the first
|
||||
// image url) or FAILED (error). Honors ctx cancellation / deadline.
|
||||
func (c *Client) pollImage(ctx context.Context, cookie, genID string) (string, error) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "GenerationImages",
|
||||
"query": qGenerationImages,
|
||||
"variables": map[string]any{
|
||||
"where": map[string]any{"id": map[string]any{"_in": []string{genID}}},
|
||||
},
|
||||
})
|
||||
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Poll for the full generation budget (caller's genCtx), leaving headroom for
|
||||
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
|
||||
// ctx already bounds the wait, so a stuck job still can't hang indefinitely.
|
||||
deadline := time.Now().Add(5 * time.Minute)
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
deadline = dl.Add(-60 * time.Second)
|
||||
}
|
||||
|
||||
for {
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "poll")
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return "", err
|
||||
}
|
||||
// 其它错误(含上游临时抖动)不中断轮询,等 deadline 再判超时。
|
||||
if err == nil {
|
||||
var pr struct {
|
||||
Data struct {
|
||||
Generations []struct {
|
||||
Status string `json:"status"`
|
||||
GeneratedImages []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"generated_images"`
|
||||
} `json:"generations"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &pr); err == nil && len(pr.Data.Generations) > 0 {
|
||||
g := pr.Data.Generations[0]
|
||||
switch strings.ToUpper(g.Status) {
|
||||
case "COMPLETE":
|
||||
for _, img := range g.GeneratedImages {
|
||||
if u := strings.TrimSpace(img.URL); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w: complete but no image url", ErrTemporaryUpstream)
|
||||
case "FAILED":
|
||||
return "", fmt.Errorf("%w: generation failed", ErrTemporaryUpstream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// graphqlError inspects a GraphQL response body for an "errors" array and maps the
|
||||
// first message to a classified sentinel (auth / quota / temporary). Returns nil
|
||||
// when there are no errors.
|
||||
func graphqlError(body []byte) error {
|
||||
var env struct {
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
Extensions struct {
|
||||
Code string `json:"code"`
|
||||
Details struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"details"`
|
||||
} `json:"extensions"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil || len(env.Errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
msg := strings.TrimSpace(env.Errors[0].Message)
|
||||
// The generic "An error occurred." hides the real reason in extensions.
|
||||
if detail := strings.TrimSpace(env.Errors[0].Extensions.Details.Message); detail != "" && detail != msg {
|
||||
msg = msg + " (" + detail + ")"
|
||||
}
|
||||
low := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.Contains(low, "unauthor") || strings.Contains(low, "jwt") || strings.Contains(low, "token is") || strings.Contains(low, "forbidden"):
|
||||
return ErrAuth
|
||||
case strings.Contains(low, "token") || strings.Contains(low, "credit") || strings.Contains(low, "quota") || strings.Contains(low, "insufficient") || strings.Contains(low, "not enough"):
|
||||
return ErrQuotaExhausted
|
||||
default:
|
||||
return fmt.Errorf("leonardo: %s", clip([]byte(msg), 200))
|
||||
}
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
package leonardo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// qGenerationVideos polls one video generation's status AND its produced clip in
|
||||
// a single round-trip (motionMP4URL carries the mp4).
|
||||
const qGenerationVideos = `query GenerationVideos($where: generations_bool_exp = {}) {
|
||||
generations(where: $where) {
|
||||
id
|
||||
status
|
||||
generated_images {
|
||||
id
|
||||
motionMP4URL
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// VideoAssets are the decoded reference assets a video request can carry:
|
||||
// image references (strength MID), one audio track and video references. The
|
||||
// caller enforces the per-type caps; durations are derived from the bytes here
|
||||
// because Leonardo requires them for audio/video guidances.
|
||||
type VideoAssets struct {
|
||||
Images [][]byte
|
||||
Audios [][]byte
|
||||
Videos [][]byte
|
||||
}
|
||||
|
||||
// GenerateVideo runs the Leonardo video pipeline (seedance-2.0 / -fast, hailuo) against
|
||||
// one account cookie: upload every reference asset, submit the Generate mutation
|
||||
// as a PRIVATE generation, poll until COMPLETE, then optionally download the mp4.
|
||||
func (c *Client) GenerateVideo(ctx context.Context, cookie, model, prompt string, width, height, durationSeconds int, refs VideoAssets, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
sess, err := c.GetSession(ctx, cookie)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "seedance-2.0-fast"
|
||||
}
|
||||
|
||||
guidances := map[string]any{}
|
||||
var imageRefs []map[string]any
|
||||
for _, img := range refs.Images {
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadInitImage(ctx, cookie, assetExtension(img, "png"), img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
imageRefs = append(imageRefs, map[string]any{
|
||||
"image": map[string]any{"id": uploadID, "type": "UPLOADED"},
|
||||
"strength": "MID",
|
||||
})
|
||||
}
|
||||
if len(imageRefs) > 0 {
|
||||
guidances["image_reference"] = imageRefs
|
||||
}
|
||||
var videoRefs []map[string]any
|
||||
for _, vid := range refs.Videos {
|
||||
if len(vid) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(vid, "mp4"), vid)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
video := map[string]any{"id": uploadID, "type": "UPLOADED"}
|
||||
if secs := MediaDurationSeconds(vid); secs > 0 {
|
||||
video["duration"] = secs
|
||||
}
|
||||
videoRefs = append(videoRefs, map[string]any{"video": video})
|
||||
}
|
||||
if len(videoRefs) > 0 {
|
||||
guidances["video_reference_base"] = videoRefs
|
||||
}
|
||||
// Leonardo rejects an audio reference that isn't paired with an image or
|
||||
// video reference (audio_reference_only_compatible_with_image_or_video_reference).
|
||||
var audioRefs []map[string]any
|
||||
for _, aud := range refs.Audios {
|
||||
if len(aud) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(imageRefs) == 0 && len(videoRefs) == 0 {
|
||||
return nil, nil, errors.New("leonardo: audio reference requires an image or video reference")
|
||||
}
|
||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(aud, "mp3"), aud)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
audio := map[string]any{"id": uploadID, "type": "UPLOADED"}
|
||||
if secs := MediaDurationSeconds(aud); secs > 0 {
|
||||
audio["duration"] = secs
|
||||
}
|
||||
audioRefs = append(audioRefs, map[string]any{"audio": audio})
|
||||
}
|
||||
if len(audioRefs) > 0 {
|
||||
guidances["audio_reference"] = audioRefs
|
||||
}
|
||||
|
||||
parameters := map[string]any{
|
||||
"height": height,
|
||||
"width": width,
|
||||
"duration": durationSeconds,
|
||||
"motion_has_audio": true,
|
||||
"quantity": 1,
|
||||
"prompt": prompt,
|
||||
"guidances": guidances,
|
||||
}
|
||||
// seedance 走随机种子;hailuo 的生成页 seedEnabled=false,请求里不带 seed。
|
||||
if !strings.HasPrefix(model, "hailuo") {
|
||||
parameters["seed"] = -1
|
||||
}
|
||||
genReq := map[string]any{
|
||||
"operationName": "Generate",
|
||||
"query": mGenerate,
|
||||
"variables": map[string]any{
|
||||
"request": map[string]any{
|
||||
"model": model,
|
||||
// 私有生成:不进公开 feed。
|
||||
"public": false,
|
||||
"parameters": parameters,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, _ := json.Marshal(genReq)
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, true, "generate-video")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
Generate struct {
|
||||
GenerationID string `json:"generationId"`
|
||||
} `json:"generate"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &genResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: generate non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
genID := strings.TrimSpace(genResp.Data.Generate.GenerationID)
|
||||
if genID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: no generationId: %s", ErrTemporaryUpstream, clip(body, 200))
|
||||
}
|
||||
|
||||
videoURL, err := c.pollVideo(ctx, cookie, genID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info := map[string]any{
|
||||
"generation_id": genID,
|
||||
"video_url": videoURL,
|
||||
"user_id": sess.UserID,
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, info, nil
|
||||
}
|
||||
data, err := c.downloadImage(ctx, videoURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, info, nil
|
||||
}
|
||||
|
||||
// pollVideo polls one generation until COMPLETE (returning the mp4 url) or
|
||||
// FAILED. Temporary upstream hiccups don't abort the wait — only ctx/deadline do.
|
||||
func (c *Client) pollVideo(ctx context.Context, cookie, genID string) (string, error) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "GenerationVideos",
|
||||
"query": qGenerationVideos,
|
||||
"variables": map[string]any{
|
||||
"where": map[string]any{"id": map[string]any{"_in": []string{genID}}},
|
||||
},
|
||||
})
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
deadline := time.Now().Add(10 * time.Minute)
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
deadline = dl.Add(-60 * time.Second)
|
||||
}
|
||||
|
||||
for {
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "poll-video")
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return "", err
|
||||
}
|
||||
if err == nil {
|
||||
var pr struct {
|
||||
Data struct {
|
||||
Generations []struct {
|
||||
Status string `json:"status"`
|
||||
GeneratedImages []struct {
|
||||
MotionMP4URL string `json:"motionMP4URL"`
|
||||
} `json:"generated_images"`
|
||||
} `json:"generations"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &pr); err == nil && len(pr.Data.Generations) > 0 {
|
||||
g := pr.Data.Generations[0]
|
||||
switch strings.ToUpper(g.Status) {
|
||||
case "COMPLETE":
|
||||
for _, img := range g.GeneratedImages {
|
||||
if u := strings.TrimSpace(img.MotionMP4URL); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w: complete but no video url", ErrTemporaryUpstream)
|
||||
case "FAILED":
|
||||
return "", fmt.Errorf("%w: generation failed", ErrTemporaryUpstream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assetExtension sniffs the upload extension Leonardo expects for a reference
|
||||
// asset; fallback is used when the bytes aren't recognized.
|
||||
func assetExtension(data []byte, fallback string) string {
|
||||
n := len(data)
|
||||
switch {
|
||||
case n >= 8 && string(data[1:4]) == "PNG":
|
||||
return "png"
|
||||
case n >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
|
||||
return "jpg"
|
||||
case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
|
||||
return "webp"
|
||||
case n >= 12 && string(data[4:8]) == "ftyp":
|
||||
if n >= 12 && strings.HasPrefix(string(data[8:12]), "qt") {
|
||||
return "mov"
|
||||
}
|
||||
if n >= 12 && strings.HasPrefix(string(data[8:12]), "M4A") {
|
||||
return "m4a"
|
||||
}
|
||||
return "mp4"
|
||||
case n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3:
|
||||
return "webm"
|
||||
case n >= 3 && string(data[0:3]) == "ID3":
|
||||
return "mp3"
|
||||
case n >= 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0:
|
||||
return "mp3"
|
||||
case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WAVE":
|
||||
return "wav"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// MediaDurationSeconds returns a media asset's duration in seconds (0 when it
|
||||
// can't be determined). Leonardo's audio_reference / video_reference_base
|
||||
// guidances carry the duration, and the caller validates video lengths with it,
|
||||
// so it's derived from the uploaded bytes: mp4/mov via the mvhd box, wav via the
|
||||
// fmt byte rate, mp3 from the first frame's bitrate.
|
||||
func MediaDurationSeconds(data []byte) float64 {
|
||||
if secs := mp4Duration(data); secs > 0 {
|
||||
return secs
|
||||
}
|
||||
if secs := wavDuration(data); secs > 0 {
|
||||
return secs
|
||||
}
|
||||
return mp3Duration(data)
|
||||
}
|
||||
|
||||
// mp4Duration walks the ISOBMFF box tree to moov/mvhd and reads timescale+duration.
|
||||
func mp4Duration(data []byte) float64 {
|
||||
if len(data) < 12 || string(data[4:8]) != "ftyp" {
|
||||
return 0
|
||||
}
|
||||
moov := findBox(data, "moov")
|
||||
if moov == nil {
|
||||
return 0
|
||||
}
|
||||
mvhd := findBox(moov, "mvhd")
|
||||
if len(mvhd) < 20 {
|
||||
return 0
|
||||
}
|
||||
version := mvhd[0]
|
||||
if version == 1 {
|
||||
if len(mvhd) < 32 {
|
||||
return 0
|
||||
}
|
||||
timescale := binary.BigEndian.Uint32(mvhd[20:24])
|
||||
duration := binary.BigEndian.Uint64(mvhd[24:32])
|
||||
if timescale == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(duration) / float64(timescale)
|
||||
}
|
||||
timescale := binary.BigEndian.Uint32(mvhd[12:16])
|
||||
duration := binary.BigEndian.Uint32(mvhd[16:20])
|
||||
if timescale == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(duration) / float64(timescale)
|
||||
}
|
||||
|
||||
// findBox returns the payload of the first box named typ among data's boxes
|
||||
// (callers descend one level at a time by passing a parent's payload back in).
|
||||
func findBox(data []byte, typ string) []byte {
|
||||
for off := 0; off+8 <= len(data); {
|
||||
size := int(binary.BigEndian.Uint32(data[off : off+4]))
|
||||
name := string(data[off+4 : off+8])
|
||||
header := 8
|
||||
if size == 1 { // 64-bit size
|
||||
if off+16 > len(data) {
|
||||
return nil
|
||||
}
|
||||
size = int(binary.BigEndian.Uint64(data[off+8 : off+16]))
|
||||
header = 16
|
||||
} else if size == 0 {
|
||||
size = len(data) - off
|
||||
}
|
||||
if size < header || off+size > len(data) {
|
||||
return nil
|
||||
}
|
||||
if name == typ {
|
||||
return data[off+header : off+size]
|
||||
}
|
||||
off += size
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wavDuration reads the RIFF fmt chunk's byte rate and the data chunk size.
|
||||
func wavDuration(data []byte) float64 {
|
||||
if len(data) < 44 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
|
||||
return 0
|
||||
}
|
||||
byteRate := 0
|
||||
dataSize := 0
|
||||
for off := 12; off+8 <= len(data); {
|
||||
name := string(data[off : off+4])
|
||||
size := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
|
||||
body := off + 8
|
||||
if size < 0 || body > len(data) {
|
||||
break
|
||||
}
|
||||
switch name {
|
||||
case "fmt ":
|
||||
if body+16 <= len(data) {
|
||||
byteRate = int(binary.LittleEndian.Uint32(data[body+8 : body+12]))
|
||||
}
|
||||
case "data":
|
||||
dataSize = size
|
||||
if body+size > len(data) {
|
||||
dataSize = len(data) - body
|
||||
}
|
||||
}
|
||||
off = body + size + size%2
|
||||
}
|
||||
if byteRate <= 0 || dataSize <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(dataSize) / float64(byteRate)
|
||||
}
|
||||
|
||||
// mp3Bitrates are the Layer III bitrate tables (kbps) indexed by the frame
|
||||
// header's bitrate index: 1 = MPEG1, 2 = MPEG2/2.5.
|
||||
var mp3Bitrates = map[int][]int{
|
||||
1: {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0},
|
||||
2: {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0},
|
||||
}
|
||||
|
||||
// mp3Duration estimates the duration from the first frame header's bitrate
|
||||
// (constant-bitrate assumption — good enough for the guidance duration field).
|
||||
func mp3Duration(data []byte) float64 {
|
||||
off := 0
|
||||
if len(data) >= 10 && string(data[0:3]) == "ID3" {
|
||||
// syncsafe int: 7 bits per byte
|
||||
tagSize := int(data[6]&0x7F)<<21 | int(data[7]&0x7F)<<14 | int(data[8]&0x7F)<<7 | int(data[9]&0x7F)
|
||||
off = 10 + tagSize
|
||||
}
|
||||
for ; off+4 <= len(data); off++ {
|
||||
if data[off] != 0xFF || data[off+1]&0xE0 != 0xE0 {
|
||||
continue
|
||||
}
|
||||
versionBits := (data[off+1] >> 3) & 0x03
|
||||
layerBits := (data[off+1] >> 1) & 0x03
|
||||
if layerBits != 0x01 { // Layer III only
|
||||
continue
|
||||
}
|
||||
table := 1
|
||||
if versionBits != 0x03 { // MPEG2 / 2.5
|
||||
table = 2
|
||||
}
|
||||
idx := int((data[off+2] >> 4) & 0x0F)
|
||||
rates := mp3Bitrates[table]
|
||||
if idx <= 0 || idx >= len(rates) || rates[idx] == 0 {
|
||||
continue
|
||||
}
|
||||
kbps := rates[idx]
|
||||
return float64(len(data)-off) * 8 / float64(kbps*1000)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
// Package runway implements the Runway (runwayml.com) provider client. For now
|
||||
// it only covers account management — JWT detection, workspace/team id
|
||||
// extraction and credit-balance probing — mirroring the curl_cffi reference in
|
||||
// query_credits.py with tls-client so the JA3/JA4 fingerprint matches Chrome.
|
||||
package runway
|
||||
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"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://api.runwayml.com"
|
||||
origin = "https://app.runwayml.com"
|
||||
|
||||
// userAgent / secChUA mirror the real browser that produced a successful
|
||||
// registration + generation in the reference HAR (Edge 150 on Windows).
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0"
|
||||
secChUA = `"Not;A=Brand";v="8", "Chromium";v="150", "Microsoft Edge";v="150"`
|
||||
|
||||
// sourceApp / buildHash are the x-runway-source-application[-version] the real
|
||||
// web app stamps on EVERY authed API call (319/336 requests in the reference
|
||||
// HAR). buildHash is the web bundle's git sha; it also appears as the
|
||||
// `sentry-release` segment of the baggage header. Runway anti-abuse keys on
|
||||
// these being present + client-id (see clientIDFromToken): a write/upload that
|
||||
// lacks them is judged a non-web client and the account's free credits are
|
||||
// zeroed on the FIRST upload ("上传清零积分"). buildHash tracks Runway's web
|
||||
// releases — refresh it from a current HAR if uploads start getting flagged.
|
||||
sourceApp = "web"
|
||||
buildHash = "3e96b2f0f85b8c0cafb7c0dcd7a6878305aaa0f0"
|
||||
)
|
||||
|
||||
// clientIDOverride, when non-empty, forces the x-runway-client-id (used to pin an
|
||||
// account to the exact persistent device id its real browser session used).
|
||||
var clientIDOverride string
|
||||
|
||||
// clientIDUUIDNamespace is a fixed namespace so clientIDFromToken is stable
|
||||
// across processes/restarts — the same account always derives the same
|
||||
// x-runway-client-id, exactly like the browser's localStorage-persisted id.
|
||||
var clientIDUUIDNamespace = uuid.MustParse("6ba7b811-9dad-11d1-80b4-00c04fd430c8")
|
||||
|
||||
// clientIDFromToken derives the persistent per-account x-runway-client-id. The
|
||||
// real SPA generates this UUID once and stores it in localStorage; every request
|
||||
// from that browser reuses the SAME value (5b89c786-… appears on all 319 authed
|
||||
// calls in the HAR). We reproduce that stability by deterministically deriving a
|
||||
// v5 UUID from the account's JWT "id" claim, so one account == one client-id for
|
||||
// its whole lifetime without needing a DB column. Falls back to the raw token if
|
||||
// the id claim is missing.
|
||||
func clientIDFromToken(token string) string {
|
||||
if clientIDOverride != "" {
|
||||
return clientIDOverride
|
||||
}
|
||||
seed := TeamIDFromToken(token)
|
||||
if seed == "" {
|
||||
seed = strings.TrimPrefix(token, "Bearer ")
|
||||
}
|
||||
return uuid.NewSHA1(clientIDUUIDNamespace, []byte("runway-client-id:"+seed)).String()
|
||||
}
|
||||
|
||||
// randHex returns n random bytes hex-encoded (2n chars), for sentry trace ids.
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := crand.Read(b); err != nil {
|
||||
return strings.Repeat("0", n*2)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// browserHeaders builds the full Chrome/Edge header set the Runway web app sends
|
||||
// on its JSON API calls. tls-client only spoofs the TLS/JA3 fingerprint — it does
|
||||
// NOT inject a User-Agent or the client-hint / fetch-metadata headers. Without
|
||||
// them a request looks like a headless bot and Runway's anti-abuse revokes the
|
||||
// account's free credits within minutes ("秒死"). teamID is optional.
|
||||
func browserHeaders(token, teamID string) http.Header {
|
||||
traceID := randHex(16)
|
||||
spanID := randHex(8)
|
||||
h := http.Header{
|
||||
"accept": {"application/json"},
|
||||
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"},
|
||||
"authorization": {"Bearer " + strings.TrimPrefix(token, "Bearer ")},
|
||||
"baggage": {"sentry-environment=production,sentry-release=" + buildHash + ",sentry-public_key=8ea832c064ed4bbcb4b8952c02ba119a,sentry-trace_id=" + traceID},
|
||||
"content-type": {"application/json"},
|
||||
"origin": {origin},
|
||||
"priority": {"u=1, i"},
|
||||
"referer": {origin + "/"},
|
||||
"sec-ch-ua": {secChUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-site"},
|
||||
"sentry-trace": {traceID + "-" + spanID},
|
||||
"user-agent": {userAgent},
|
||||
// x-runway-* identify this as the real web app to Runway's anti-abuse.
|
||||
// client-id must be persistent per account (see clientIDFromToken);
|
||||
// omitting these is what zeroes an account's credits on first upload.
|
||||
"x-runway-client-id": {clientIDFromToken(token)},
|
||||
"x-runway-source-application": {sourceApp},
|
||||
"x-runway-source-application-version": {buildHash},
|
||||
"x-runway-workspace": {teamID},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "authorization", "baggage",
|
||||
"content-type", "origin", "priority", "referer",
|
||||
"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
|
||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
"sentry-trace", "user-agent",
|
||||
"x-runway-client-id", "x-runway-source-application",
|
||||
"x-runway-source-application-version", "x-runway-workspace",
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(teamID) == "" {
|
||||
h.Del("x-runway-workspace")
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// browserAssetHeaders is the lighter header set the browser sends on cross-site
|
||||
// asset transfers (presigned S3 upload / CloudFront artifact download): a
|
||||
// User-Agent and client hints, but no Runway authorization.
|
||||
func browserAssetHeaders() http.Header {
|
||||
return http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"sec-ch-ua": {secChUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"cross-site"},
|
||||
"user-agent": {userAgent},
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("runway auth failed")
|
||||
ErrQuotaExhausted = errors.New("runway quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("runway 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)
|
||||
}
|
||||
|
||||
// IsRunwayToken reports whether a JWT looks like a Runway access token: a
|
||||
// top-level numeric "id" plus an "sso" claim, and crucially NO OpenAI
|
||||
// (https://api.openai.com/*) claims — that's what disambiguates it from a
|
||||
// ChatGPT token, which is otherwise also an opaque three-part JWT.
|
||||
func IsRunwayToken(token string) bool {
|
||||
claims := decodeJWTPayload(token)
|
||||
if len(claims) == 0 {
|
||||
return false
|
||||
}
|
||||
for k := range claims {
|
||||
if strings.HasPrefix(k, "https://api.openai.com/") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
_, hasSSO := claims["sso"]
|
||||
return hasSSO && claims["id"] != nil
|
||||
}
|
||||
|
||||
// TeamIDFromToken returns the Runway workspace/team id, which equals the JWT
|
||||
// "id" claim (query_credits.py / gen_video.py both derive teamId this way).
|
||||
func TeamIDFromToken(token string) string {
|
||||
claims := decodeJWTPayload(token)
|
||||
switch v := claims["id"].(type) {
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
case json.Number:
|
||||
return v.String()
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractAccountInfo decodes the free (no-network) JWT claims for the accounts
|
||||
// view: email, team id and expiry.
|
||||
func ExtractAccountInfo(token string) map[string]any {
|
||||
claims := decodeJWTPayload(token)
|
||||
return map[string]any{
|
||||
"email": emptyStringNil(strings.TrimSpace(stringValue(claims["email"]))),
|
||||
"team_id": emptyStringNil(TeamIDFromToken(token)),
|
||||
"expires_at": claims["exp"],
|
||||
}
|
||||
}
|
||||
|
||||
// FetchCreditsBalance probes the account's plan credits via /v1/profile/features
|
||||
// (query_credits.py). Returns a normalized map mirroring the Adobe client so the
|
||||
// TokenService quota plumbing can treat all providers uniformly. A 401/403 maps
|
||||
// to ErrAuth (token dead); any other failure is reported as unknown without
|
||||
// killing the account.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return unknownBalance("empty token"), nil
|
||||
}
|
||||
teamID := TeamIDFromToken(token)
|
||||
if teamID == "" {
|
||||
return unknownBalance("no team id"), nil
|
||||
}
|
||||
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := apiBase + "/v1/profile/features?asTeamId=" + teamID
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = browserHeaders(token, teamID)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return unknownBalance(fmt.Sprintf("http %d: %s", resp.StatusCode, clip(body, 160))), nil
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
features, _ := payload["features"].(map[string]any)
|
||||
permitted, _ := features["permitted"].(map[string]any)
|
||||
used, _ := features["used"].(map[string]any)
|
||||
total := intValue(permitted["numPlanCredits"])
|
||||
spent := intValue(used["numPlanCredits"])
|
||||
remaining := total - spent
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": spent,
|
||||
"total": total,
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": true,
|
||||
"error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
||||
|
||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
||||
// reference-image upload, polling and result download.
|
||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
||||
|
||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
// Whole-request timeout, incl. reading the response body. 30s was too
|
||||
// tight for downloading a rendered image/video artifact off a slow CDN,
|
||||
// surfacing as "request canceled (Client.Timeout ... while reading body)".
|
||||
// The caller's genCtx still bounds the overall generation.
|
||||
tlsclient.WithTimeoutSeconds(300),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_133),
|
||||
tlsclient.WithRandomTLSExtensionOrder(),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
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 intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package runway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// GenerateImage runs a Runway gemini 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. modelID selects the variant: "runway-nano-banana-2"
|
||||
// → gemini_3_1_flash_image / gemini-3.1-flash-image-preview (with aspect_ratio),
|
||||
// anything else → workflow_gemini_image / gemini-3-pro-image-preview. imageSize
|
||||
// is the "1K"/"2K"/"4K" tier. teamID is the workspace id; if empty it's derived
|
||||
// from the token. refs may be empty (pure text-to-image).
|
||||
func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prompt, aspectRatio, imageSize string, refs [][]byte, downloadResult bool) ([]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(imageSize) == "" {
|
||||
imageSize = "1K"
|
||||
}
|
||||
|
||||
// Only the task-create (generate submit) egresses via the proxy; reference
|
||||
// upload, polling and download run on the local IP.
|
||||
submitClient, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
directClient, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var refImages []map[string]any
|
||||
var refAssetIDs []string
|
||||
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, directClient, 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,
|
||||
})
|
||||
refAssetIDs = append(refAssetIDs, assetID)
|
||||
}
|
||||
|
||||
// Browser order: create the real session FIRST, attach the references and its
|
||||
// own asset group, THEN submit the task into it (see createSession). The task
|
||||
// carries this session's assetGroupId, NOT the account-wide "Generations"
|
||||
// group.
|
||||
sessionID, err := c.createSession(ctx, submitClient, token, teamID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, aid := range refAssetIDs {
|
||||
c.attachReference(ctx, submitClient, token, teamID, sessionID, aid)
|
||||
}
|
||||
assetGroupID, _ := c.sessionAssetGroup(ctx, submitClient, token, teamID, sessionID) // best-effort
|
||||
|
||||
taskID, err := c.createImageTask(ctx, submitClient, token, teamID, modelID, prompt, aspectRatio, imageSize, sessionID, assetGroupID, refImages)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
artifactURL, err := c.pollTask(ctx, directClient, token, teamID, taskID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meta := map[string]any{
|
||||
"provider": "runway",
|
||||
"task_id": taskID,
|
||||
"team_id": teamID,
|
||||
"image_url": artifactURL,
|
||||
}
|
||||
// downloadResult=false (API-key url-only mode): return the upstream artifact
|
||||
// URL without downloading the PNG.
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, directClient, artifactURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
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 submits a gemini image task (workflow_gemini_image for Pro,
|
||||
// gemini_3_1_flash_image for Nano Banana 2) INTO the pre-created session and
|
||||
// returns its id. sessionID must be a real server-side session (see
|
||||
// createSession) and assetGroupID must be that session's own group.
|
||||
func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize, sessionID, assetGroupID string, refImages []map[string]any) (string, error) {
|
||||
if strings.TrimSpace(aspectRatio) == "" {
|
||||
aspectRatio = "16:9"
|
||||
}
|
||||
taskType := "workflow_gemini_image"
|
||||
opts := map[string]any{
|
||||
"name": "Nano Banana Pro - " + prompt,
|
||||
"text_prompt": prompt,
|
||||
"aspect_ratio": aspectRatio,
|
||||
"num_images": 1,
|
||||
"image_size": imageSize,
|
||||
"model": "gemini-3-pro-image-preview",
|
||||
"exploreMode": false,
|
||||
"creationSource": "tool-mode",
|
||||
}
|
||||
if modelID == "runway-nano-banana-2" {
|
||||
taskType = "gemini_3_1_flash_image"
|
||||
opts["name"] = "Nano Banana 2 - " + prompt
|
||||
opts["model"] = "gemini-3.1-flash-image-preview"
|
||||
}
|
||||
if assetGroupID != "" {
|
||||
opts["assetGroupId"] = assetGroupID
|
||||
}
|
||||
if len(refImages) > 0 {
|
||||
opts["reference_images"] = refImages
|
||||
}
|
||||
// Pre-flight cost estimate, exactly as the web app does before every spend.
|
||||
c.estimateCost(ctx, client, token, teamID, "gemini_image", opts)
|
||||
|
||||
res, err := c.submitTask(ctx, client, token, teamID, map[string]any{
|
||||
"taskType": taskType,
|
||||
"options": opts,
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
"sessionId": sessionID,
|
||||
})
|
||||
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)
|
||||
}
|
||||
// Post-submit: generation record + session play (session already exists).
|
||||
c.recordGeneration(ctx, client, token, teamID, id, sessionID, prompt, opts)
|
||||
return id, nil
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
package runway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
mrand "math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// ratioDimensions maps an aspect ratio to the Gen-4 Turbo native output size.
|
||||
// These are the only dimensions gen4_turbo accepts; "2K" is a UI label over this
|
||||
// native tier (see runway-video-gen-spec). Unknown ratios fall back to 16:9.
|
||||
func ratioDimensions(aspectRatio string) (int, int) {
|
||||
switch strings.TrimSpace(strings.ReplaceAll(aspectRatio, "x", ":")) {
|
||||
case "16:9":
|
||||
return 1280, 720
|
||||
case "9:16":
|
||||
return 720, 1280
|
||||
case "1:1":
|
||||
return 960, 960
|
||||
case "4:3":
|
||||
return 1104, 832
|
||||
case "3:4":
|
||||
return 832, 1104
|
||||
case "21:9":
|
||||
return 1584, 672
|
||||
default:
|
||||
return 1280, 720
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateVideo runs the full i2v pipeline (gen_video.py): upload the first-frame
|
||||
// image (preview + dataset), create a dataset, create a gen4_turbo task and poll
|
||||
// it to completion, then download the rendered MP4. teamID is the workspace id
|
||||
// (meta["team_id"]); if empty it's derived from the token. seconds must be 5 or
|
||||
// 10; aspectRatio picks the native output size.
|
||||
// GenerateVideo renders the clip and (when downloadResult) downloads the MP4.
|
||||
// With downloadResult=false it returns nil bytes and the upstream artifact URL in
|
||||
// meta["video_url"] — used by the async /v1/videos job, which proxies that URL on
|
||||
// /content instead of persisting the file.
|
||||
func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspectRatio string, seconds int, frame []byte, downloadResult bool) ([]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 len(frame) == 0 {
|
||||
return nil, nil, errors.New("runway: first-frame image required")
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(frame))
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("runway: failed to decode first-frame image")
|
||||
}
|
||||
|
||||
// Only the task-create (generate submit) egresses via the proxy; reference
|
||||
// upload, dataset create, polling and download run on the local IP (matches
|
||||
// the image pipeline).
|
||||
submitClient, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
directClient, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
filename := "frame_" + time.Now().UTC().Format("20060102_150405") + ".png"
|
||||
previewUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET_PREVIEW", frame)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
datasetUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET", frame)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
assetID, imageURL, err := c.createDataset(ctx, directClient, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Browser order: real session first, attach the first-frame asset + its own
|
||||
// asset group, THEN submit the task into it (see createSession).
|
||||
sessionID, err := c.createSession(ctx, submitClient, token, teamID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
c.attachReference(ctx, submitClient, token, teamID, sessionID, assetID)
|
||||
assetGroupID, _ := c.sessionAssetGroup(ctx, submitClient, token, teamID, sessionID) // best-effort
|
||||
|
||||
taskID, err := c.createTask(ctx, submitClient, token, teamID, prompt, imageURL, assetID, assetGroupID, sessionID, aspectRatio, seconds)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
artifactURL, err := c.pollTask(ctx, directClient, token, teamID, taskID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meta := map[string]any{
|
||||
"provider": "runway",
|
||||
"task_id": taskID,
|
||||
"team_id": teamID,
|
||||
"video_url": artifactURL,
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, directClient, artifactURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// uploadFile mirrors gen_video.upload_file: register the upload, PUT the bytes to
|
||||
// the returned S3 URL, then complete. Returns the upload id and final url.
|
||||
func (c *Client) uploadFile(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, uploadType string, data []byte) (string, string, error) {
|
||||
info, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads", map[string]any{
|
||||
"filename": filename,
|
||||
"numberOfParts": 1,
|
||||
"type": uploadType,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
uploadID := strings.TrimSpace(stringValue(info["id"]))
|
||||
urls, _ := info["uploadUrls"].([]any)
|
||||
if uploadID == "" || len(urls) == 0 {
|
||||
return "", "", fmt.Errorf("%w: upload register missing fields", ErrTemporaryUpstream)
|
||||
}
|
||||
putURL := strings.TrimSpace(stringValue(urls[0]))
|
||||
contentType := "application/octet-stream"
|
||||
if hdrs, ok := info["uploadHeaders"].(map[string]any); ok {
|
||||
if ct := strings.TrimSpace(stringValue(hdrs["Content-Type"])); ct != "" {
|
||||
contentType = ct
|
||||
}
|
||||
}
|
||||
|
||||
etag, err := c.putBytes(ctx, client, putURL, contentType, data)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads/"+uploadID+"/complete", map[string]any{
|
||||
"parts": []map[string]any{{"PartNumber": 1, "ETag": etag}},
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return uploadID, strings.TrimSpace(stringValue(res["url"])), nil
|
||||
}
|
||||
|
||||
func (c *Client) createDataset(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, datasetUploadID, previewUploadID string, w, h int) (string, string, error) {
|
||||
teamIDNum := jsonNumberOrString(teamID)
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/datasets", map[string]any{
|
||||
"fileCount": 1,
|
||||
"name": filename,
|
||||
"uploadId": datasetUploadID,
|
||||
"previewUploadIds": []string{previewUploadID},
|
||||
"metadata": map[string]any{"size": map[string]any{"width": w, "height": h}},
|
||||
"type": map[string]any{"name": "image", "type": "image", "isDirectory": false},
|
||||
"asTeamId": teamIDNum,
|
||||
"privateInTeam": true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ds, _ := res["dataset"].(map[string]any)
|
||||
id := strings.TrimSpace(stringValue(ds["id"]))
|
||||
url := strings.TrimSpace(stringValue(ds["url"]))
|
||||
if id == "" || url == "" {
|
||||
return "", "", fmt.Errorf("%w: dataset missing fields", ErrTemporaryUpstream)
|
||||
}
|
||||
return id, url, nil
|
||||
}
|
||||
|
||||
func (c *Client) assetGroupID(ctx context.Context, client tlsclient.HttpClient, token, teamID string) (string, error) {
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet,
|
||||
"/v1/asset_groups/by_name?name=Generations&asTeamId="+teamID+"&privateInTeam=true", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ag, _ := res["assetGroup"].(map[string]any)
|
||||
return strings.TrimSpace(stringValue(ag["id"])), nil
|
||||
}
|
||||
|
||||
func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, imageURL, assetID, assetGroupID, sessionID, aspectRatio string, seconds int) (string, error) {
|
||||
w, h := ratioDimensions(aspectRatio)
|
||||
opts := map[string]any{
|
||||
"route": "i2v",
|
||||
"name": "Gen-4 Turbo - " + prompt,
|
||||
"text_prompt": prompt,
|
||||
"seconds": seconds,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"init_image": imageURL,
|
||||
"imageAssetId": assetID,
|
||||
"exploreMode": false,
|
||||
"creationSource": "tool-mode",
|
||||
"seed": mrand.IntN(999999999) + 1,
|
||||
"watermark": true,
|
||||
}
|
||||
if assetGroupID != "" {
|
||||
opts["assetGroupId"] = assetGroupID
|
||||
}
|
||||
res, err := c.submitTask(ctx, client, token, teamID, map[string]any{
|
||||
"taskType": "gen4_turbo",
|
||||
"options": opts,
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
"sessionId": sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
task, _ := res["task"].(map[string]any)
|
||||
id := strings.TrimSpace(stringValue(task["id"]))
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: task missing id", ErrTemporaryUpstream)
|
||||
}
|
||||
// Post-submit: generation record + session play (session already exists).
|
||||
c.recordGeneration(ctx, client, token, teamID, id, sessionID, prompt, opts)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (c *Client) pollTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, taskID string) (string, error) {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet, "/v1/tasks/"+taskID+"?asTeamId="+teamID, nil)
|
||||
if err != nil {
|
||||
// A transient blip shouldn't kill a render that may still succeed.
|
||||
if errors.Is(err, ErrTemporaryUpstream) {
|
||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
task, _ := res["task"].(map[string]any)
|
||||
status := strings.ToUpper(strings.TrimSpace(stringValue(task["status"])))
|
||||
switch status {
|
||||
case "SUCCEEDED":
|
||||
arts, _ := task["artifacts"].([]any)
|
||||
for _, raw := range arts {
|
||||
art, _ := raw.(map[string]any)
|
||||
if url := strings.TrimSpace(stringValue(art["url"])); url != "" {
|
||||
return url, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("runway: task succeeded with no artifact url")
|
||||
case "FAILED", "CANCELED":
|
||||
reason := strings.TrimSpace(stringValue(task["error"]))
|
||||
if isCreditError(reason) {
|
||||
return "", fmt.Errorf("%w: %s", ErrQuotaExhausted, reason)
|
||||
}
|
||||
return "", fmt.Errorf("runway: task %s: %s", status, reason)
|
||||
}
|
||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// estimateCost fires the /v1/billing/estimate_feature_cost_credits pre-flight the
|
||||
// web app ALWAYS does before a spend (52 times in the reference HAR, always with
|
||||
// the exact taskOptions right before /v1/tasks). It returns no reservation token,
|
||||
// so its purpose is purely to mark the spend as coming from the real UI flow;
|
||||
// submitting a task with no preceding estimate for that spec is a scripted-abuse
|
||||
// tell. Best-effort — the estimate itself costs nothing.
|
||||
func (c *Client) estimateCost(ctx context.Context, client tlsclient.HttpClient, token, teamID, feature string, taskOptions map[string]any) {
|
||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/billing/estimate_feature_cost_credits", map[string]any{
|
||||
"feature": feature,
|
||||
"count": 1,
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
"taskOptions": taskOptions,
|
||||
})
|
||||
}
|
||||
|
||||
// createSession creates an EMPTY tool-mode session (POST /v1/sessions with an
|
||||
// empty taskIds array) and returns its real server-side id. This is the crux of
|
||||
// the whole flow: the browser creates the session BEFORE submitting the first
|
||||
// task, then submits the task INTO that real session. Submitting /v1/tasks with a
|
||||
// sessionId that was never created server-side is a forgery tell that makes
|
||||
// Runway revoke the account's free credits (permitted numPlanCredits 500 -> 0)
|
||||
// the instant the task lands ("提交生图就清零"). Note taskIds must be [] — passing
|
||||
// a bogus id yields "Invalid task IDs".
|
||||
func (c *Client) createSession(ctx context.Context, client tlsclient.HttpClient, token, teamID string) (string, error) {
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/sessions", map[string]any{
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
"taskIds": []string{},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sess, _ := res["session"].(map[string]any)
|
||||
id := strings.TrimSpace(stringValue(sess["id"]))
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: session missing id", ErrTemporaryUpstream)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// attachReference attaches an uploaded asset to the session (the browser does
|
||||
// this before the task, so the task's reference_images belong to a real session).
|
||||
func (c *Client) attachReference(ctx context.Context, client tlsclient.HttpClient, token, teamID, sessionID, assetID string) {
|
||||
if strings.TrimSpace(assetID) == "" {
|
||||
return
|
||||
}
|
||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost,
|
||||
"/v1/sessions/"+sessionID+"/references", map[string]any{
|
||||
"assetId": assetID,
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
})
|
||||
}
|
||||
|
||||
// sessionAssetGroup creates the session's OWN asset group (POST
|
||||
// /v1/sessions/{id}/assetGroup) and returns its id — this, NOT the account-wide
|
||||
// "Generations" group, is the assetGroupId the browser puts on the task.
|
||||
func (c *Client) sessionAssetGroup(ctx context.Context, client tlsclient.HttpClient, token, teamID, sessionID string) (string, error) {
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost,
|
||||
"/v1/sessions/"+sessionID+"/assetGroup", map[string]any{"asTeamId": jsonNumberOrString(teamID)})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ag, _ := res["assetGroup"].(map[string]any)
|
||||
return strings.TrimSpace(stringValue(ag["id"])), nil
|
||||
}
|
||||
|
||||
// recordGeneration is the post-submit lifecycle the browser fires AFTER the task
|
||||
// lands: the generation record (POST /v1/generations, recordingEnabled=true) and
|
||||
// a session /play. The session itself already exists (createSession ran before
|
||||
// the task), so this no longer creates it. Best-effort.
|
||||
func (c *Client) recordGeneration(ctx context.Context, client tlsclient.HttpClient, token, teamID, taskID, sessionID, prompt string, taskOptions map[string]any) {
|
||||
settings := map[string]any{"taskId": taskID, "recordingEnabled": true}
|
||||
for k, v := range taskOptions {
|
||||
if k == "exploreMode" { // not present on the browser's generation record
|
||||
continue
|
||||
}
|
||||
settings[k] = v
|
||||
}
|
||||
// The generation record carries the model id WITHOUT the "-preview" suffix
|
||||
// the task uses (gemini-3-pro-image-preview -> gemini-3-pro-image in the HAR).
|
||||
if m, ok := settings["model"].(string); ok {
|
||||
settings["model"] = strings.TrimSuffix(m, "-preview")
|
||||
}
|
||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/generations", map[string]any{
|
||||
"toolId": "generate",
|
||||
"prompt": prompt,
|
||||
"outputs": map[string]any{"outputUrls": []any{}},
|
||||
"settings": settings,
|
||||
})
|
||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/sessions/"+sessionID+"/play",
|
||||
map[string]any{"asTeamId": jsonNumberOrString(teamID), "taskId": taskID})
|
||||
}
|
||||
|
||||
// submitTask POSTs a /v1/tasks create. Transient (network / 5xx) failures
|
||||
// surface as ErrTemporaryUpstream so the pool fails over to the NEXT account
|
||||
// (换号重试) instead of retrying this one.
|
||||
func (c *Client) submitTask(ctx context.Context, client tlsclient.HttpClient, token, teamID string, body map[string]any) (map[string]any, error) {
|
||||
return c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/tasks", body)
|
||||
}
|
||||
|
||||
// apiJSON performs an authed JSON request against the Runway API and returns the
|
||||
// parsed body, mapping status codes to the shared provider error sentinels.
|
||||
func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token, teamID, method, path string, body any) (map[string]any, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(method, apiBase+path, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = browserHeaders(token, teamID)
|
||||
// The generate-submit endpoint is the ONE authed call the real web bundle
|
||||
// sends WITHOUT x-runway-client-id / source-application[-version] — every
|
||||
// other endpoint carries them, /v1/tasks carries only x-runway-workspace
|
||||
// (verified across the whole reference HAR). Leaving them on /v1/tasks is a
|
||||
// bot tell that gets the account's free credits revoked (permitted
|
||||
// numPlanCredits 500 -> 0) the instant a task is submitted. Strip them here so
|
||||
// the submit matches the browser exactly.
|
||||
if method == http.MethodPost && path == "/v1/tasks" {
|
||||
// NOTE: raw map delete, NOT req.Header.Del() — fhttp canonicalizes the key
|
||||
// in Del() ("X-Runway-Client-Id") but our headers are stored lowercase (so
|
||||
// they go on the h2 wire lowercase like Chrome), so Del() silently no-ops.
|
||||
delete(req.Header, "x-runway-client-id")
|
||||
delete(req.Header, "x-runway-source-application")
|
||||
delete(req.Header, "x-runway-source-application-version")
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
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))
|
||||
case resp.StatusCode == 429:
|
||||
return nil, fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 200))
|
||||
case resp.StatusCode >= 500:
|
||||
return nil, fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, resp.StatusCode, clip(raw, 200))
|
||||
case resp.StatusCode < 200 || resp.StatusCode >= 300:
|
||||
if isCreditError(string(raw)) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 200))
|
||||
}
|
||||
return nil, fmt.Errorf("runway: %s %d %s", path, resp.StatusCode, clip(raw, 200))
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// putBytes uploads raw bytes to a presigned S3 URL (no auth) and returns the
|
||||
// ETag, mirroring the plain requests.Session().put in gen_video.py.
|
||||
func (c *Client) putBytes(ctx context.Context, client tlsclient.HttpClient, url, contentType string, data []byte) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = browserAssetHeaders()
|
||||
req.Header.Set("content-type", contentType)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("%w: s3 put %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return strings.Trim(resp.Header.Get("ETag"), `"`), nil
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = browserAssetHeaders()
|
||||
req.Header.Set("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
req.Header.Set("sec-fetch-dest", "image")
|
||||
req.Header.Set("sec-fetch-mode", "no-cors")
|
||||
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, errors.New("runway: empty artifact download")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// jsonNumberOrString returns the team id as a JSON number when it's purely
|
||||
// numeric (Runway's asTeamId is an integer in the reference payloads), else the
|
||||
// raw string.
|
||||
func jsonNumberOrString(teamID string) any {
|
||||
return json.Number(strings.TrimSpace(teamID))
|
||||
}
|
||||
|
||||
func isCreditError(s string) bool {
|
||||
s = strings.ToLower(s)
|
||||
return strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
|
||||
}
|
||||
|
||||
// sleepCtx sleeps for d or until ctx is done; returns ctx.Err() if cancelled.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ChannelRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewChannelRepository(db *gorm.DB) *ChannelRepository {
|
||||
return &ChannelRepository{db: db}
|
||||
}
|
||||
|
||||
// ChannelWithUpstream pairs a channel row with the upstream model name a
|
||||
// binding carries — the shape dispatch needs in one fetch.
|
||||
type ChannelWithUpstream struct {
|
||||
Channel model.Channel
|
||||
UpstreamModel string
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) List(ctx context.Context) ([]model.Channel, error) {
|
||||
var items []model.Channel
|
||||
if err := r.db.WithContext(ctx).Order("created_at asc").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) Get(ctx context.Context, id string) (*model.Channel, error) {
|
||||
var item model.Channel
|
||||
if err := r.db.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) Create(ctx context.Context, item *model.Channel) error {
|
||||
return r.db.WithContext(ctx).Create(item).Error
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) Update(ctx context.Context, id string, patch map[string]any) (*model.Channel, error) {
|
||||
patch["updated_at"] = time.Now()
|
||||
if err := r.db.WithContext(ctx).Model(&model.Channel{}).Where("id = ?", id).Updates(patch).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) Delete(ctx context.Context, id string) (int64, error) {
|
||||
res := r.db.WithContext(ctx).Delete(&model.Channel{}, "id = ?", id)
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// Bindings returns all channel_models rows for a channel.
|
||||
func (r *ChannelRepository) Bindings(ctx context.Context, channelID string) ([]model.ChannelModel, error) {
|
||||
var items []model.ChannelModel
|
||||
if err := r.db.WithContext(ctx).Where("channel_id = ?", channelID).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Bind upserts one channel↔model binding (upstream model name is refreshed on
|
||||
// conflict so re-import can rename it).
|
||||
func (r *ChannelRepository) Bind(ctx context.Context, channelID, modelID, upstreamModel string) error {
|
||||
row := model.ChannelModel{ChannelID: channelID, ModelID: modelID, UpstreamModel: upstreamModel}
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "channel_id"}, {Name: "model_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"upstream_model", "updated_at"}),
|
||||
}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) Unbind(ctx context.Context, channelID, modelID string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ? AND model_id = ?", channelID, modelID).Error
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) DeleteBindingsByChannel(ctx context.Context, channelID string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ?", channelID).Error
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) DeleteBindingsByModel(ctx context.Context, modelID string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "model_id = ?", modelID).Error
|
||||
}
|
||||
|
||||
// ForModel returns enabled channels bound to modelID (channel rows + the
|
||||
// upstream model name each binding carries), stable-ordered for dispatch.
|
||||
func (r *ChannelRepository) ForModel(ctx context.Context, modelID string) ([]ChannelWithUpstream, error) {
|
||||
var rows []struct {
|
||||
model.Channel
|
||||
UpstreamModel string `gorm:"column:upstream_model"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("channels").
|
||||
Select("channels.*, channel_models.upstream_model").
|
||||
Joins("JOIN channel_models ON channel_models.channel_id = channels.id").
|
||||
Where("channel_models.model_id = ? AND channels.enabled = ?", modelID, true).
|
||||
Order("channels.id asc").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ChannelWithUpstream, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, ChannelWithUpstream{Channel: row.Channel, UpstreamModel: row.UpstreamModel})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ForModelAnyState is ForModel without the enabled filter — used by admin
|
||||
// channel-pinned tests which deliberately target one channel even when it's
|
||||
// disabled for traffic.
|
||||
func (r *ChannelRepository) ForModelAnyState(ctx context.Context, modelID string) ([]ChannelWithUpstream, error) {
|
||||
var rows []struct {
|
||||
model.Channel
|
||||
UpstreamModel string `gorm:"column:upstream_model"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("channels").
|
||||
Select("channels.*, channel_models.upstream_model").
|
||||
Joins("JOIN channel_models ON channel_models.channel_id = channels.id").
|
||||
Where("channel_models.model_id = ?", modelID).
|
||||
Order("channels.id asc").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ChannelWithUpstream, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, ChannelWithUpstream{Channel: row.Channel, UpstreamModel: row.UpstreamModel})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ModelIDsByChannel lists local model ids bound to a channel.
|
||||
func (r *ChannelRepository) ModelIDsByChannel(ctx context.Context, channelID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := r.db.WithContext(ctx).Model(&model.ChannelModel{}).
|
||||
Where("channel_id = ?", channelID).Pluck("model_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// UpstreamModelMap returns model_id → upstream_model for a channel.
|
||||
func (r *ChannelRepository) UpstreamModelMap(ctx context.Context, channelID string) (map[string]string, error) {
|
||||
var rows []model.ChannelModel
|
||||
if err := r.db.WithContext(ctx).Where("channel_id = ?", channelID).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string, len(rows))
|
||||
for _, row := range rows {
|
||||
out[row.ModelID] = row.UpstreamModel
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ChannelIDsByModel lists channel ids bound to a model (any enabled state).
|
||||
func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := r.db.WithContext(ctx).Model(&model.ChannelModel{}).
|
||||
Where("model_id = ?", modelID).Pluck("channel_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// OrphanModelIDs returns local model ids that no channel binds anymore.
|
||||
func (r *ChannelRepository) OrphanModelIDs(ctx context.Context) ([]string, error) {
|
||||
var ids []string
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("model_configs").
|
||||
Select("model_configs.id").
|
||||
Joins("LEFT JOIN channel_models ON channel_models.model_id = model_configs.id").
|
||||
Where("channel_models.model_id IS NULL").
|
||||
Scan(&ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// BumpSuccess records one successful upstream call (count + reported credits
|
||||
// spent + last-used timestamp). Best-effort.
|
||||
func (r *ChannelRepository) BumpSuccess(ctx context.Context, channelID string, credits float64) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&model.Channel{}).Where("id = ?", channelID).Updates(map[string]any{
|
||||
"success_total": gorm.Expr("success_total + 1"),
|
||||
"used_credits": gorm.Expr("used_credits + ?", credits),
|
||||
"last_used_at": now,
|
||||
}).Error
|
||||
}
|
||||
@@ -24,8 +24,8 @@ type EventListFilter struct {
|
||||
UserID string
|
||||
UserIDs []string // when set, keep ONLY rows whose user_id is in this list (admin 用户搜索)
|
||||
Query string // free-text search over prompt / model / error (server-side, 跨页)
|
||||
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
|
||||
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
|
||||
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
|
||||
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
|
||||
HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media)
|
||||
ExcludeFiles []string // when set, omit rows whose file is in this list (e.g. hide homepage showcase media from user galleries)
|
||||
MediaOnly bool // when true, keep only rows that are pending or have a stored file — the 画图台 grid, so deleted works don't eat a slot
|
||||
@@ -618,9 +618,16 @@ func (r *EventRepository) MarkRefunded(ctx context.Context, eventID string) (boo
|
||||
return res.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// SetAccount stamps which provider account is fulfilling an in-flight event.
|
||||
// Called when generation commits to a token, so the accounts view can count
|
||||
// pending events per account and an abandoned-event purge can attribute back.
|
||||
// SetUpstreamJob records the job id the upstream assigned (NDJSON jobId frame)
|
||||
// for cross-instance debugging. Best-effort.
|
||||
func (r *EventRepository) SetUpstreamJob(ctx context.Context, eventID, jobID string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.EventLog{}).
|
||||
Where("id = ?", eventID).
|
||||
Update("upstream_job", jobID).Error
|
||||
}
|
||||
|
||||
// SetAccount stamps which channel is fulfilling an in-flight event.
|
||||
func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID, accountEmail string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.EventLog{}).
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RefreshProfileRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRefreshProfileRepository(db *gorm.DB) *RefreshProfileRepository {
|
||||
return &RefreshProfileRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) List(ctx context.Context) ([]model.RefreshProfile, error) {
|
||||
var items []model.RefreshProfile
|
||||
if err := r.db.WithContext(ctx).
|
||||
Order("created_at desc").
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) Get(ctx context.Context, id string) (*model.RefreshProfile, error) {
|
||||
var item model.RefreshProfile
|
||||
if err := r.db.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) Create(ctx context.Context, item *model.RefreshProfile) error {
|
||||
return r.db.WithContext(ctx).Create(item).Error
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) Update(ctx context.Context, id string, patch map[string]any) (*model.RefreshProfile, error) {
|
||||
patch["updated_at"] = time.Now()
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.RefreshProfile{}).
|
||||
Where("id = ?", id).
|
||||
Updates(patch).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.RefreshProfile{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) DeleteByIDs(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Delete(&model.RefreshProfile{}, "id IN ?", ids).Error
|
||||
}
|
||||
|
||||
// ListDue returns enabled profiles whose next_retry_at has passed (or is unset,
|
||||
// e.g. freshly imported). The background maintenance loop refreshes these.
|
||||
func (r *RefreshProfileRepository) ListDue(ctx context.Context, now time.Time) ([]model.RefreshProfile, error) {
|
||||
var items []model.RefreshProfile
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("enabled = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", true, now).
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type TokenRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTokenRepository(db *gorm.DB) *TokenRepository {
|
||||
return &TokenRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *TokenRepository) List(ctx context.Context) ([]model.TokenAccount, error) {
|
||||
var items []model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
Order("pool asc, created_at desc").
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *TokenRepository) ListByPool(ctx context.Context, pool string) ([]model.TokenAccount, error) {
|
||||
var items []model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("pool = ?", pool).
|
||||
Order("created_at desc").
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *TokenRepository) Get(ctx context.Context, pool, id string) (*model.TokenAccount, error) {
|
||||
var item model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
First(&item, "pool = ? AND id = ?", pool, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// GetByPoolEmail finds an account in a pool by its account_email (the logical
|
||||
// identity for import dedup). Returns (nil, nil) when none / email is blank.
|
||||
func (r *TokenRepository) GetByPoolEmail(ctx context.Context, pool, email string) (*model.TokenAccount, error) {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var item model.TokenAccount
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("pool = ? AND account_email = ?", pool, email).
|
||||
First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *TokenRepository) Create(ctx context.Context, item *model.TokenAccount) error {
|
||||
return r.db.WithContext(ctx).Create(item).Error
|
||||
}
|
||||
|
||||
func (r *TokenRepository) Update(ctx context.Context, pool, id string, patch map[string]any) (*model.TokenAccount, error) {
|
||||
patch["updated_at"] = time.Now()
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", pool, id).
|
||||
Updates(patch).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, pool, id)
|
||||
}
|
||||
|
||||
// SwapValue replaces an account's credential only while the stored one is still
|
||||
// the value the caller started from. A rotating cookie is minted from whatever
|
||||
// was in the row, so a goroutine that has been holding an older copy (a long
|
||||
// render, a slow quota probe) must NOT be allowed to write it back over a newer
|
||||
// rotation — that older copy no longer authenticates, and the account then looks
|
||||
// dead. Reports whether the row was updated.
|
||||
func (r *TokenRepository) SwapValue(ctx context.Context, pool, id, from, to string) (bool, error) {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ? AND value = ?", pool, id, from).
|
||||
Updates(map[string]any{"value": to, "updated_at": time.Now()})
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// ReserveQuota atomically pre-deducts `amount` from an account's cached image
|
||||
// token balance under a row lock, so concurrent picks of the same near-empty
|
||||
// account can never over-commit it. Returns:
|
||||
// - allowed=true, deducted=true: balance was known and ≥ amount → decremented.
|
||||
// - allowed=true, deducted=false: balance unknown → allowed without a hold
|
||||
// (benefit of the doubt; a post-render reconcile writes the real value).
|
||||
// - allowed=false: balance known and < amount → caller should fail over.
|
||||
// RefundQuota releases a hold made with deducted=true when the render fails.
|
||||
func (r *TokenRepository) ReserveQuota(ctx context.Context, pool, id string, amount int) (allowed, deducted bool, err error) {
|
||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item model.TokenAccount
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&item, "pool = ? AND id = ?", pool, id).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
rem, known := metaInt(item.Meta, "cached_quota_remaining")
|
||||
if !known {
|
||||
allowed, deducted = true, false
|
||||
return nil
|
||||
}
|
||||
if rem < amount {
|
||||
allowed, deducted = false, false
|
||||
return nil
|
||||
}
|
||||
meta := cloneMeta(item.Meta)
|
||||
meta["cached_quota_remaining"] = rem - amount
|
||||
if e := tx.Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", pool, id).
|
||||
Updates(map[string]any{"meta": meta, "updated_at": time.Now()}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
allowed, deducted = true, true
|
||||
return nil
|
||||
})
|
||||
return allowed, deducted, err
|
||||
}
|
||||
|
||||
// Grok accounts carry a forced local quota instead of an upstream balance:
|
||||
// Console 没有额度接口,所以导入时写死 图 5 / 视频 2,下单预扣、失败退回,两个都归零直接判死。
|
||||
const (
|
||||
GrokImageQuotaKey = "grok_image_remaining"
|
||||
GrokVideoQuotaKey = "grok_video_remaining"
|
||||
GrokImageQuota = 5
|
||||
GrokVideoQuota = 2
|
||||
)
|
||||
|
||||
// grokQuotas reads an account's local per-kind counters, falling back to the
|
||||
// forced defaults for accounts imported before the counters existed.
|
||||
func grokQuotas(meta datatypes.JSONMap) (images, videos int) {
|
||||
images, known := metaInt(meta, GrokImageQuotaKey)
|
||||
if !known {
|
||||
images = GrokImageQuota
|
||||
}
|
||||
videos, known = metaInt(meta, GrokVideoQuotaKey)
|
||||
if !known {
|
||||
videos = GrokVideoQuota
|
||||
}
|
||||
return images, videos
|
||||
}
|
||||
|
||||
// ReserveGrokQuota pre-deducts one unit of a grok account's local per-kind quota
|
||||
// under a row lock, so concurrent orders on the same near-empty account can't
|
||||
// over-commit it(下单即扣,不会超扣)。allowed=false 表示这一份已经用光,调用方换号。
|
||||
// 归零的那一份打上 image_limited / video_limited 让调度跳过;判死留给
|
||||
// FinalizeGrokQuota(生成真的成功了才锁号),失败时用 RefundGrokQuota 退回。
|
||||
func (r *TokenRepository) ReserveGrokQuota(ctx context.Context, id, kind string) (allowed bool, err error) {
|
||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item model.TokenAccount
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
images, videos := grokQuotas(item.Meta)
|
||||
if kind == "video" {
|
||||
if videos <= 0 {
|
||||
return nil
|
||||
}
|
||||
videos--
|
||||
} else {
|
||||
if images <= 0 {
|
||||
return nil
|
||||
}
|
||||
images--
|
||||
}
|
||||
meta := cloneMeta(item.Meta)
|
||||
meta[GrokImageQuotaKey] = images
|
||||
meta[GrokVideoQuotaKey] = videos
|
||||
if e := tx.Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", "grok", id).
|
||||
Updates(map[string]any{
|
||||
"meta": meta,
|
||||
"image_limited": images <= 0,
|
||||
"video_limited": videos <= 0,
|
||||
"updated_at": time.Now(),
|
||||
}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
allowed = true
|
||||
return nil
|
||||
})
|
||||
return allowed, err
|
||||
}
|
||||
|
||||
// RefundGrokQuota gives back a unit reserved by ReserveGrokQuota when the render
|
||||
// failed, clearing that kind's limited flag again. Capped at the forced default so
|
||||
// repeated refunds can't inflate the quota.
|
||||
func (r *TokenRepository) RefundGrokQuota(ctx context.Context, id, kind string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item model.TokenAccount
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
images, videos := grokQuotas(item.Meta)
|
||||
if kind == "video" {
|
||||
videos = min(videos+1, GrokVideoQuota)
|
||||
} else {
|
||||
images = min(images+1, GrokImageQuota)
|
||||
}
|
||||
meta := cloneMeta(item.Meta)
|
||||
meta[GrokImageQuotaKey] = images
|
||||
meta[GrokVideoQuotaKey] = videos
|
||||
return tx.Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", "grok", id).
|
||||
Updates(map[string]any{
|
||||
"meta": meta,
|
||||
"image_limited": images <= 0,
|
||||
"video_limited": videos <= 0,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// FinalizeGrokQuota locks an account down once a successful generation left both
|
||||
// local counters at zero (no reset time — 用完就废).
|
||||
func (r *TokenRepository) FinalizeGrokQuota(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item model.TokenAccount
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
images, videos := grokQuotas(item.Meta)
|
||||
if images > 0 || videos > 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", "grok", id).
|
||||
Updates(map[string]any{
|
||||
"status": "disabled",
|
||||
"dead": true,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// RefundQuota atomically adds `amount` back to cached_quota_remaining (releasing a
|
||||
// hold from a reservation whose render then failed). No-op if the balance is
|
||||
// unknown. Row-locked like ReserveQuota.
|
||||
func (r *TokenRepository) RefundQuota(ctx context.Context, pool, id string, amount int) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item model.TokenAccount
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&item, "pool = ? AND id = ?", pool, id).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
rem, known := metaInt(item.Meta, "cached_quota_remaining")
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
meta := cloneMeta(item.Meta)
|
||||
meta["cached_quota_remaining"] = rem + amount
|
||||
return tx.Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", pool, id).
|
||||
Updates(map[string]any{"meta": meta, "updated_at": time.Now()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func cloneMeta(m datatypes.JSONMap) datatypes.JSONMap {
|
||||
out := datatypes.JSONMap{}
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func metaInt(m datatypes.JSONMap, key string) (int, bool) {
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x, true
|
||||
case int64:
|
||||
return int(x), true
|
||||
case float64:
|
||||
return int(x), true
|
||||
case json.Number:
|
||||
n, e := x.Int64()
|
||||
if e != nil {
|
||||
return 0, false
|
||||
}
|
||||
return int(n), true
|
||||
case string:
|
||||
n, e := strconv.Atoi(strings.TrimSpace(x))
|
||||
if e != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// TouchLastUsed stamps last_used_at at the moment a token is SELECTED, so the
|
||||
// accounts view reflects an accurate "last used" time. Rotation order is driven
|
||||
// by the in-memory strict round-robin cursor in the service layer (see
|
||||
// V1Service.rotateRoundRobin), not by this timestamp.
|
||||
func (r *TokenRepository) TouchLastUsed(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.TokenAccount{}).
|
||||
Where("id = ?", id).
|
||||
Update("last_used_at", time.Now()).Error
|
||||
}
|
||||
|
||||
// IncrementFail bumps an account's failure counters by one. Used to attribute
|
||||
// an abandoned (purged) generation's failure back to the account it was using,
|
||||
// since that generation never reached the normal markTokenFailure path.
|
||||
func (r *TokenRepository) IncrementFail(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.TokenAccount{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"fail_total": gorm.Expr("fail_total + 1"),
|
||||
"fails": gorm.Expr("fails + 1"),
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *TokenRepository) Delete(ctx context.Context, pool, id string) (int64, error) {
|
||||
res := r.db.WithContext(ctx).
|
||||
Delete(&model.TokenAccount{}, "pool = ? AND id = ?", pool, id)
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// DeleteByIDs removes accounts by id across pools (ids are globally unique),
|
||||
// for bulk delete. Returns the number of rows removed.
|
||||
func (r *TokenRepository) DeleteByIDs(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
res := r.db.WithContext(ctx).Delete(&model.TokenAccount{}, "id IN ?", ids)
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// leonardoDailyTokens is the free-tier daily allowance restored at each reset.
|
||||
// A paid account's true balance is reconciled on its next successful render.
|
||||
const leonardoDailyTokens = 150
|
||||
|
||||
// RecoverQuota reactivates quota-exhausted tokens whose reset time has passed.
|
||||
// Reset source: cached_quota_reset_after (upstream marker) first, else the
|
||||
// quota_recover_at fallback stamped when the token was marked quota-exhausted.
|
||||
// Mirrors Python TokenPool.recover_quota; returns the count reactivated.
|
||||
// RecoverQuota reactivates quota-exhausted tokens whose reset time has passed and
|
||||
// returns the accounts it recovered, so the caller can re-sync their real balance
|
||||
// (the providers only sync quota when accessed).
|
||||
func (r *TokenRepository) RecoverQuota(ctx context.Context) ([]model.TokenAccount, error) {
|
||||
// Also pick up accounts that are only single-kind limited (image_limited /
|
||||
// video_limited) — those keep status "active" and would otherwise never have
|
||||
// their per-kind flag cleared. This is legacy support; Adobe no longer sets
|
||||
// per-kind limits.
|
||||
var items []model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("status = ? OR image_limited = ? OR video_limited = ?", "quota", true, true).
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
var recovered []model.TokenAccount
|
||||
for i := range items {
|
||||
t := &items[i]
|
||||
// Runway's reset marker is the JWT expiry, not a quota-refresh time, and
|
||||
// there's no way to refresh a bare JWT — so a runway account is never
|
||||
// "recovered"; it's expired-to-dead by ExpireByReset instead.
|
||||
if t.Pool == "runway" {
|
||||
continue
|
||||
}
|
||||
reset := parseResetMarker(t.CachedQuotaResetAfter)
|
||||
if reset == nil {
|
||||
reset = t.QuotaRecoverAt
|
||||
}
|
||||
if reset == nil || now.Before(*reset) {
|
||||
continue
|
||||
}
|
||||
patch := map[string]any{
|
||||
"fails": 0,
|
||||
"quota_recover_at": nil,
|
||||
}
|
||||
// For Adobe, also clear any legacy per-kind limit flags.
|
||||
if t.Pool == "adobe" {
|
||||
patch["image_limited"] = false
|
||||
patch["video_limited"] = false
|
||||
}
|
||||
// Only flip status back to active if it was sunk to "quota" (both kinds
|
||||
// limited); a single-kind limit left status untouched.
|
||||
if t.Status == "quota" {
|
||||
patch["status"] = "active"
|
||||
}
|
||||
// Leonardo's free tokens fully renew at each daily reset — restore the
|
||||
// balance and advance the reset marker to the next 08:00 Beijing (== next
|
||||
// UTC midnight), so the account is immediately usable instead of stuck at a
|
||||
// stale 0. A paid account's real balance is corrected on its next render.
|
||||
if t.Pool == "leonardo" || t.Pool == "krea" || t.Pool == "imagine" {
|
||||
meta := cloneMeta(t.Meta)
|
||||
if t.Pool == "leonardo" {
|
||||
meta["cached_quota_remaining"] = leonardoDailyTokens
|
||||
} else {
|
||||
// Krea/Imagine balances re-sync from upstream (billing-data / v1/credit)
|
||||
// on next probe — drop the stale value so the account isn't shown as
|
||||
// empty after reset.
|
||||
delete(meta, "cached_quota_remaining")
|
||||
}
|
||||
meta["cached_quota_at"] = int(now.Unix())
|
||||
patch["meta"] = meta
|
||||
patch["cached_quota_reset_after"] = time.Unix((now.Unix()/86400+1)*86400, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
if _, err := r.Update(ctx, t.Pool, t.ID, patch); err != nil {
|
||||
return recovered, err
|
||||
}
|
||||
recovered = append(recovered, *t)
|
||||
}
|
||||
return recovered, nil
|
||||
}
|
||||
|
||||
// RollResetMarkers advances a stale (past) daily-reset marker to its next future
|
||||
// occurrence — same time-of-day, +N whole days — for ACTIVE accounts of the given
|
||||
// daily-reset pools, so the 恢复时间 column always shows the upcoming reset rather
|
||||
// than yesterday's. Only active accounts are rolled: a 限额 account must keep its
|
||||
// past marker so RecoverQuota can recover it (rolling it forward early would
|
||||
// prevent recovery). Returns the number advanced.
|
||||
func (r *TokenRepository) RollResetMarkers(ctx context.Context, pools []string) (int, error) {
|
||||
var items []model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("pool IN ? AND dead = ? AND status = ? AND image_limited = ? AND video_limited = ? AND cached_quota_reset_after <> ''",
|
||||
pools, false, "active", false, false).
|
||||
Find(&items).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now()
|
||||
n := 0
|
||||
for i := range items {
|
||||
t := &items[i]
|
||||
reset := parseResetMarker(t.CachedQuotaResetAfter)
|
||||
if reset == nil || !reset.Before(now) {
|
||||
continue // unparseable or already in the future
|
||||
}
|
||||
next := *reset
|
||||
for !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
if _, err := r.Update(ctx, t.Pool, t.ID, map[string]any{
|
||||
"cached_quota_reset_after": next.UTC().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ExpireByReset marks accounts of a pool dead once their reset marker has passed.
|
||||
// For runway the marker IS the JWT expiry and there's no refresh, so an expired
|
||||
// token can only 401 — we proactively flip it to disabled+dead (the same end
|
||||
// state a 401 would produce) instead of leaving a doomed account "active".
|
||||
func (r *TokenRepository) ExpireByReset(ctx context.Context, pool string) (int, error) {
|
||||
var items []model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("pool = ? AND dead = ?", pool, false).
|
||||
Find(&items).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now()
|
||||
expired := 0
|
||||
for i := range items {
|
||||
t := &items[i]
|
||||
reset := parseResetMarker(t.CachedQuotaResetAfter)
|
||||
if reset == nil || now.Before(*reset) {
|
||||
continue
|
||||
}
|
||||
if _, err := r.Update(ctx, t.Pool, t.ID, map[string]any{
|
||||
"status": "disabled",
|
||||
"dead": true,
|
||||
}); err != nil {
|
||||
return expired, err
|
||||
}
|
||||
expired++
|
||||
}
|
||||
return expired, nil
|
||||
}
|
||||
|
||||
// parseResetMarker best-effort parses a quota reset marker into a time. Accepts
|
||||
// epoch seconds (numeric string) or ISO-8601 (e.g. Adobe's available_until
|
||||
// "2026-06-16T23:59:59.999Z"). Returns nil if unparseable.
|
||||
func parseResetMarker(v string) *time.Time {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 946684800 {
|
||||
t := time.Unix(int64(f), 0)
|
||||
return &t
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05.999Z07:00", "2006-01-02T15:04:05Z07:00"} {
|
||||
if t, err := time.Parse(layout, v); err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -19,20 +19,20 @@ type AdminReadService struct {
|
||||
models *repo.ModelRepository
|
||||
events *repo.EventRepository
|
||||
settings *repo.SiteSettingRepository
|
||||
tokens *repo.TokenRepository
|
||||
channels *repo.ChannelRepository
|
||||
cdks *repo.CDKRepository
|
||||
store *storage.Client
|
||||
showcase *repo.ShowcaseRepository
|
||||
}
|
||||
|
||||
func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, tokens *repo.TokenRepository, cdks *repo.CDKRepository, store *storage.Client, showcase *repo.ShowcaseRepository) *AdminReadService {
|
||||
func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, channels *repo.ChannelRepository, cdks *repo.CDKRepository, store *storage.Client, showcase *repo.ShowcaseRepository) *AdminReadService {
|
||||
return &AdminReadService{
|
||||
cfg: cfg,
|
||||
users: users,
|
||||
models: models,
|
||||
events: events,
|
||||
settings: settings,
|
||||
tokens: tokens,
|
||||
channels: channels,
|
||||
cdks: cdks,
|
||||
store: store,
|
||||
showcase: showcase,
|
||||
@@ -102,6 +102,12 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
||||
"duration_prices_agent": map[string]any(item.DurationPricesAgent),
|
||||
"durations": repo.JSONStrings(item.Durations),
|
||||
"max_reference_images": item.MaxReferenceImages,
|
||||
"max_reference_videos": item.MaxReferenceVideos,
|
||||
"max_reference_audios": item.MaxReferenceAudios,
|
||||
"duration_min": item.DurationMin,
|
||||
"duration_max": item.DurationMax,
|
||||
"max_ref_video_seconds": item.MaxRefVideoSeconds,
|
||||
"max_ref_audio_seconds": item.MaxRefAudioSeconds,
|
||||
"reference_mode": item.ReferenceMode,
|
||||
"weight": item.Weight,
|
||||
"generation_count": item.GenerationCount,
|
||||
@@ -194,24 +200,24 @@ func (s *AdminReadService) UserNameMap(ctx context.Context) (map[string]string,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AccountNameMap builds a token-account id -> display label lookup (account
|
||||
// email, else display name, else id) used to annotate log rows with which
|
||||
// provider account fulfilled each generation (event_logs.account_id).
|
||||
// AccountNameMap builds a channel id -> display name lookup used to annotate
|
||||
// log rows with which channel fulfilled each generation (event_logs.account_id
|
||||
// now stores the channel id; account_email carries a denormalized name stamp).
|
||||
func (s *AdminReadService) AccountNameMap(ctx context.Context) (map[string]string, error) {
|
||||
accounts, err := s.tokens.List(ctx)
|
||||
if s.channels == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
channels, err := s.channels.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string, len(accounts))
|
||||
for _, a := range accounts {
|
||||
label := strings.TrimSpace(a.AccountEmail)
|
||||
out := make(map[string]string, len(channels))
|
||||
for _, ch := range channels {
|
||||
label := strings.TrimSpace(ch.Name)
|
||||
if label == "" {
|
||||
label = strings.TrimSpace(a.AccountDisplayName)
|
||||
label = ch.ID
|
||||
}
|
||||
if label == "" {
|
||||
label = a.ID
|
||||
}
|
||||
out[a.ID] = label
|
||||
out[ch.ID] = label
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -420,66 +426,30 @@ func (s *AdminReadService) Invites(ctx context.Context) ([]repo.InviteRecord, *r
|
||||
return s.users.AllInvites(ctx, reward)
|
||||
}
|
||||
|
||||
// Providers is now a channel summary (the account pools are gone): one row per
|
||||
// channel with its bound-model count and local usage stats.
|
||||
func (s *AdminReadService) Providers(ctx context.Context) ([]map[string]any, error) {
|
||||
models, err := s.models.List(ctx)
|
||||
if s.channels == nil {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
channels, err := s.channels.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, err := s.tokens.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelCounts := map[string]int{}
|
||||
for _, item := range models {
|
||||
modelCounts[item.Provider]++
|
||||
}
|
||||
type aggregate struct {
|
||||
active int
|
||||
disabled int
|
||||
quota int
|
||||
}
|
||||
tokenCounts := map[string]*aggregate{}
|
||||
for _, item := range tokens {
|
||||
if _, ok := tokenCounts[item.Pool]; !ok {
|
||||
tokenCounts[item.Pool] = &aggregate{}
|
||||
}
|
||||
switch item.Status {
|
||||
case "active":
|
||||
tokenCounts[item.Pool].active++
|
||||
case "quota":
|
||||
tokenCounts[item.Pool].quota++
|
||||
default:
|
||||
tokenCounts[item.Pool].disabled++
|
||||
}
|
||||
}
|
||||
providers := []struct {
|
||||
Name string
|
||||
Pool string
|
||||
Type string
|
||||
}{
|
||||
{Name: "chatgpt", Pool: "chatgpt", Type: "openai"},
|
||||
{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))
|
||||
for _, item := range providers {
|
||||
count := tokenCounts[item.Pool]
|
||||
if count == nil {
|
||||
count = &aggregate{}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
modelIDs, _ := s.channels.ModelIDsByChannel(ctx, ch.ID)
|
||||
out = append(out, map[string]any{
|
||||
"name": item.Name,
|
||||
"token_pool": item.Pool,
|
||||
"type": item.Type,
|
||||
"model_count": modelCounts[item.Name],
|
||||
"tokens_total": count.active + count.disabled + count.quota,
|
||||
"tokens_active": count.active,
|
||||
"tokens_disabled": count.disabled,
|
||||
"tokens_quota": count.quota,
|
||||
"id": ch.ID,
|
||||
"name": ch.Name,
|
||||
"base_url": ch.BaseURL,
|
||||
"enabled": ch.Enabled,
|
||||
"concurrency": ch.Concurrency,
|
||||
"weight": ch.Weight,
|
||||
"model_count": len(modelIDs),
|
||||
"success_total": ch.SuccessTotal,
|
||||
"used_credits": ch.UsedCredits,
|
||||
"type": "vivid",
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -28,18 +28,18 @@ type AdminWriteService struct {
|
||||
models *repo.ModelRepository
|
||||
events *repo.EventRepository
|
||||
apiKeys *repo.APIKeyRepository
|
||||
tokens *repo.TokenRepository
|
||||
channels *repo.ChannelRepository
|
||||
orders *repo.OrderRepository
|
||||
}
|
||||
|
||||
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository, tokens *repo.TokenRepository, orders *repo.OrderRepository) *AdminWriteService {
|
||||
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository, channels *repo.ChannelRepository, orders *repo.OrderRepository) *AdminWriteService {
|
||||
return &AdminWriteService{
|
||||
users: users,
|
||||
showcase: showcase,
|
||||
models: models,
|
||||
events: events,
|
||||
apiKeys: apiKeys,
|
||||
tokens: tokens,
|
||||
channels: channels,
|
||||
orders: orders,
|
||||
}
|
||||
}
|
||||
@@ -371,7 +371,7 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
||||
return nil, errors.New("type must be image or video")
|
||||
}
|
||||
if provider == "" {
|
||||
return nil, errors.New("provider required")
|
||||
provider = "channel"
|
||||
}
|
||||
if err := s.validateModelNameSpace(ctx, "", modelID, alias); err != nil {
|
||||
return nil, err
|
||||
@@ -401,6 +401,8 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
||||
DurationPricesAgent: jsonMap(body["duration_prices_agent"]),
|
||||
Durations: jsonArray(body["durations"]),
|
||||
MaxReferenceImages: intValue(body["max_reference_images"]),
|
||||
MaxReferenceVideos: intValue(body["max_reference_videos"]),
|
||||
MaxReferenceAudios: intValue(body["max_reference_audios"]),
|
||||
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
|
||||
Weight: intValue(body["weight"]),
|
||||
CreatedAt: time.Now(),
|
||||
@@ -434,10 +436,9 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
||||
}
|
||||
if _, ok := body["provider"]; ok {
|
||||
provider := strings.TrimSpace(stringValue(body["provider"]))
|
||||
if provider == "" {
|
||||
return nil, errors.New("provider required")
|
||||
if provider != "" {
|
||||
patch["provider"] = provider
|
||||
}
|
||||
patch["provider"] = provider
|
||||
}
|
||||
// Only touch `enabled` when the caller explicitly sends a non-null value;
|
||||
// mirrors Python models_store.update ("enabled" in fields and is not None).
|
||||
@@ -477,9 +478,27 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
||||
if _, ok := body["max_reference_images"]; ok {
|
||||
patch["max_reference_images"] = intValue(body["max_reference_images"])
|
||||
}
|
||||
if _, ok := body["max_reference_videos"]; ok {
|
||||
patch["max_reference_videos"] = intValue(body["max_reference_videos"])
|
||||
}
|
||||
if _, ok := body["max_reference_audios"]; ok {
|
||||
patch["max_reference_audios"] = intValue(body["max_reference_audios"])
|
||||
}
|
||||
if _, ok := body["reference_mode"]; ok {
|
||||
patch["reference_mode"] = defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none")
|
||||
}
|
||||
if _, ok := body["duration_min"]; ok {
|
||||
patch["duration_min"] = intValue(body["duration_min"])
|
||||
}
|
||||
if _, ok := body["duration_max"]; ok {
|
||||
patch["duration_max"] = intValue(body["duration_max"])
|
||||
}
|
||||
if _, ok := body["max_ref_video_seconds"]; ok {
|
||||
patch["max_ref_video_seconds"] = intValue(body["max_ref_video_seconds"])
|
||||
}
|
||||
if _, ok := body["max_ref_audio_seconds"]; ok {
|
||||
patch["max_ref_audio_seconds"] = intValue(body["max_ref_audio_seconds"])
|
||||
}
|
||||
if _, ok := body["weight"]; ok {
|
||||
patch["weight"] = intValue(body["weight"])
|
||||
}
|
||||
@@ -524,52 +543,14 @@ func (s *AdminWriteService) DeleteModel(ctx context.Context, modelID string) err
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
// Clean up: strip this model id from every custom upstream account's
|
||||
// supported-models list so no orphan reference is left behind.
|
||||
s.removeModelFromUpstreams(ctx, modelID)
|
||||
// Clean up: drop every channel binding pointing at the deleted model so no
|
||||
// orphan reference is left behind.
|
||||
if s.channels != nil {
|
||||
_ = s.channels.DeleteBindingsByModel(ctx, modelID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeModelFromUpstreams drops modelID from the CSV in each custom account's
|
||||
// Meta["models"]. Best-effort — a failure here doesn't undo the model delete.
|
||||
func (s *AdminWriteService) removeModelFromUpstreams(ctx context.Context, modelID string) {
|
||||
if s.tokens == nil {
|
||||
return
|
||||
}
|
||||
items, err := s.tokens.ListByPool(ctx, "custom")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
raw, _ := it.Meta["models"].(string)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue
|
||||
}
|
||||
kept := make([]string, 0, len(strings.Split(raw, ",")))
|
||||
changed := false
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if p == modelID {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
meta := datatypes.JSONMap{}
|
||||
for k, v := range it.Meta {
|
||||
meta[k] = v
|
||||
}
|
||||
meta["models"] = strings.Join(kept, ",")
|
||||
_, _ = s.tokens.Update(ctx, "custom", it.ID, map[string]any{"meta": meta})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminWriteService) ClearLogs(ctx context.Context) (int64, error) {
|
||||
return s.events.DeleteAll(ctx)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -109,8 +110,15 @@ func (s *APIKeyService) DeleteOne(ctx context.Context, userID, keyID string) err
|
||||
return s.keys.DeleteByID(ctx, userID, keyID)
|
||||
}
|
||||
|
||||
// generatePlainAPIKey mints a vivid-shaped key: vk_ + 48 lowercase hex chars
|
||||
// (upstream channel api_keys use the same shape, so one key format rules
|
||||
// everything — no per-direction prefixes).
|
||||
func generatePlainAPIKey() (string, error) {
|
||||
return "sk-" + randomUpper(38), nil
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "vk_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func previewAPIKey(plain string) string {
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/repo"
|
||||
"backend/internal/upstream"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ChannelService manages upstream vivid-protocol channels: CRUD, upstream key
|
||||
// encryption at rest, model fetching/import/binding, and live balance probes.
|
||||
// The actual request dispatch lives on V1Service (it owns concurrency + events).
|
||||
type ChannelService struct {
|
||||
channels *repo.ChannelRepository
|
||||
models *repo.ModelRepository
|
||||
up *upstream.Client
|
||||
secret []byte
|
||||
}
|
||||
|
||||
func NewChannelService(channels *repo.ChannelRepository, models *repo.ModelRepository, up *upstream.Client, secret string) *ChannelService {
|
||||
sum := sha256.Sum256([]byte("image2api-channel-keys|" + secret))
|
||||
return &ChannelService{channels: channels, models: models, up: up, secret: sum[:]}
|
||||
}
|
||||
|
||||
// ChannelView is the admin list row: local stats + bound model names + a live
|
||||
// upstream balance probe (nil when the upstream doesn't implement /v1/balance
|
||||
// or the probe failed).
|
||||
type ChannelView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Weight int `json:"weight"`
|
||||
Models []string `json:"models"`
|
||||
SuccessTotal int64 `json:"success_total"`
|
||||
UsedCredits float64 `json:"used_credits"`
|
||||
UpstreamBalance *int64 `json:"upstream_balance"`
|
||||
LastUsedAt *int64 `json:"last_used_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *ChannelService) List(ctx context.Context) ([]ChannelView, error) {
|
||||
items, err := s.channels.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ChannelView, len(items))
|
||||
var wg sync.WaitGroup
|
||||
for i, ch := range items {
|
||||
out[i] = ChannelView{
|
||||
ID: ch.ID,
|
||||
Name: ch.Name,
|
||||
BaseURL: ch.BaseURL,
|
||||
Enabled: ch.Enabled,
|
||||
Concurrency: ch.Concurrency,
|
||||
Weight: ch.Weight,
|
||||
SuccessTotal: ch.SuccessTotal,
|
||||
UsedCredits: ch.UsedCredits,
|
||||
CreatedAt: ch.CreatedAt.Unix(),
|
||||
UpdatedAt: ch.UpdatedAt.Unix(),
|
||||
}
|
||||
if ch.LastUsedAt != nil {
|
||||
t := ch.LastUsedAt.Unix()
|
||||
out[i].LastUsedAt = &t
|
||||
}
|
||||
if ids, err := s.channels.ModelIDsByChannel(ctx, ch.ID); err == nil {
|
||||
names := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if m, err := s.models.Get(ctx, id); err == nil {
|
||||
names = append(names, m.EffectiveName())
|
||||
} else {
|
||||
names = append(names, id)
|
||||
}
|
||||
}
|
||||
out[i].Models = names
|
||||
}
|
||||
if key := s.decrypt(ch.APIKeyEnc); key != "" {
|
||||
wg.Add(1)
|
||||
go func(i int, ch model.Channel, key string) {
|
||||
defer wg.Done()
|
||||
bctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
if bal, err := s.up.Balance(bctx, ch.BaseURL, key); err == nil {
|
||||
out[i].UpstreamBalance = bal
|
||||
}
|
||||
}(i, ch, key)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveChannelDto is the admin create/update payload. ApiKey empty or the mask
|
||||
// placeholder keeps the existing key on update; required on create.
|
||||
type SaveChannelDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Weight int `json:"weight"`
|
||||
}
|
||||
|
||||
func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Channel, error) {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
base := strings.TrimRight(strings.TrimSpace(d.BaseURL), "/")
|
||||
if base == "" {
|
||||
return nil, errors.New("base_url required")
|
||||
}
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
return nil, errors.New("base_url must start with http:// or https://")
|
||||
}
|
||||
weight := d.Weight
|
||||
if weight < 1 {
|
||||
weight = 1
|
||||
}
|
||||
if d.ID == "" {
|
||||
if strings.TrimSpace(d.APIKey) == "" {
|
||||
return nil, errors.New("api_key required")
|
||||
}
|
||||
enc, err := s.encrypt(d.APIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := &model.Channel{
|
||||
ID: "ch-" + randomUpper(10),
|
||||
Name: name,
|
||||
BaseURL: base,
|
||||
APIKeyEnc: enc,
|
||||
Enabled: d.Enabled,
|
||||
Concurrency: d.Concurrency,
|
||||
Weight: weight,
|
||||
}
|
||||
if ch.Name == "" {
|
||||
ch.Name = base
|
||||
}
|
||||
return ch, s.channels.Create(ctx, ch)
|
||||
}
|
||||
patch := map[string]any{
|
||||
"name": name,
|
||||
"base_url": base,
|
||||
"enabled": d.Enabled,
|
||||
"concurrency": d.Concurrency,
|
||||
"weight": weight,
|
||||
}
|
||||
if key := strings.TrimSpace(d.APIKey); key != "" && !strings.Contains(key, "•") && !strings.Contains(key, "*") {
|
||||
enc, err := s.encrypt(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patch["api_key_enc"] = enc
|
||||
}
|
||||
return s.channels.Update(ctx, d.ID, patch)
|
||||
}
|
||||
|
||||
// Delete removes a channel, its bindings, and any local model that ends up
|
||||
// bound to no channel at all (same semantics as vivid).
|
||||
func (s *ChannelService) Delete(ctx context.Context, id string) error {
|
||||
modelIDs, _ := s.channels.ModelIDsByChannel(ctx, id)
|
||||
if err := s.channels.DeleteBindingsByChannel(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
res := 0
|
||||
if n, err := s.channels.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
} else {
|
||||
res = int(n)
|
||||
}
|
||||
if res == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
for _, mid := range modelIDs {
|
||||
rest, _ := s.channels.ChannelIDsByModel(ctx, mid)
|
||||
if len(rest) == 0 {
|
||||
_, _ = s.models.Delete(ctx, mid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchModelsDto resolves credentials for a model fetch: Id uses the stored
|
||||
// channel's key; otherwise BaseURL+ApiKey preview an unsaved channel.
|
||||
type FetchModelsDto struct {
|
||||
ID string `json:"id"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
// FetchModels lists the upstream's sellable models and flags which are already
|
||||
// imported locally.
|
||||
func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]upstream.RemoteModel, error) {
|
||||
base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := s.up.Models(ctx, base, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ChannelModelSpec is one model the admin ticks for import (same shape as the
|
||||
// upstream /v1/models item plus an optional local alias).
|
||||
type ChannelModelSpec struct {
|
||||
ID string `json:"id"`
|
||||
Alias string `json:"alias"`
|
||||
Kind string `json:"kind"`
|
||||
Qualities []string `json:"qualities"`
|
||||
Duration *upstream.RemoteDuration `json:"duration,omitempty"`
|
||||
RefImages int64 `json:"refImages"`
|
||||
RefVideos int64 `json:"refVideos"`
|
||||
RefAudios int64 `json:"refAudios"`
|
||||
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
|
||||
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ChannelService) resolveCreds(ctx context.Context, id, base, key string) (string, string, error) {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
ch, err := s.channels.Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return strings.TrimRight(ch.BaseURL, "/"), s.decrypt(ch.APIKeyEnc), nil
|
||||
}
|
||||
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
if base == "" || strings.TrimSpace(key) == "" {
|
||||
return "", "", errors.New("id or base_url+api_key required")
|
||||
}
|
||||
return base, strings.TrimSpace(key), nil
|
||||
}
|
||||
|
||||
// ImportModels creates/updates local model rows for the ticked specs and binds
|
||||
// them to the channel. Pricing stays whatever the local row already has
|
||||
// (empty for new rows — admin prices them in 模型管理).
|
||||
func (s *ChannelService) ImportModels(ctx context.Context, channelID string, specs []ChannelModelSpec) error {
|
||||
ch, err := s.channels.Get(ctx, channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, spec := range specs {
|
||||
upID := strings.TrimSpace(spec.ID)
|
||||
if upID == "" {
|
||||
continue
|
||||
}
|
||||
localID := strings.TrimSpace(spec.Alias)
|
||||
if localID == "" {
|
||||
localID = upID
|
||||
}
|
||||
existing, gerr := s.models.Get(ctx, localID)
|
||||
if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) {
|
||||
existing = nil
|
||||
}
|
||||
var local *model.ModelConfig
|
||||
if existing != nil && existing.ID == localID {
|
||||
local = existing
|
||||
}
|
||||
patch := modelPatchFromSpec(spec, local)
|
||||
if local == nil {
|
||||
local = &model.ModelConfig{
|
||||
ID: localID,
|
||||
Name: localID,
|
||||
Type: spec.Kind,
|
||||
Provider: "channel",
|
||||
Enabled: true,
|
||||
Ratios: jsonArray([]string{"16:9"}),
|
||||
Prices: datatypes.JSONMap{},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
applySpec(local, patch)
|
||||
if err := s.models.Create(ctx, local); err != nil {
|
||||
return fmt.Errorf("model %s: %w", localID, err)
|
||||
}
|
||||
} else {
|
||||
if _, err := s.models.Update(ctx, localID, patch); err != nil {
|
||||
return fmt.Errorf("model %s: %w", localID, err)
|
||||
}
|
||||
}
|
||||
if err := s.channels.Bind(ctx, ch.ID, localID, upID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// modelPatchFromSpec maps an upstream model spec onto model_configs columns.
|
||||
// Prices/enabled/alias are intentionally untouched on update so admin pricing
|
||||
// survives re-import.
|
||||
func modelPatchFromSpec(spec ChannelModelSpec, existing *model.ModelConfig) map[string]any {
|
||||
patch := map[string]any{
|
||||
"type": spec.Kind,
|
||||
"resolutions": jsonArray(spec.Qualities),
|
||||
"max_reference_images": spec.RefImages,
|
||||
"max_reference_videos": spec.RefVideos,
|
||||
"max_reference_audios": spec.RefAudios,
|
||||
"max_ref_video_seconds": spec.RefVideoSeconds,
|
||||
"max_ref_audio_seconds": spec.RefAudioSeconds,
|
||||
}
|
||||
if spec.RefImages+spec.RefVideos+spec.RefAudios > 0 {
|
||||
patch["reference_mode"] = "asset"
|
||||
patch["image_to_image"] = spec.RefImages > 0
|
||||
}
|
||||
if d := spec.Duration; d != nil {
|
||||
switch d.Mode {
|
||||
case "fixed":
|
||||
patch["durations"] = jsonArray([]string{fmt.Sprintf("%ds", d.Seconds)})
|
||||
patch["duration_min"], patch["duration_max"] = int64(0), int64(0)
|
||||
case "options":
|
||||
opts := make([]string, 0, len(d.Options))
|
||||
for _, s := range d.Options {
|
||||
opts = append(opts, fmt.Sprintf("%ds", s))
|
||||
}
|
||||
patch["durations"] = jsonArray(opts)
|
||||
patch["duration_min"], patch["duration_max"] = int64(0), int64(0)
|
||||
case "range":
|
||||
patch["duration_min"], patch["duration_max"] = d.Min, d.Max
|
||||
}
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
func applySpec(m *model.ModelConfig, patch map[string]any) {
|
||||
if v, ok := patch["resolutions"]; ok {
|
||||
m.Resolutions = datatypes.JSON(mustJSON(v))
|
||||
}
|
||||
if v, ok := patch["durations"]; ok {
|
||||
m.Durations = datatypes.JSON(mustJSON(v))
|
||||
}
|
||||
if v, ok := patch["duration_min"]; ok {
|
||||
m.DurationMin = int(v.(int64))
|
||||
}
|
||||
if v, ok := patch["duration_max"]; ok {
|
||||
m.DurationMax = int(v.(int64))
|
||||
}
|
||||
m.MaxReferenceImages = int(specInt(patch, "max_reference_images"))
|
||||
m.MaxReferenceVideos = int(specInt(patch, "max_reference_videos"))
|
||||
m.MaxReferenceAudios = int(specInt(patch, "max_reference_audios"))
|
||||
m.MaxRefVideoSeconds = int(specInt(patch, "max_ref_video_seconds"))
|
||||
m.MaxRefAudioSeconds = int(specInt(patch, "max_ref_audio_seconds"))
|
||||
if v, ok := patch["reference_mode"].(string); ok {
|
||||
m.ReferenceMode = v
|
||||
}
|
||||
if v, ok := patch["image_to_image"].(bool); ok {
|
||||
m.ImageToImage = v
|
||||
}
|
||||
}
|
||||
|
||||
func specInt(patch map[string]any, key string) int64 {
|
||||
switch v := patch[key].(type) {
|
||||
case int64:
|
||||
return v
|
||||
case int:
|
||||
return int64(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
// ChannelsForModel returns enabled channels bound to the model with their
|
||||
// upstream model names — dispatch order is decided by the caller.
|
||||
func (s *ChannelService) ChannelsForModel(ctx context.Context, modelID string) ([]repo.ChannelWithUpstream, error) {
|
||||
return s.channels.ForModel(ctx, modelID)
|
||||
}
|
||||
|
||||
// ChannelsForModelAnyState includes disabled channels (admin pin tests).
|
||||
func (s *ChannelService) ChannelsForModelAnyState(ctx context.Context, modelID string) ([]repo.ChannelWithUpstream, error) {
|
||||
return s.channels.ForModelAnyState(ctx, modelID)
|
||||
}
|
||||
|
||||
// Decrypt exposes the upstream key for dispatch.
|
||||
func (s *ChannelService) Decrypt(ch model.Channel) string { return s.decrypt(ch.APIKeyEnc) }
|
||||
|
||||
// Upstream returns the shared protocol client (dispatch needs it for
|
||||
// UploadRef/Generate/Download; admin handlers for balance probes).
|
||||
func (s *ChannelService) Upstream() *upstream.Client { return s.up }
|
||||
|
||||
// BumpSuccess forwards local stats updates to the repo.
|
||||
func (s *ChannelService) BumpSuccess(ctx context.Context, channelID string, credits float64) {
|
||||
_ = s.channels.BumpSuccess(ctx, channelID, credits)
|
||||
}
|
||||
|
||||
// --- AES-GCM key encryption ---
|
||||
|
||||
const encPrefix = "enc:v1:"
|
||||
|
||||
func (s *ChannelService) encrypt(plain string) (string, error) {
|
||||
block, err := aes.NewCipher(s.secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return encPrefix + base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(plain), nil)), nil
|
||||
}
|
||||
|
||||
func (s *ChannelService) decrypt(stored string) string {
|
||||
if !strings.HasPrefix(stored, encPrefix) {
|
||||
return stored // plaintext rows (e.g. seeded by hand)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(stored, encPrefix))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
block, err := aes.NewCipher(s.secret)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return ""
|
||||
}
|
||||
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(plain)
|
||||
}
|
||||
@@ -5,25 +5,19 @@ import (
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/repo"
|
||||
"backend/internal/storage"
|
||||
)
|
||||
|
||||
// MaintenanceService runs the periodic self-healing sweep that the Python
|
||||
// original did via a 60s daemon thread plus read-time lazy cleanup. Without it
|
||||
// the Go token pool only ever loses capacity: tokens never re-activate after a
|
||||
// quota reset, cookies never auto-renew, stale pending events permanently block
|
||||
// a user's generation gate, and old media/logs accumulate unbounded.
|
||||
// MaintenanceService runs the periodic self-healing sweep. Without it stale
|
||||
// pending events permanently block a user's generation gate, and old media /
|
||||
// logs / temp objects accumulate unbounded. Token-pool and cookie-refresh
|
||||
// upkeep is gone — channels are stateless upstream credentials.
|
||||
type MaintenanceService struct {
|
||||
tokens *repo.TokenRepository
|
||||
tokenSvc *TokenService
|
||||
events *repo.EventRepository
|
||||
users *repo.UserRepository
|
||||
refresh *RefreshProfileService
|
||||
settings *repo.SiteSettingRepository
|
||||
store *storage.Client
|
||||
inflight *InflightRegistry
|
||||
@@ -36,20 +30,17 @@ type MaintenanceService struct {
|
||||
lastMediaPrune time.Time
|
||||
}
|
||||
|
||||
func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService, events *repo.EventRepository, users *repo.UserRepository, refresh *RefreshProfileService, settings *repo.SiteSettingRepository, store *storage.Client, inflight *InflightRegistry, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService {
|
||||
func NewMaintenanceService(events *repo.EventRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository, store *storage.Client, inflight *InflightRegistry, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService {
|
||||
return &MaintenanceService{
|
||||
tokens: tokens,
|
||||
tokenSvc: tokenSvc,
|
||||
events: events,
|
||||
users: users,
|
||||
refresh: refresh,
|
||||
settings: settings,
|
||||
store: store,
|
||||
inflight: inflight,
|
||||
showcase: showcase,
|
||||
orders: orders,
|
||||
interval: 60 * time.Second,
|
||||
stalePending: 600 * time.Second,
|
||||
events: events,
|
||||
users: users,
|
||||
settings: settings,
|
||||
store: store,
|
||||
inflight: inflight,
|
||||
showcase: showcase,
|
||||
orders: orders,
|
||||
interval: 60 * time.Second,
|
||||
stalePending: 600 * time.Second,
|
||||
// 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的
|
||||
// 10 分钟去扫会在它出片前判死并退款。
|
||||
stalePendingVid: 30 * time.Minute,
|
||||
@@ -63,10 +54,6 @@ func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService,
|
||||
func (m *MaintenanceService) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(m.interval)
|
||||
defer ticker.Stop()
|
||||
// The leonardo session keep-alive gets its own loop: one tick() can take tens
|
||||
// of minutes (219 adobe cookie profiles alone), which would stretch a 5-minute
|
||||
// keep-alive to the tick's real duration.
|
||||
go m.runLeonardoKeepalive(ctx)
|
||||
m.tick(ctx)
|
||||
for {
|
||||
select {
|
||||
@@ -78,55 +65,6 @@ func (m *MaintenanceService) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// runLeonardoKeepalive re-checks every minute which leonardo accounts are due for
|
||||
// a session renewal (the 5-minute due-ness itself is read per account from the DB,
|
||||
// so a restart can't skip one).
|
||||
func (m *MaintenanceService) runLeonardoKeepalive(ctx context.Context) {
|
||||
if m.tokenSvc == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
m.tokenSvc.RefreshLeonardoSessions(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncRecoveredQuota re-probes each just-recovered account so its displayed
|
||||
// balance reflects the post-reset value (these providers only sync quota when
|
||||
// accessed). krea additionally needs /app (Activate) to actually grant the daily
|
||||
// free balance before billing-data reports it. Bounded concurrency avoids a
|
||||
// thundering herd at the daily reset.
|
||||
func (m *MaintenanceService) syncRecoveredQuota(accs []model.TokenAccount) {
|
||||
sem := make(chan struct{}, 4)
|
||||
var wg sync.WaitGroup
|
||||
for _, acc := range accs {
|
||||
switch acc.Pool {
|
||||
case "chatgpt", "leonardo", "krea", "imagine":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(a model.TokenAccount) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
if a.Pool == "krea" && m.tokenSvc.krea != nil {
|
||||
m.tokenSvc.krea.Activate(ctx, a.Value)
|
||||
}
|
||||
_, _ = m.tokenSvc.Quota(ctx, a.Pool, a.ID)
|
||||
}(acc)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (m *MaintenanceService) tick(ctx context.Context) {
|
||||
// 0. Auto-cancel unpaid recharge orders past their 30-min TTL.
|
||||
if m.orders != nil {
|
||||
@@ -137,74 +75,7 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Re-activate quota-exhausted tokens whose reset time has passed, then
|
||||
// auto-sync their real balance — these providers only refresh quota when
|
||||
// accessed, so recovery alone would leave a stale 0/—. For krea the sync
|
||||
// must first load /app (Activate) to grant the daily free balance.
|
||||
if recovered, err := m.tokens.RecoverQuota(ctx); err != nil {
|
||||
log.Printf("maintenance: recover_quota: %v", err)
|
||||
} else if len(recovered) > 0 {
|
||||
log.Printf("maintenance: recovered %d quota token(s)", len(recovered))
|
||||
if m.tokenSvc != nil {
|
||||
go m.syncRecoveredQuota(recovered)
|
||||
}
|
||||
}
|
||||
|
||||
// 1a. Roll the 恢复时间 marker of ACTIVE daily-reset accounts forward to the next
|
||||
// future reset (same time-of-day, +1 day) so the column never shows a stale
|
||||
// past time. Limited accounts are intentionally skipped (RecoverQuota owns
|
||||
// their marker). adobe/leonardo/krea/imagine all renew daily.
|
||||
if _, err := m.tokens.RollResetMarkers(ctx, []string{"adobe", "leonardo", "krea", "imagine"}); err != nil {
|
||||
log.Printf("maintenance: roll_reset: %v", err)
|
||||
}
|
||||
|
||||
// 1b. Runway tokens have no refresh — once the JWT expiry marker passes the
|
||||
// token can only 401, so flip it to disabled+dead proactively instead of
|
||||
// leaving a doomed account "active". Grok is intentionally NOT swept here:
|
||||
// its reset marker is billingPeriodEnd (a credits-renewal date), not a
|
||||
// death deadline — a grok sso keeps working past billingPeriodEnd, so
|
||||
// expiring on it kills live accounts. Grok death is caught for real by the
|
||||
// import-time FetchSession check and by marking dead on a 401 at use.
|
||||
for _, pool := range []string{"runway"} {
|
||||
if n, err := m.tokens.ExpireByReset(ctx, pool); err != nil {
|
||||
log.Printf("maintenance: expire_%s: %v", pool, err)
|
||||
} else if n > 0 {
|
||||
log.Printf("maintenance: expired %d %s token(s)", n, pool)
|
||||
}
|
||||
}
|
||||
|
||||
// 1c. Proactively renew krea/imagine sessions ~10min before expiry so a
|
||||
// dormant account's rotating refresh_token never lapses (a dead token
|
||||
// can't be recovered and, for krea, blocks the daily free-credit meter
|
||||
// from being re-created). Only near-expiry accounts hit the network.
|
||||
if m.tokenSvc != nil {
|
||||
m.tokenSvc.RefreshExpiringTokens(ctx)
|
||||
// 1d. Once-per-day krea /app activation for accounts not yet synced since the
|
||||
// daily reset — krea only grants the free balance after /app loads, so an
|
||||
// always-active account (never went 限额) would otherwise read 0 / 402
|
||||
// after each reset. Self-guarded + background; no-op once all are done.
|
||||
m.tokenSvc.ActivateKreaDue(ctx)
|
||||
// 1e. Re-validate grok accounts: an empty /rest/subscriptions (or 401)
|
||||
// means the membership lapsed → disable+dead; otherwise re-sync the
|
||||
// credits balance and 恢复时间 (from the credits' weekly reset).
|
||||
m.tokenSvc.RefreshGrokLiveness(ctx)
|
||||
}
|
||||
|
||||
// 2. Auto-renew Adobe cookies whose refresh interval has elapsed.
|
||||
if m.refresh != nil {
|
||||
if m.settings != nil {
|
||||
if proxy, err := m.settings.GetValue(ctx, "proxy.url"); err == nil && proxy != "" {
|
||||
m.refresh.SetProxy(proxy)
|
||||
}
|
||||
}
|
||||
if n, err := m.refresh.RefreshDue(ctx); err != nil {
|
||||
log.Printf("maintenance: refresh_due: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("maintenance: refreshed %d cookie profile(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fail long-pending events so they stop blocking the per-user gate, and
|
||||
// 1. Fail long-pending events so they stop blocking the per-user gate, and
|
||||
// refund the credits debited up-front for each abandoned generation (the
|
||||
// normal failure-refund path never ran for a process-restart orphan).
|
||||
if purged, err := m.events.PurgeStale(ctx, m.stalePending, m.stalePendingVid); err != nil {
|
||||
@@ -219,13 +90,6 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
||||
if m.inflight != nil && m.inflight.Cancel(e.ID) {
|
||||
cancelled++
|
||||
}
|
||||
// Attribute the abandoned failure back to the account it was using
|
||||
// (the normal markTokenFailure path never ran for an orphaned job).
|
||||
if e.AccountID != "" {
|
||||
if err := m.tokens.IncrementFail(ctx, e.AccountID); err != nil {
|
||||
log.Printf("maintenance: fail-count abandoned event %s (account %s): %v", e.ID, e.AccountID, err)
|
||||
}
|
||||
}
|
||||
if e.UserID == "" || e.Cost <= 0 {
|
||||
continue
|
||||
}
|
||||
@@ -248,10 +112,11 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
||||
log.Printf("maintenance: marked %d stale pending event(s) failed, refunded %d, cancelled %d in-flight", len(purged), refunded, cancelled)
|
||||
}
|
||||
|
||||
// 4. Enforce the admin-configured log retention window.
|
||||
// 2. Enforce the admin-configured log retention window.
|
||||
m.pruneLogs(ctx)
|
||||
|
||||
// 5. Enforce the media retention window. Runs every 60s like the log prune;
|
||||
// 3. Enforce the media retention window + sweep expired tmp/ (v1 API job
|
||||
// results) and stale refs/ objects. Runs every 60s like the log prune;
|
||||
// mediaPruneEvery still gates it in case the interval is ever shortened.
|
||||
if time.Since(m.lastMediaPrune) >= m.mediaPruneEvery {
|
||||
m.pruneMedia(ctx)
|
||||
@@ -274,10 +139,11 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
days := m.retentionDays(ctx, "media.retention_days")
|
||||
if days <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
||||
// tmp/ holds /v1 job results: presigned for 30 min, pruned shortly after.
|
||||
tmpCutoff := time.Now().Add(-40 * time.Minute)
|
||||
// <uid>/refs/ holds uploaded reference material: live for the presign TTL.
|
||||
refCutoff := time.Now().Add(-2 * PresignTTL)
|
||||
objs, err := m.store.List(ctx, "")
|
||||
if err != nil {
|
||||
log.Printf("maintenance: list media: %v", err)
|
||||
@@ -303,10 +169,20 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
|
||||
removed, skipped := 0, 0
|
||||
var clearedKeys []string
|
||||
for _, o := range objs {
|
||||
if !o.LastModified.Before(cutoff) {
|
||||
key := strings.TrimLeft(o.Key, "/")
|
||||
expired := false
|
||||
switch {
|
||||
case strings.HasPrefix(key, "tmp/"):
|
||||
expired = o.LastModified.Before(tmpCutoff)
|
||||
case strings.Contains(key, "/refs/"):
|
||||
expired = o.LastModified.Before(refCutoff)
|
||||
case days > 0:
|
||||
expired = o.LastModified.Before(cutoff)
|
||||
}
|
||||
if !expired {
|
||||
continue
|
||||
}
|
||||
if _, ok := pinned[strings.TrimLeft(o.Key, "/")]; ok {
|
||||
if _, ok := pinned[key]; ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/provider/adobe"
|
||||
"backend/internal/repo"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type RefreshProfileService struct {
|
||||
profiles *repo.RefreshProfileRepository
|
||||
tokens *repo.TokenRepository
|
||||
adobe *adobe.Client
|
||||
}
|
||||
|
||||
func NewRefreshProfileService(profiles *repo.RefreshProfileRepository, tokens *repo.TokenRepository, adobeClient *adobe.Client) *RefreshProfileService {
|
||||
return &RefreshProfileService{
|
||||
profiles: profiles,
|
||||
tokens: tokens,
|
||||
adobe: adobeClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) SetProxy(proxy string) {
|
||||
if s.adobe != nil {
|
||||
s.adobe.SetProxy(proxy)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) List(ctx context.Context) ([]model.RefreshProfile, error) {
|
||||
return s.profiles.List(ctx)
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error {
|
||||
if s.adobe == nil || s.tokens == nil {
|
||||
return errors.New("refresh client not configured")
|
||||
}
|
||||
profile, err := s.profiles.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if profile.Pool != "adobe" || profile.Kind != "adobe_cookie" {
|
||||
return errors.New("unsupported refresh profile")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_, _ = s.profiles.Update(ctx, id, map[string]any{
|
||||
"last_attempt_at": now,
|
||||
})
|
||||
|
||||
result, err := s.adobe.ExchangeCookie(ctx, profile.Cookie)
|
||||
if err != nil {
|
||||
failures := profile.ConsecutiveFailures + 1
|
||||
// Exponential backoff: 60s per consecutive failure, capped at 1h.
|
||||
secs := 60 * failures
|
||||
if secs > 3600 {
|
||||
secs = 3600
|
||||
}
|
||||
msg := err.Error()
|
||||
if len(msg) > 300 {
|
||||
msg = msg[:300]
|
||||
}
|
||||
_, _ = s.profiles.Update(ctx, id, map[string]any{
|
||||
"last_error": msg,
|
||||
"consecutive_failures": failures,
|
||||
"next_retry_at": now.Add(time.Duration(secs) * time.Second),
|
||||
})
|
||||
// Mark the token dead when the cookie exchange keeps failing:
|
||||
// - 5 consecutive failures → cookie is likely expired/revoked
|
||||
// - ride_AdobeID_acct_actreq (Adobe requires account action) → permanently broken
|
||||
if failures >= 5 || strings.Contains(msg, "ride_AdobeID_acct_actreq") {
|
||||
_, _ = s.tokens.Update(ctx, "adobe", id, map[string]any{
|
||||
"status": "disabled",
|
||||
"dead": true,
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
tokenPatch := map[string]any{
|
||||
"value": result.AccessToken,
|
||||
"status": "active",
|
||||
"dead": false,
|
||||
"fails": 0,
|
||||
"updated_at": now,
|
||||
}
|
||||
email, _ := parseJWTEmailExpiry(result.AccessToken)
|
||||
if email != "" {
|
||||
tokenPatch["account_email"] = email
|
||||
}
|
||||
if profile.IntervalSeconds > 0 {
|
||||
tokenPatch["cached_quota_reset_after"] = now.Add(time.Duration(profile.IntervalSeconds) * time.Second).Format(time.RFC3339)
|
||||
} else {
|
||||
tokenPatch["cached_quota_reset_after"] = now.Add(54000 * time.Second).Format(time.RFC3339)
|
||||
}
|
||||
if profileData, profileErr := s.adobe.FetchAccountProfile(ctx, result.AccessToken); profileErr == nil {
|
||||
if email := strings.TrimSpace(stringValue(profileData["email"])); email != "" {
|
||||
tokenPatch["account_email"] = email
|
||||
}
|
||||
if displayName := strings.TrimSpace(stringValue(profileData["display_name"])); displayName != "" {
|
||||
tokenPatch["account_display_name"] = displayName
|
||||
}
|
||||
}
|
||||
planKnown := false
|
||||
if quotaData, quotaErr := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); quotaErr == nil {
|
||||
meta := datatypes.JSONMap{
|
||||
"cached_quota_at": int(time.Now().Unix()),
|
||||
}
|
||||
if remaining, ok := quotaData["remaining"].(int); ok {
|
||||
meta["cached_quota_remaining"] = remaining
|
||||
}
|
||||
if used, ok := quotaData["used"].(int); ok {
|
||||
meta["cached_quota_used"] = used
|
||||
}
|
||||
if total, ok := quotaData["total"].(int); ok {
|
||||
meta["cached_quota_total"] = total
|
||||
}
|
||||
tokenPatch["meta"] = meta
|
||||
|
||||
planCap := strings.ToLower(strings.TrimSpace(stringValue(quotaData["plan"])))
|
||||
isVIP := planCap != "" && !strings.EqualFold(planCap, "free")
|
||||
planKnown = planCap != ""
|
||||
// 额度打到 0 的 VIP(母号/子号) 视为普号:写死 plan=free,额度恢复(>0)后下次刷新自动还原真实身份。
|
||||
if isVIP {
|
||||
if rem, ok := quotaData["remaining"].(int); ok && rem <= 0 {
|
||||
isVIP = false
|
||||
planCap = "free"
|
||||
}
|
||||
}
|
||||
meta["plan"] = planCap
|
||||
|
||||
// VIP: use Adobe's reset time; set concurrency 5.
|
||||
// 母号/子号 身份固定,不随积分动态变化——只有降级普号才刷新身份。
|
||||
if isVIP {
|
||||
if resetAfter := strings.TrimSpace(stringValue(quotaData["available_until"])); resetAfter != "" {
|
||||
tokenPatch["cached_quota_reset_after"] = resetAfter
|
||||
}
|
||||
tokenPatch["concurrency"] = 5
|
||||
// 身份固定:这里重建了整个 meta,若不带上 is_sub_account,刷新会把导入时
|
||||
// 确定的 母号/子号 标记冲掉。身份只在导入时确定,刷新只沿用、绝不靠积分猜:
|
||||
// 已有标记就带过来;取不到(没 meta / 缺字段)说明身份未知,直接置死号。
|
||||
if existing, gerr := s.tokens.Get(ctx, "adobe", id); gerr == nil {
|
||||
if v, ok := existing.Meta["is_sub_account"]; ok {
|
||||
meta["is_sub_account"] = v
|
||||
}
|
||||
}
|
||||
if _, ok := meta["is_sub_account"]; !ok {
|
||||
tokenPatch["status"] = "disabled"
|
||||
tokenPatch["dead"] = true
|
||||
}
|
||||
} else {
|
||||
// Free account: concurrency 1, limit image+video
|
||||
tokenPatch["concurrency"] = 1
|
||||
tokenPatch["image_limited"] = true
|
||||
tokenPatch["video_limited"] = true
|
||||
meta["is_sub_account"] = false
|
||||
}
|
||||
}
|
||||
// 探测不到会员身份(credits 接口失败或没返回 plan)的号既不算普号也不算会员号,
|
||||
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号。
|
||||
if !planKnown {
|
||||
tokenPatch["status"] = "disabled"
|
||||
tokenPatch["dead"] = true
|
||||
}
|
||||
if _, err := s.tokens.Update(ctx, "adobe", id, tokenPatch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
interval := profile.IntervalSeconds
|
||||
if interval <= 0 {
|
||||
interval = 54000
|
||||
}
|
||||
_, err = s.profiles.Update(ctx, id, map[string]any{
|
||||
"last_success_at": now,
|
||||
"next_retry_at": now.Add(time.Duration(interval) * time.Second),
|
||||
"last_error": "",
|
||||
"consecutive_failures": 0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshDue refreshes every enabled profile whose next_retry_at has passed.
|
||||
// Driven by the background maintenance loop so Adobe cookies auto-renew without
|
||||
// an admin clicking "refresh". Individual failures are recorded on the profile
|
||||
// (backoff + dead escalation) and don't abort the sweep.
|
||||
func (s *RefreshProfileService) RefreshDue(ctx context.Context) (int, error) {
|
||||
if s.adobe == nil || s.tokens == nil {
|
||||
return 0, nil
|
||||
}
|
||||
due, err := s.profiles.ListDue(ctx, time.Now())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
refreshed := 0
|
||||
for _, p := range due {
|
||||
if p.Pool != "adobe" || p.Kind != "adobe_cookie" {
|
||||
continue
|
||||
}
|
||||
if err := s.RefreshNow(ctx, p.ID); err != nil {
|
||||
continue
|
||||
}
|
||||
refreshed++
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) Update(ctx context.Context, id string, body map[string]any) (*model.RefreshProfile, error) {
|
||||
patch := map[string]any{}
|
||||
if raw, ok := body["enabled"]; ok {
|
||||
patch["enabled"] = boolValueWithDefault(raw, false)
|
||||
}
|
||||
if raw, ok := body["name"]; ok {
|
||||
patch["name"] = stringValue(raw)
|
||||
}
|
||||
if raw, ok := body["interval_seconds"]; ok {
|
||||
n := intValue(raw)
|
||||
if n <= 0 {
|
||||
return nil, errors.New("interval_seconds must be positive")
|
||||
}
|
||||
patch["interval_seconds"] = n
|
||||
}
|
||||
if len(patch) == 0 {
|
||||
return s.profiles.Get(ctx, id)
|
||||
}
|
||||
return s.profiles.Update(ctx, id, patch)
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) Delete(ctx context.Context, id string) error {
|
||||
return s.profiles.Delete(ctx, id)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,8 +40,8 @@ type UserGenerateRequest struct {
|
||||
// DeAI applies 去AI特征 post-processing to the generated image (image only)
|
||||
// and charges the per-tier surcharge on top of the model price.
|
||||
DeAI bool
|
||||
// AccountID pins an admin test to one specific provider account (账号生图测试).
|
||||
AccountID string
|
||||
// ChannelID pins an admin test to one specific channel (渠道生图测试).
|
||||
ChannelID string
|
||||
}
|
||||
|
||||
func (s *UserGenerationService) Generate(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) {
|
||||
@@ -95,9 +95,10 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
|
||||
}
|
||||
}
|
||||
|
||||
// validateReferenceMode mirrors the /v1 checks: the override must be "frame"
|
||||
// or "asset", the model must support references at all, and frame mode carries
|
||||
// at most 2 images (first+last frame).
|
||||
// validateReferenceMode mirrors the /v1 checks: the override must be "frame",
|
||||
// "asset" or "media", the model must support references at all, and frame mode
|
||||
// carries at most 2 images (first+last frame). "media" = 参考视频/音频 — refs
|
||||
// may be video or audio blobs (vivid-style upstreams that accept all kinds).
|
||||
func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int) error {
|
||||
rm = strings.TrimSpace(rm)
|
||||
if rm == "" {
|
||||
@@ -107,8 +108,8 @@ func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int
|
||||
if supported == "" || supported == "none" {
|
||||
return fmt.Errorf("%w: reference_mode not supported for this model", ErrUnsupportedParams)
|
||||
}
|
||||
if rm != "frame" && rm != "asset" {
|
||||
return fmt.Errorf("%w: reference_mode must be 'frame' or 'asset'", ErrUnsupportedParams)
|
||||
if rm != "frame" && rm != "asset" && rm != "media" {
|
||||
return fmt.Errorf("%w: reference_mode must be 'frame', 'asset' or 'media'", ErrUnsupportedParams)
|
||||
}
|
||||
if rm == "frame" && refCount > 2 {
|
||||
return fmt.Errorf("%w: frame mode supports at most 2 reference images (first+last frame), got %d", ErrUnsupportedParams, refCount)
|
||||
@@ -137,7 +138,7 @@ func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User,
|
||||
AspectRatio: in.Ratio,
|
||||
Resolution: in.Resolution,
|
||||
ReferenceImages: in.ReferenceImages,
|
||||
AccountID: in.AccountID,
|
||||
ChannelID: in.ChannelID,
|
||||
})
|
||||
default:
|
||||
return s.v1.prepareAdminTestImage(ctx, principal, V1ImageRequest{
|
||||
@@ -146,7 +147,7 @@ func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User,
|
||||
AspectRatio: in.Ratio,
|
||||
Resolution: in.Resolution,
|
||||
ReferenceImages: in.ReferenceImages,
|
||||
AccountID: in.AccountID,
|
||||
ChannelID: in.ChannelID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+938
-3165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PresignGet issues a query-string SigV4 URL that lets anyone holding it GET
|
||||
// the object for ttl. Used for /v1/generate result URLs and reference
|
||||
// read-back.
|
||||
func (c *Client) PresignGet(key string, ttl time.Duration) (string, error) {
|
||||
return c.presign(http.MethodGet, key, "", ttl)
|
||||
}
|
||||
|
||||
// PresignPut issues a query-string SigV4 URL bound to PUT with the exact
|
||||
// contentType signed in — the uploader must send that Content-Type verbatim
|
||||
// (same contract vivid upstreams expect on /v1/uploads sessions).
|
||||
func (c *Client) PresignPut(key, contentType string, ttl time.Duration) (string, error) {
|
||||
if strings.TrimSpace(contentType) == "" {
|
||||
return "", fmt.Errorf("presign put requires contentType")
|
||||
}
|
||||
return c.presign(http.MethodPut, key, contentType, ttl)
|
||||
}
|
||||
|
||||
// presign builds an AWS SigV4 presigned URL (UNSIGNED-PAYLOAD). Signed headers
|
||||
// are host (+content-type for PUT). Validity is capped at 7 days by SigV4.
|
||||
func (c *Client) presign(method, key, contentType string, ttl time.Duration) (string, error) {
|
||||
if !c.Configured() {
|
||||
return "", fmt.Errorf("rustfs not configured")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
if ttl > 7*24*time.Hour {
|
||||
ttl = 7 * 24 * time.Hour
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
scope := dateStamp + "/" + region + "/" + service + "/aws4_request"
|
||||
|
||||
signedHeaders := "host"
|
||||
if contentType != "" {
|
||||
signedHeaders = "content-type;host"
|
||||
}
|
||||
|
||||
q := map[string]string{
|
||||
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
|
||||
"X-Amz-Credential": c.ak + "/" + scope,
|
||||
"X-Amz-Date": amzDate,
|
||||
"X-Amz-Expires": strconv.FormatInt(int64(ttl.Seconds()), 10),
|
||||
"X-Amz-SignedHeaders": signedHeaders,
|
||||
}
|
||||
canonicalURI := "/" + uriEncode(c.bucket+"/"+strings.TrimPrefix(key, "/"), true)
|
||||
canonicalQuery := canonicalQueryString(q)
|
||||
|
||||
var canonHeaders strings.Builder
|
||||
if contentType != "" {
|
||||
canonHeaders.WriteString("content-type:" + contentType + "\n")
|
||||
}
|
||||
canonHeaders.WriteString("host:" + c.host + "\n")
|
||||
|
||||
canonicalRequest := strings.Join([]string{
|
||||
method, canonicalURI, canonicalQuery, canonHeaders.String(), signedHeaders, "UNSIGNED-PAYLOAD",
|
||||
}, "\n")
|
||||
stringToSign := strings.Join([]string{
|
||||
"AWS4-HMAC-SHA256", amzDate, scope, hexSHA256([]byte(canonicalRequest)),
|
||||
}, "\n")
|
||||
sig := hex.EncodeToString(hmacSHA256(signingKey(c.sk, dateStamp), []byte(stringToSign)))
|
||||
|
||||
// Rebuild the query with url.Values so the signature chars get escaped
|
||||
// correctly, then append X-Amz-Signature verbatim (hex, safe).
|
||||
vals := url.Values{}
|
||||
for k, v := range q {
|
||||
vals.Set(k, v)
|
||||
}
|
||||
return c.endpoint + canonicalURI + "?" + vals.Encode() + "&X-Amz-Signature=" + sig, nil
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
// Package upstream is the vivid-protocol client used to talk to channel
|
||||
// upstreams (other same-protocol instances). It covers the full public
|
||||
// surface: POST /v1/generate (NDJSON stream with jobId reconnect), POST
|
||||
// /v1/uploads (presigned reference upload), GET /v1/models, GET /v1/balance,
|
||||
// plus presigned-URL result download.
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Error classes. Dispatch decisions (retry / fail over / fail the job) key off
|
||||
// these sentinels via errors.Is — KindOf maps them for log messages.
|
||||
var (
|
||||
// ErrAuth = channel credentials rejected (4011 / HTTP 401 / 403). The
|
||||
// channel is skipped without consuming a retry budget.
|
||||
ErrAuth = errors.New("upstream auth rejected")
|
||||
// ErrInvalid = upstream rejected the request itself (4001 / HTTP 4xx).
|
||||
// Retrying on another channel would fail identically — terminal.
|
||||
ErrInvalid = errors.New("upstream rejected request")
|
||||
// ErrJobFailed = the upstream ran the job and returned a failed frame.
|
||||
// The frame's error text is user-safe and surfaced as-is.
|
||||
ErrJobFailed = errors.New("upstream job failed")
|
||||
// ErrRetryable = transient upstream failure (4293 / 5001 / 5xx / quota
|
||||
// / timeout / transport). Fail over to the next channel.
|
||||
ErrRetryable = errors.New("upstream temporary failure")
|
||||
// ErrAmbiguous = the generate request was sent but the stream dropped
|
||||
// before any jobId arrived — the job may or may not exist upstream.
|
||||
// Treated as retryable for dispatch purposes.
|
||||
ErrAmbiguous = errors.New("upstream submission ambiguous")
|
||||
)
|
||||
|
||||
// Kind is the coarse dispatch classification of an upstream error.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
KindRetryable Kind = iota
|
||||
KindAuth
|
||||
KindInvalid
|
||||
KindJobFailed
|
||||
)
|
||||
|
||||
// KindOf classifies an error returned by this package.
|
||||
func KindOf(err error) Kind {
|
||||
switch {
|
||||
case errors.Is(err, ErrAuth):
|
||||
return KindAuth
|
||||
case errors.Is(err, ErrInvalid):
|
||||
return KindInvalid
|
||||
case errors.Is(err, ErrJobFailed):
|
||||
return KindJobFailed
|
||||
default:
|
||||
return KindRetryable
|
||||
}
|
||||
}
|
||||
|
||||
// Failoverable reports whether the request should be re-tried on another
|
||||
// channel. ErrInvalid and ErrJobFailed are deterministic — same request, same
|
||||
// result — so they are terminal.
|
||||
func Failoverable(err error) bool {
|
||||
return !errors.Is(err, ErrInvalid) && !errors.Is(err, ErrJobFailed)
|
||||
}
|
||||
|
||||
// GenParams is the POST /v1/generate request body.
|
||||
type GenParams struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Ratio string `json:"ratio,omitempty"`
|
||||
Duration int64 `json:"duration,omitempty"`
|
||||
Refs []string `json:"refs,omitempty"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
}
|
||||
|
||||
// RemoteModel mirrors one item of a vivid GET /v1/models response.
|
||||
type RemoteModel struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Qualities []string `json:"qualities"`
|
||||
Duration *RemoteDuration `json:"duration,omitempty"`
|
||||
// Per-kind reference caps; 0 = that kind is not accepted.
|
||||
RefImages int64 `json:"refImages"`
|
||||
RefVideos int64 `json:"refVideos"`
|
||||
RefAudios int64 `json:"refAudios"`
|
||||
// Combined reference duration caps in seconds; 0 = unlimited.
|
||||
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
|
||||
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
|
||||
}
|
||||
|
||||
// RemoteDuration mirrors the duration spec of a vivid model item.
|
||||
type RemoteDuration struct {
|
||||
Mode string `json:"mode"` // fixed | options | range
|
||||
Seconds int64 `json:"seconds,omitempty"`
|
||||
Min int64 `json:"min,omitempty"`
|
||||
Max int64 `json:"max,omitempty"`
|
||||
Options []int64 `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// Client is stateless; per-call baseURL/key come from the channel row.
|
||||
type Client struct {
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
return &Client{HTTP: &http.Client{Timeout: 30 * time.Minute}}
|
||||
}
|
||||
|
||||
func (c *Client) http() *http.Client {
|
||||
if c != nil && c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return NewClient().HTTP
|
||||
}
|
||||
|
||||
// maxStreamFailures caps reconnect attempts after a jobId is known — each
|
||||
// reconnect re-opens /v1/generate with {jobId} and never double-charges.
|
||||
const maxStreamFailures = 3
|
||||
|
||||
// Generate submits a vivid job and reads the NDJSON stream to a terminal
|
||||
// frame, reconnecting with the reported jobId when the stream drops mid-run
|
||||
// (upstream heartbeat keeps us attached; transport blips don't lose the job).
|
||||
// onJobID, when non-nil, fires once the upstream assigns a job id. Returns the
|
||||
// presigned result URL and the credits the upstream reports charging us.
|
||||
func (c *Client) Generate(ctx context.Context, baseURL, apiKey string, p GenParams, waitSeconds int64, onJobID func(string)) (resultURL string, credits int64, err error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return "", 0, ErrAuth
|
||||
}
|
||||
var last map[string]any
|
||||
failures := 0
|
||||
for {
|
||||
term, ferr := c.streamOnce(ctx, baseURL, apiKey, &p, waitSeconds, onJobID)
|
||||
if ctx.Err() != nil {
|
||||
return "", 0, ctx.Err()
|
||||
}
|
||||
if ferr != nil && !errors.Is(ferr, ErrRetryable) && !errors.Is(ferr, ErrAmbiguous) {
|
||||
return "", 0, ferr
|
||||
}
|
||||
if ferr != nil {
|
||||
failures++
|
||||
} else {
|
||||
failures = 0
|
||||
}
|
||||
if ferr == nil && term != nil {
|
||||
status := strings.ToLower(strings.TrimSpace(str(term["status"])))
|
||||
if status != "running" && status != "" {
|
||||
last = term
|
||||
break
|
||||
}
|
||||
}
|
||||
if p.JobID == "" {
|
||||
if ferr != nil {
|
||||
return "", 0, ferr
|
||||
}
|
||||
return "", 0, ErrAmbiguous
|
||||
}
|
||||
if failures >= maxStreamFailures {
|
||||
if ferr != nil {
|
||||
return "", 0, ferr
|
||||
}
|
||||
return "", 0, fmt.Errorf("%w: stream dropped repeatedly", ErrRetryable)
|
||||
}
|
||||
if sleepCtx(ctx, 2*time.Second) != nil {
|
||||
return "", 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(str(last["status"])))
|
||||
switch status {
|
||||
case "succeeded", "completed", "success":
|
||||
resultURL = strings.TrimSpace(str(last["result"]))
|
||||
if resultURL == "" {
|
||||
return "", 0, fmt.Errorf("%w: succeeded frame missing result", ErrRetryable)
|
||||
}
|
||||
return resultURL, int64(num(last["creditsCharged"])), nil
|
||||
default:
|
||||
reason := strings.TrimSpace(str(last["error"]))
|
||||
if reason == "" {
|
||||
reason = "upstream job " + status
|
||||
}
|
||||
if isCreditError(reason) {
|
||||
return "", 0, fmt.Errorf("%w: %s", ErrRetryable, clip([]byte(reason), 160))
|
||||
}
|
||||
// failed 帧的 error 是上游给用户看的中文文案 — 透传
|
||||
return "", 0, fmt.Errorf("%w: %s", ErrJobFailed, clip([]byte(reason), 160))
|
||||
}
|
||||
}
|
||||
|
||||
// streamOnce performs one /v1/generate call and scans the NDJSON body. It
|
||||
// records the upstream jobId into p.JobID as soon as a frame carries it.
|
||||
func (c *Client) streamOnce(ctx context.Context, baseURL, apiKey string, p *GenParams, waitSeconds int64, onJobID func(string)) (map[string]any, error) {
|
||||
raw, _ := json.Marshal(p)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/generate", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if waitSeconds <= 0 {
|
||||
waitSeconds = 300
|
||||
}
|
||||
req.Header.Set("X-Wait-Seconds", fmt.Sprintf("%d", waitSeconds))
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
if p.JobID == "" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrAmbiguous, sanitizeErr(err))
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, vividError(resp.StatusCode, body)
|
||||
}
|
||||
var last map[string]any
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for sc.Scan() {
|
||||
line := bytes.TrimSpace(sc.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue // 25s heartbeat empty line
|
||||
}
|
||||
var frame map[string]any
|
||||
if json.Unmarshal(line, &frame) != nil {
|
||||
continue
|
||||
}
|
||||
last = frame
|
||||
if job := strings.TrimSpace(str(frame["jobId"])); job != "" && job != p.JobID {
|
||||
p.JobID = job
|
||||
if onJobID != nil {
|
||||
onJobID(job)
|
||||
}
|
||||
}
|
||||
if status := strings.ToLower(strings.TrimSpace(str(frame["status"]))); status == "succeeded" || status == "failed" {
|
||||
return frame, nil
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
if p.JobID == "" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrAmbiguous, sanitizeErr(err))
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
// UploadRef runs the two-step reference upload: POST /v1/uploads {kind,mime}
|
||||
// then PUT the bytes to the presigned url with the exact signed Content-Type.
|
||||
// Returns the object key for refs[].
|
||||
func (c *Client) UploadRef(ctx context.Context, baseURL, apiKey string, data []byte) (string, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return "", ErrAuth
|
||||
}
|
||||
kind, mime := RefKindMime(data)
|
||||
if kind == "" {
|
||||
return "", fmt.Errorf("%w: 参考素材格式不支持", ErrInvalid)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"kind": kind, "mime": mime})
|
||||
up, err := c.postJSON(ctx, baseURL+"/v1/uploads", apiKey, raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
putURL, key := UploadSession(up)
|
||||
if putURL == "" || key == "" {
|
||||
rawUp, _ := json.Marshal(up)
|
||||
return "", fmt.Errorf("%w: upload session missing url/key (got %s)", ErrRetryable, clip(rawUp, 200))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, putURL, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", mime) // must match the signed mime exactly
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("%w: ref upload %d %s", ErrRetryable, resp.StatusCode, clip(body, 120))
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// UploadSession extracts the presigned PUT url + object key from a
|
||||
// /v1/uploads response. Implementations vary: some return the pair at top
|
||||
// level, others wrap it in {"data":{...}} / {"result":{...}}, and field
|
||||
// spellings differ (upload_url / presigned_url / put_url; key / file_key /
|
||||
// object_key). Search top level first, then one layer deep.
|
||||
func UploadSession(up map[string]any) (putURL, key string) {
|
||||
// Go map 查找区分大小写,而上游实现可能返回 PascalCase 字段;按去分隔符+
|
||||
// 小写归一化匹配。
|
||||
urlKeys := map[string]bool{"url": true, "uploadurl": true, "presignedurl": true, "puturl": true, "signedurl": true}
|
||||
keyKeys := map[string]bool{"key": true, "filekey": true, "objectkey": true, "uploadkey": true}
|
||||
norm := func(s string) string { return strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(s)) }
|
||||
pick := func(m map[string]any) (string, string) {
|
||||
var u, k string
|
||||
for name, v := range m {
|
||||
s := strings.TrimSpace(str(v))
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case u == "" && urlKeys[norm(name)]:
|
||||
u = s
|
||||
case k == "" && keyKeys[norm(name)]:
|
||||
k = s
|
||||
}
|
||||
}
|
||||
return u, k
|
||||
}
|
||||
levels := []map[string]any{up}
|
||||
for _, nest := range []string{"data", "result", "upload", "session", "upload_session"} {
|
||||
if sub, ok := up[nest].(map[string]any); ok {
|
||||
levels = append(levels, sub)
|
||||
}
|
||||
}
|
||||
for _, m := range levels {
|
||||
u, k := pick(m)
|
||||
if putURL == "" {
|
||||
putURL = u
|
||||
}
|
||||
if key == "" {
|
||||
key = k
|
||||
}
|
||||
}
|
||||
return putURL, key
|
||||
}
|
||||
|
||||
// Models fetches the upstream's sellable model list (GET /v1/models → bare
|
||||
// array of RemoteModel).
|
||||
func (c *Client) Models(ctx context.Context, baseURL, apiKey string) ([]RemoteModel, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/models", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, vividError(resp.StatusCode, body)
|
||||
}
|
||||
var items []RemoteModel
|
||||
if err := json.Unmarshal(body, &items); err != nil {
|
||||
return nil, fmt.Errorf("%w: bad /v1/models body", ErrRetryable)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Balance fetches the upstream balance (GET /v1/balance → {"balance":N}).
|
||||
// Returns nil when the upstream does not implement the endpoint.
|
||||
func (c *Client) Balance(ctx context.Context, baseURL, apiKey string) (*int64, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/balance", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, vividError(resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Balance *int64 `json:"balance"`
|
||||
}
|
||||
if json.Unmarshal(body, &env) != nil || env.Balance == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return env.Balance, nil
|
||||
}
|
||||
|
||||
// Download fetches a presigned result URL — no Authorization header.
|
||||
func (c *Client) Download(ctx context.Context, url string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("%w: result download %d", ErrRetryable, resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "", fmt.Errorf("%w: empty result", ErrRetryable)
|
||||
}
|
||||
ct := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
||||
if ct == "" || ct == "application/octet-stream" {
|
||||
ct = http.DetectContentType(data)
|
||||
}
|
||||
return data, ct, nil
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, url, apiKey string, body []byte) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, vividError(resp.StatusCode, raw)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%w: bad response %s", ErrRetryable, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// vividError maps the {"error":{"code":"NNNN","message":...}} envelope to our
|
||||
// error classes; falls back to plain HTTP status mapping.
|
||||
func vividError(status int, body []byte) error {
|
||||
var e struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
code := ""
|
||||
msg := clip(body, 160)
|
||||
if json.Unmarshal(body, &e) == nil {
|
||||
code = strings.TrimSpace(e.Error.Code)
|
||||
if m := strings.TrimSpace(e.Error.Message); m != "" {
|
||||
msg = clip([]byte(m), 160)
|
||||
}
|
||||
}
|
||||
switch code {
|
||||
case "4011":
|
||||
return fmt.Errorf("%w: %s", ErrAuth, msg)
|
||||
case "4002":
|
||||
return fmt.Errorf("%w: 积分不足 %s", ErrRetryable, msg)
|
||||
case "4293", "5001":
|
||||
return fmt.Errorf("%w: %s", ErrRetryable, msg)
|
||||
case "4001":
|
||||
return fmt.Errorf("%w: %s", ErrInvalid, msg)
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
return fmt.Errorf("%w: http %d", ErrAuth, status)
|
||||
case status == http.StatusTooManyRequests || status >= 500:
|
||||
return fmt.Errorf("%w: http %d %s", ErrRetryable, status, msg)
|
||||
default:
|
||||
return fmt.Errorf("%w: http %d %s", ErrInvalid, status, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// RefKindMime classifies a reference blob into the vivid "kind" (image /
|
||||
// video / audio) plus a concrete mime. Magic bytes first (DetectContentType
|
||||
// misses mov/m4a/ogg-audio), then sniffing, then a png default.
|
||||
func RefKindMime(data []byte) (kind, mime string) {
|
||||
n := len(data)
|
||||
// ISOBMFF box: ftyp — mp4/mov/m4a share the container; the brand decides.
|
||||
if n >= 12 && string(data[4:8]) == "ftyp" {
|
||||
brand := string(data[8:12])
|
||||
switch {
|
||||
case strings.HasPrefix(brand, "M4A"), strings.HasPrefix(brand, "M4B"), strings.HasPrefix(brand, "F4A"), hasBMFFHandler(data, "soun") && !hasBMFFHandler(data, "vide"):
|
||||
return "audio", "audio/mp4"
|
||||
case brand == "qt ":
|
||||
return "video", "video/quicktime"
|
||||
default:
|
||||
return "video", "video/mp4"
|
||||
}
|
||||
}
|
||||
// WebM/MKV EBML
|
||||
if n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3 && bytes.Contains(bytes.ToLower(data[:minInt(n, 4096)]), []byte("webm")) {
|
||||
return "video", "video/webm"
|
||||
}
|
||||
// MP3: ID3 tag or MPEG sync
|
||||
if n >= 3 && string(data[:3]) == "ID3" {
|
||||
return "audio", "audio/mpeg"
|
||||
}
|
||||
if n >= 4 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0 &&
|
||||
(data[1]>>3)&3 != 1 && (data[1]>>1)&3 != 0 && data[2]>>4 != 0 && data[2]>>4 != 15 && (data[2]>>2)&3 != 3 {
|
||||
return "audio", "audio/mpeg"
|
||||
}
|
||||
// WAV / OGG
|
||||
if n >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WAVE" {
|
||||
return "audio", "audio/wav"
|
||||
}
|
||||
if n >= 27 && string(data[:4]) == "OggS" && (bytes.Contains(data[:minInt(n, 4096)], []byte("OpusHead")) ||
|
||||
bytes.Contains(data[:minInt(n, 4096)], []byte("vorbis")) || bytes.Contains(data[:minInt(n, 4096)], []byte("Speex ")) ||
|
||||
bytes.Contains(data[:minInt(n, 4096)], []byte("fLaC"))) {
|
||||
return "audio", "audio/ogg"
|
||||
}
|
||||
if m := http.DetectContentType(data); m == "image/png" || m == "image/jpeg" || m == "image/webp" {
|
||||
return "image", m
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func hasBMFFHandler(data []byte, handler string) bool {
|
||||
data = data[:minInt(len(data), 4096)]
|
||||
for offset := 0; offset < len(data); {
|
||||
i := bytes.Index(data[offset:], []byte("hdlr"))
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
i += offset
|
||||
if i+16 <= len(data) && string(data[i+12:i+16]) == handler {
|
||||
return true
|
||||
}
|
||||
offset = i + 4
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isCreditError(s string) bool {
|
||||
m := strings.ToLower(s)
|
||||
return strings.Contains(m, "credit") || strings.Contains(m, "quota") ||
|
||||
strings.Contains(m, "insufficient") || strings.Contains(s, "积分不足") ||
|
||||
strings.Contains(s, "余额")
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case fmt.Stringer:
|
||||
return t.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func num(v any) float64 {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case int64:
|
||||
return float64(t)
|
||||
case json.Number:
|
||||
f, _ := t.Float64()
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// sanitizeErr strips the upstream URL/host from a network error so a channel's
|
||||
// private upstream URL never leaks into the event log / API response.
|
||||
func sanitizeErr(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
s := err.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"):
|
||||
return "request timeout"
|
||||
case strings.Contains(s, "connection refused"):
|
||||
return "connection refused"
|
||||
case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "):
|
||||
return "cannot reach upstream"
|
||||
case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"):
|
||||
return "TLS error"
|
||||
case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"):
|
||||
return "connection reset"
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return strings.ToLower(ue.Op) + " upstream failed"
|
||||
}
|
||||
return "upstream request failed"
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
)
|
||||
|
||||
// maxMediaDurationSec 时长 sanity 上限:解析出超过 24h 视为垃圾数据丢弃。
|
||||
const maxMediaDurationSec = 24 * 3600
|
||||
|
||||
// MediaDurationSeconds 从已读片段尽力解析媒体时长(秒)。
|
||||
// mp4/mov/m4a 扫描 mvhd 盒子;webm 解 EBML Duration×TimecodeScale;wav 按
|
||||
// byteRate 换算。解析不出返回 false——调用方可用文件尾片段再试一次
|
||||
// (未 faststart 的 mp4 moov 盒子常在文件尾)。mp3/ogg 无廉价可靠头长度,跳过。
|
||||
func MediaDurationSeconds(mime string, buf []byte) (float64, bool) {
|
||||
if len(buf) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
var sec float64
|
||||
var ok bool
|
||||
switch mime {
|
||||
case "video/mp4", "video/quicktime", "audio/mp4":
|
||||
sec, ok = mvhdDuration(buf)
|
||||
case "video/webm":
|
||||
sec, ok = webmDuration(buf)
|
||||
case "audio/wav":
|
||||
sec, ok = wavDuration(buf)
|
||||
}
|
||||
if !ok || sec <= 0 || sec > maxMediaDurationSec {
|
||||
return 0, false
|
||||
}
|
||||
return sec, true
|
||||
}
|
||||
|
||||
// mvhdDuration 在缓冲里扫 "mvhd" 盒子并解析 duration/timescale。
|
||||
// 盒子布局:[size4][type4=mvhd][version1][flags3]
|
||||
// v0: creation4 mod4 timescale4 duration4 → timescale@idx+16, duration@idx+20
|
||||
// v1: creation8 mod8 timescale4 duration8 → timescale@idx+24, duration@idx+28
|
||||
func mvhdDuration(buf []byte) (float64, bool) {
|
||||
for off := 0; ; {
|
||||
i := bytes.Index(buf[off:], []byte("mvhd"))
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
i += off
|
||||
if ver := buf[i+4]; ver == 0 && i+24 <= len(buf) {
|
||||
ts := binary.BigEndian.Uint32(buf[i+16 : i+20])
|
||||
dur := binary.BigEndian.Uint32(buf[i+20 : i+24])
|
||||
if ts > 0 && dur > 0 {
|
||||
return float64(dur) / float64(ts), true
|
||||
}
|
||||
} else if ver == 1 && i+36 <= len(buf) {
|
||||
ts := binary.BigEndian.Uint32(buf[i+24 : i+28])
|
||||
dur := binary.BigEndian.Uint64(buf[i+28 : i+36])
|
||||
if ts > 0 && dur > 0 {
|
||||
return float64(dur) / float64(ts), true
|
||||
}
|
||||
}
|
||||
off = i + 4
|
||||
}
|
||||
}
|
||||
|
||||
// webmDuration 扫 EBML Duration 元素(0x4489,float)和 TimecodeScale
|
||||
// (0x2AD7B1,uint,缺省 1,000,000ns)。秒 = duration × scale / 1e9。
|
||||
func webmDuration(buf []byte) (float64, bool) {
|
||||
scale := uint64(1_000_000)
|
||||
if i := bytes.Index(buf, []byte{0x2a, 0xd7, 0xb1}); i >= 0 {
|
||||
if v, n := ebmlVint(buf[i+3:]); n > 0 && i+3+n+v <= len(buf) {
|
||||
scale = 0
|
||||
for _, b := range buf[i+3+n : i+3+n+v] {
|
||||
scale = scale<<8 | uint64(b)
|
||||
}
|
||||
if scale == 0 {
|
||||
scale = 1_000_000
|
||||
}
|
||||
}
|
||||
}
|
||||
i := bytes.Index(buf, []byte{0x44, 0x89})
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
v, n := ebmlVint(buf[i+2:])
|
||||
if n == 0 || i+2+n+v > len(buf) {
|
||||
return 0, false
|
||||
}
|
||||
f := buf[i+2+n : i+2+n+v]
|
||||
var dur float64
|
||||
switch v {
|
||||
case 4:
|
||||
dur = float64(math.Float32frombits(binary.BigEndian.Uint32(f)))
|
||||
case 8:
|
||||
dur = math.Float64frombits(binary.BigEndian.Uint64(f))
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
return dur * float64(scale) / 1e9, true
|
||||
}
|
||||
|
||||
// ebmlVint 读 EBML 变长整数的长度值:返回 (负载长度, vint 本身字节数)。
|
||||
func ebmlVint(buf []byte) (int, int) {
|
||||
if len(buf) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
for n := 1; n <= 8; n++ {
|
||||
mask := byte(0x80 >> (n - 1))
|
||||
if buf[0]&mask != 0 {
|
||||
if len(buf) < n {
|
||||
return 0, 0
|
||||
}
|
||||
v := int(buf[0] &^ mask)
|
||||
for i := 1; i < n; i++ {
|
||||
v = v<<8 | int(buf[i])
|
||||
}
|
||||
return v, n
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// wavDuration 解析 RIFF/WAVE:byteRate@28(LE u32) + "data" 块大小换算秒数。
|
||||
func wavDuration(buf []byte) (float64, bool) {
|
||||
if len(buf) < 44 || string(buf[:4]) != "RIFF" || string(buf[8:12]) != "WAVE" {
|
||||
return 0, false
|
||||
}
|
||||
rate := binary.LittleEndian.Uint32(buf[28:32])
|
||||
if rate == 0 {
|
||||
return 0, false
|
||||
}
|
||||
for off := 12; off+8 <= len(buf); {
|
||||
size := int(binary.LittleEndian.Uint32(buf[off+4 : off+8]))
|
||||
if string(buf[off:off+4]) == "data" {
|
||||
return float64(size) / float64(rate), true
|
||||
}
|
||||
off += 8 + size + size&1 // 块按 2 字节对齐
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = defineProps({ account: { type: Object, required: true } })
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
// concurrency is only adjustable for custom upstreams — others are system-fixed
|
||||
// (adobe is set from the plan on every quota refresh: 会员号 5 / 普号 1).
|
||||
const canEditConcurrency = props.account.type === 'custom'
|
||||
const fixedConcurrency =
|
||||
props.account.type === 'adobe'
|
||||
? (props.account.plan === 'free' ? 1 : Number(props.account.concurrency) || 5)
|
||||
: props.account.type === 'grok'
|
||||
? 10
|
||||
: 1
|
||||
|
||||
const weight = ref(Number(props.account.weight) || 0)
|
||||
const concurrency = ref(Number(props.account.concurrency) || 1)
|
||||
const status = ref('')
|
||||
const isError = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true; status.value = ''; isError.value = false
|
||||
const payload = { weight: Number(weight.value) || 0 }
|
||||
if (canEditConcurrency) payload.concurrency = Math.max(1, Number(concurrency.value) || 1)
|
||||
try {
|
||||
const r = await api(`/tokens/${props.account.pool}/${props.account.id}`, jsonBody('PATCH', payload))
|
||||
if (r.ok) {
|
||||
status.value = '✓ 已保存'; emit('saved', payload)
|
||||
setTimeout(() => emit('close'), 600)
|
||||
} else {
|
||||
status.value = r.data?.detail || '保存失败'; isError.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
status.value = String(e); isError.value = true
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-md">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">编辑账号</h2>
|
||||
<button @click="emit('close')" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<div>
|
||||
<label class="lbl">账户</label>
|
||||
<input :value="account.email || account.id" disabled class="field font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">类型</label>
|
||||
<input :value="account.type" disabled class="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">权重 <span class="text-white/35">(高的优先)</span></label>
|
||||
<input v-model.number="weight" type="number" class="field" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">并发数 <span class="text-white/35">{{ canEditConcurrency ? '(单账号)' : '(系统固定,不可调整)' }}</span></label>
|
||||
<input v-if="canEditConcurrency" v-model.number="concurrency" type="number" min="1" class="field" placeholder="1" />
|
||||
<input v-else :value="fixedConcurrency" disabled class="field" />
|
||||
</div>
|
||||
<p v-if="status" class="text-xs" :class="isError ? 'text-rose-400' : 'text-emerald-400'">{{ status }}</p>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button @click="emit('close')" class="btn-soft">取消</button>
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary">{{ submitting ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* mirror the 用户管理 edit modal so the two are visually identical */
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
/* disabled inputs — readable, but visually 'cool' so the admin knows they
|
||||
can't change them (matches UsersView). */
|
||||
.field:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
}
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
import SelectMenu from './SelectMenu.vue'
|
||||
|
||||
// 账号生成测试:固定使用某个 provider 账号跑一次生成(图像或视频)。
|
||||
// 模型列表由 AccountsView 打开页面时预加载好传入,弹窗即开即用。
|
||||
const props = defineProps({
|
||||
account: { type: Object, required: true }, // { pool, id, email, type }
|
||||
allModels: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 该账号所属 provider 的全部模型(图像 + 视频)。
|
||||
const models = computed(() =>
|
||||
props.allModels.filter((m) => (m.provider || '') === props.account.pool))
|
||||
const selectedModel = ref('')
|
||||
if (models.value.length) selectedModel.value = models.value[0].alias || models.value[0].id
|
||||
|
||||
const modelOptions = computed(() => models.value.map((m) => ({
|
||||
value: m.alias || m.id,
|
||||
label: `${m.alias || m.id}${m.type === 'video' ? ' · 视频' : ''}`,
|
||||
})))
|
||||
const currentModel = computed(() =>
|
||||
models.value.find((m) => (m.alias || m.id) === selectedModel.value) || null)
|
||||
const isVideo = computed(() => currentModel.value?.type === 'video')
|
||||
|
||||
const prompt = ref('a cute cat sitting on a desk, studio lighting')
|
||||
|
||||
const busy = ref(false)
|
||||
const status = ref('')
|
||||
const error = ref('')
|
||||
const resultUrl = ref('')
|
||||
const resultKind = ref('')
|
||||
|
||||
// Gateway-timeout recovery — same policy as TestModal: the backend detaches the
|
||||
// render from the request, so on a 524/504 we keep polling /jobs/mine?source=admin.
|
||||
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
|
||||
let recoverTimer = null
|
||||
let recoverJobId = ''
|
||||
let recoverSubmitTs = 0
|
||||
onUnmounted(() => clearTimeout(recoverTimer))
|
||||
|
||||
function firstDuration(m) {
|
||||
const keys = Object.keys(m?.duration_prices || {})
|
||||
.sort((a, b) => parseFloat(a) - parseFloat(b))
|
||||
return keys[0] || '5s'
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!selectedModel.value) { error.value = '请选择模型'; return }
|
||||
if (!prompt.value.trim()) { error.value = '请输入指令'; return }
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
status.value = isVideo.value ? '正在生成视频 (约 1–3 分钟)…' : '正在生成…'
|
||||
resultUrl.value = ''
|
||||
resultKind.value = ''
|
||||
const m = currentModel.value
|
||||
const payload = {
|
||||
model: selectedModel.value,
|
||||
prompt: prompt.value,
|
||||
ratio: (m?.ratios && m.ratios[0]) || (isVideo.value ? '16x9' : '1:1'),
|
||||
resolution: (m?.resolutions && m.resolutions[0]) || '',
|
||||
account_id: props.account.id,
|
||||
}
|
||||
if (isVideo.value) payload.duration = firstDuration(m)
|
||||
recoverSubmitTs = Date.now()
|
||||
const r = await api('/test', jsonBody('POST', payload))
|
||||
if (r.ok && r.data?.url) {
|
||||
busy.value = false
|
||||
resultUrl.value = r.data.url
|
||||
resultKind.value = r.data.kind || (isVideo.value ? 'video' : 'image')
|
||||
status.value = `完成 · ${r.data.provider} · ${(r.data.elapsed_ms / 1000).toFixed(1)}s`
|
||||
} else if (GATEWAY_TIMEOUT.has(r.status)) {
|
||||
status.value = isVideo.value ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
recoverJobId = ''
|
||||
clearTimeout(recoverTimer)
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
} else {
|
||||
busy.value = false
|
||||
status.value = ''
|
||||
error.value = r.data?.detail || `失败 (${r.status})`
|
||||
}
|
||||
}
|
||||
|
||||
async function recover() {
|
||||
const r = await api('/jobs/mine?source=admin')
|
||||
if (!r.ok) { recoverTimer = setTimeout(recover, 3000); return }
|
||||
const { pending, latest } = r.data || {}
|
||||
if (pending) {
|
||||
recoverJobId = pending.id
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
return
|
||||
}
|
||||
const mine = latest && (
|
||||
(recoverJobId && latest.id === recoverJobId) ||
|
||||
(!recoverJobId && latest.ts && latest.ts * 1000 >= recoverSubmitTs - 2000)
|
||||
)
|
||||
if (mine && latest.status === 'success' && latest.url) {
|
||||
busy.value = false
|
||||
resultUrl.value = latest.url
|
||||
resultKind.value = latest.kind || (isVideo.value ? 'video' : 'image')
|
||||
status.value = `完成 · ${(latest.elapsed_ms / 1000).toFixed(1)}s`
|
||||
return
|
||||
}
|
||||
if (mine && latest.status === 'failed') {
|
||||
busy.value = false
|
||||
status.value = ''
|
||||
error.value = latest.error || '生成失败'
|
||||
return
|
||||
}
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-lg">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-semibold">账号生成测试</h2>
|
||||
<div class="text-xs text-white/45 font-mono truncate">{{ account.email || account.id }} · {{ account.pool }}</div>
|
||||
</div>
|
||||
<button @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="lbl">模型</label>
|
||||
<div v-if="!models.length" class="text-xs text-amber-300">该账号所属 provider ({{ account.pool }}) 没有可用的模型</div>
|
||||
<SelectMenu v-else v-model="selectedModel" :options="modelOptions" placeholder="选择模型" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="lbl">指令</label>
|
||||
<textarea v-model="prompt" rows="3" class="field resize-none" placeholder="输入测试指令…"></textarea>
|
||||
</div>
|
||||
|
||||
<button @click="run" :disabled="busy || !models.length" class="btn-primary w-full">
|
||||
<Icon name="spark" class="w-4 h-4" /> {{ busy ? (isVideo ? '生成中…(请耐心等待)' : '生成中…') : '生成' }}
|
||||
</button>
|
||||
|
||||
<p v-if="status" class="text-xs text-white/55">{{ status }}</p>
|
||||
<p v-if="error" class="text-xs text-rose-300 break-all">{{ error }}</p>
|
||||
|
||||
<div v-if="resultUrl" class="rounded-xl ring-1 ring-white/10 bg-white/[0.03] overflow-hidden grid place-items-center min-h-[220px]">
|
||||
<video v-if="resultKind === 'video'" :src="resultUrl" controls autoplay
|
||||
class="max-w-full max-h-[420px] object-contain" />
|
||||
<img v-else :src="resultUrl" class="max-w-full max-h-[360px] object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,211 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
import SelectMenu from './SelectMenu.vue'
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
// 17 ratios — the union of what our models actually support; kept in sync with
|
||||
// the backend guessRatio() and the docs 对照表. 9:21 removed (no image provider accepts it).
|
||||
const RATIO_OPTS = ['1:1', '5:4', '4:3', '3:2', '16:9', '2:1', '21:9', '3:1', '4:1', '8:1', '4:5', '3:4', '2:3', '9:16', '1:3', '1:4', '1:8']
|
||||
const IMG_RES = ['1K', '2K', '4K']
|
||||
const VID_RES = ['720p', '1080p', '2K', '4K']
|
||||
const ALL_RES = ['1K', '2K', '4K', '720p', '1080p']
|
||||
const DUR_OPTS = ['5s', '6s', '8s', '10s', '15s']
|
||||
|
||||
const id = ref('')
|
||||
const alias = ref('')
|
||||
const type = ref('image')
|
||||
const ratios = ref(['1:1', '16:9', '9:16'])
|
||||
const maxRefs = ref(0)
|
||||
const refMode = ref('none')
|
||||
const weight = ref(0)
|
||||
// tier -> { price, agent } — blank price means the tier is NOT supported.
|
||||
const res = ref(Object.fromEntries(ALL_RES.map((r) => [r, { price: '', agent: '' }])))
|
||||
const dur = ref(Object.fromEntries(DUR_OPTS.map((d) => [d, { price: '', agent: '' }])))
|
||||
// Duration rows shown: the presets above + any custom seconds the admin adds.
|
||||
const durList = ref([...DUR_OPTS])
|
||||
const customDurInput = ref('')
|
||||
const error = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
// Add a custom duration (any positive integer seconds, e.g. 12 → "12s").
|
||||
function addCustomDur() {
|
||||
const n = parseInt(customDurInput.value, 10)
|
||||
if (!(n > 0)) return
|
||||
const key = n + 's'
|
||||
if (!durList.value.includes(key)) {
|
||||
if (!dur.value[key]) dur.value[key] = { price: '', agent: '' }
|
||||
durList.value.push(key)
|
||||
}
|
||||
customDurInput.value = ''
|
||||
}
|
||||
// Custom (non-preset) durations can be removed; presets stay.
|
||||
function removeDur(key) {
|
||||
if (DUR_OPTS.includes(key)) return
|
||||
durList.value = durList.value.filter((k) => k !== key)
|
||||
delete dur.value[key]
|
||||
}
|
||||
|
||||
const isVideo = computed(() => type.value === 'video')
|
||||
// Resolution tiers depend on type: image = 1K/2K/4K, video = 540p/720p/1080p/2K/4K.
|
||||
const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES))
|
||||
|
||||
// 首尾帧(frame) only has first+last slots → cap reference images at 2.
|
||||
const refsCap = computed(() => (refMode.value === 'frame' ? 2 : 99))
|
||||
watch([refMode, maxRefs], () => {
|
||||
if (refMode.value === 'frame' && Number(maxRefs.value) > 2) maxRefs.value = 2
|
||||
})
|
||||
|
||||
function toggleRatio(r) {
|
||||
const i = ratios.value.indexOf(r)
|
||||
if (i >= 0) ratios.value.splice(i, 1)
|
||||
else ratios.value.push(r)
|
||||
}
|
||||
|
||||
// collect checked tiers into { tier: price } and { tier: agentPrice }
|
||||
// blank price = tier not supported (skipped), matching the edit form.
|
||||
function collect(tiers, allowed) {
|
||||
const prices = {}, agent = {}, keys = []
|
||||
for (const [k, v] of Object.entries(tiers)) {
|
||||
if (allowed && !allowed.includes(k)) continue
|
||||
const raw = String(v.price ?? '').trim()
|
||||
if (raw === '') continue
|
||||
const n = Number(raw)
|
||||
if (Number.isNaN(n) || n < 0) continue
|
||||
prices[k] = n; keys.push(k)
|
||||
const ar = String(v.agent ?? '').trim()
|
||||
if (ar !== '') { const a = Number(ar); if (!Number.isNaN(a) && a >= 0) agent[k] = a }
|
||||
}
|
||||
return { prices, agent, keys }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const mid = id.value.trim()
|
||||
if (!mid) { error.value = '请填写模型 id'; return }
|
||||
if (refMode.value === 'frame' && Number(maxRefs.value) > 2) maxRefs.value = 2
|
||||
const r = collect(res.value, resOpts.value)
|
||||
if (!r.keys.length) { error.value = '请至少勾选一个分辨率并填价格'; return }
|
||||
const body = {
|
||||
id: mid,
|
||||
name: mid,
|
||||
alias: alias.value.trim(),
|
||||
type: type.value,
|
||||
provider: 'custom',
|
||||
prices: r.prices,
|
||||
prices_agent: r.agent,
|
||||
ratios: ratios.value.slice(),
|
||||
max_reference_images: Number(maxRefs.value) || 0,
|
||||
reference_mode: refMode.value,
|
||||
weight: Number(weight.value) || 0,
|
||||
image_to_image: (Number(maxRefs.value) || 0) > 0,
|
||||
}
|
||||
if (isVideo.value) {
|
||||
body.resolutions = r.keys
|
||||
const d = collect(dur.value)
|
||||
if (!d.keys.length) { error.value = '视频请至少勾选一个时长并填价格'; return }
|
||||
body.duration_prices = d.prices
|
||||
body.duration_prices_agent = d.agent
|
||||
body.durations = d.keys
|
||||
}
|
||||
saving.value = true; error.value = ''
|
||||
try {
|
||||
const resp = await api('/managed-models', jsonBody('POST', body))
|
||||
if (resp.ok || resp.data?.ok || resp.status === 200) emit('saved')
|
||||
else error.value = resp.data?.detail || '创建失败'
|
||||
} catch (e) { error.value = String(e) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-xl mt-10 mb-10 w-full max-w-xl">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">添加自定义模型(上游 / provider=custom)</h2>
|
||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700"><Icon name="close" class="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-xs text-slate-500 leading-relaxed">
|
||||
id 要与上游模型名<strong class="text-slate-700">一致</strong> —— 生成时按 id 自动路由到「支持该 id 的上游账号」。价格按本地价计费。设了别名后,对外只用别名调用(原 id 调不到),但内部仍按 id 路由到上游,不影响。
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 block mb-1">模型 id <span class="text-rose-500">*</span></label>
|
||||
<input v-model="id" class="field font-mono text-xs h-10" placeholder="gpt-image-2" />
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="text-xs text-slate-500 block mb-1">别名(选填)</label>
|
||||
<input v-model="alias" class="field font-mono text-xs h-10" placeholder="对外调用名" />
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<label class="text-xs text-slate-500 block mb-1">类型</label>
|
||||
<SelectMenu v-model="type" :options="[{value:'image',label:'图像'},{value:'video',label:'视频'}]" />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label class="text-xs text-slate-500 block mb-1">权重</label>
|
||||
<input v-model.number="weight" type="number" class="field h-10" placeholder="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1.5">比例(多选)</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="r in RATIO_OPTS" :key="r" type="button" @click="toggleRatio(r)"
|
||||
class="px-2 py-1 rounded text-xs ring-1 transition-colors"
|
||||
:class="ratios.includes(r) ? 'bg-sky-500/15 text-sky-600 ring-sky-300' : 'bg-slate-50 text-slate-500 ring-slate-200 hover:ring-slate-300'">{{ r }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1.5">分辨率 · 价格(填普通价 = 支持该档,<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="r in resOpts" :key="r" class="flex items-center gap-2">
|
||||
<span class="w-16 text-xs font-mono text-slate-500">{{ r }}</span>
|
||||
<input v-model="res[r].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
||||
<input v-model="res[r].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isVideo">
|
||||
<label class="text-xs text-slate-500 block mb-1.5">时长 · 价格(总价 = 分辨率价 + 时长价;<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="d in durList" :key="d" class="flex items-center gap-2">
|
||||
<span class="w-16 text-xs font-mono text-slate-500">{{ d }}</span>
|
||||
<input v-model="dur[d].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
||||
<input v-model="dur[d].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
||||
<button v-if="!DUR_OPTS.includes(d)" type="button" @click="removeDur(d)"
|
||||
class="shrink-0 w-7 h-7 grid place-items-center rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50" title="删除该时长">
|
||||
<Icon name="close" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 自定义时长:输入任意秒数,Sora 类上游支持的任意时长都能加 -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<input v-model="customDurInput" type="number" min="1" @keydown.enter.prevent="addCustomDur"
|
||||
class="field !py-1 w-32" placeholder="自定义秒数" />
|
||||
<button type="button" @click="addCustomDur" class="btn-soft text-xs whitespace-nowrap">+ 添加时长</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 block mb-1">参考图张数<span v-if="refMode==='frame'" class="text-white/40">(首尾帧最多 2)</span></label>
|
||||
<input v-model.number="maxRefs" type="number" min="0" :max="refsCap" class="field h-10" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 block mb-1">参考模式</label>
|
||||
<SelectMenu v-model="refMode" :options="[{value:'none',label:'无'},{value:'asset',label:'参考图'},{value:'frame',label:'首尾帧(视频)'}]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="save" :disabled="saving" class="btn-primary w-full">{{ saving ? '创建中…' : '创建模型' }}</button>
|
||||
<p v-if="error" class="text-xs text-rose-600">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,166 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { parseImportInput } from '../utils/import'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const emit = defineEmits(['close', 'imported'])
|
||||
|
||||
const input = ref('')
|
||||
const weight = ref(0)
|
||||
const status = ref('')
|
||||
const isError = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// type → token pool (for the post-import weight PATCH).
|
||||
const TYPE_POOL = { openai: 'chatgpt', adobe: 'adobe', runway: 'runway', leonardo: 'leonardo', krea: 'krea', imagine: 'imagine', grok: 'grok', creativefabrica: 'creativefabrica' }
|
||||
|
||||
// Live preview of what the parser would extract — updates as the user types
|
||||
// so they can see whether their paste was understood before clicking import.
|
||||
const detected = computed(() => {
|
||||
const items = parseImportInput(input.value)
|
||||
const openai = items.filter((x) => x.type === 'openai').length
|
||||
const adobe = items.filter((x) => x.type === 'adobe').length
|
||||
const runway = items.filter((x) => x.type === 'runway').length
|
||||
const leonardo = items.filter((x) => x.type === 'leonardo').length
|
||||
const krea = items.filter((x) => x.type === 'krea').length
|
||||
const imagine = items.filter((x) => x.type === 'imagine').length
|
||||
const grok = items.filter((x) => x.type === 'grok').length
|
||||
const cf = items.filter((x) => x.type === 'creativefabrica').length
|
||||
return { total: items.length, openai, adobe, runway, leonardo, krea, imagine, grok, cf }
|
||||
})
|
||||
|
||||
function setStatus(text, err = false) {
|
||||
status.value = text || ''
|
||||
isError.value = err
|
||||
}
|
||||
|
||||
async function doSmartImport() {
|
||||
const items = parseImportInput(input.value)
|
||||
if (!items.length) {
|
||||
setStatus('未识别到任何 Cookie 或 JWT', true)
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
let ok = 0, fail = 0
|
||||
const errs = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i]
|
||||
try {
|
||||
const r = it.type === 'openai'
|
||||
? 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'
|
||||
? await api('/tokens/import-runway-token', jsonBody('POST', { access_token: it.value }))
|
||||
: it.type === 'leonardo'
|
||||
? await api('/tokens/import-leonardo-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
: it.type === 'krea'
|
||||
? await api('/tokens/import-krea-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
: it.type === 'imagine'
|
||||
? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value }))
|
||||
: it.type === 'creativefabrica'
|
||||
? await api('/tokens/import-creativefabrica-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
: await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
if (r.ok) {
|
||||
ok++
|
||||
// Apply the chosen weight to the freshly-imported account (best-effort).
|
||||
const w = Number(weight.value) || 0
|
||||
const pool = TYPE_POOL[it.type]
|
||||
if (w !== 0 && pool && r.data?.id) {
|
||||
try { await api(`/tokens/${pool}/${r.data.id}`, jsonBody('PATCH', { weight: w })) } catch (_) {}
|
||||
}
|
||||
} else { fail++; errs.push(`${it.type}: ${r.data?.detail || r.status}`) }
|
||||
} catch (e) {
|
||||
fail++; errs.push(`${it.type}: ${e}`)
|
||||
}
|
||||
}
|
||||
submitting.value = false
|
||||
// Quota isn't checked here — the server probes each token off-thread and the
|
||||
// account list flips pending → active/dead on its own.
|
||||
if (fail === 0) {
|
||||
// Close immediately on success — the account lands in the table right away
|
||||
// and the server backfills quota/email off-thread (pending → active/dead).
|
||||
emit('imported')
|
||||
emit('close')
|
||||
} else {
|
||||
setStatus(`成功 ${ok} · 失败 ${fail} · ${errs.slice(0, 3).join(' | ')}`, true)
|
||||
emit('imported')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-2xl">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">导入账号</h2>
|
||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<p class="text-xs text-slate-500 mb-3 leading-relaxed">自动识别:
|
||||
<strong class="text-slate-700">Adobe Cookie 字符串</strong>(<code class="px-1 bg-slate-100 rounded">k=v; k=v; ...</code>)、
|
||||
<strong class="text-slate-700">Cookie JSON 对象</strong>、
|
||||
<strong class="text-slate-700">Cookie 数组</strong>(多 Adobe 批量)、
|
||||
<strong class="text-slate-700">ChatGPT JWT</strong>(<code class="px-1 bg-slate-100 rounded">eyJhbGciOi...</code>)、
|
||||
<strong class="text-slate-700">Runway JWT</strong>(自动与 ChatGPT 区分)、
|
||||
<strong class="text-slate-700">Leonardo Cookie</strong>(须含 better-auth.session_data)、
|
||||
<strong class="text-slate-700">Krea Cookie</strong>(含 sb-superb-auth)、
|
||||
<strong class="text-slate-700">Creative Fabrica Cookie</strong>(含 cfauth_* 或 wordpress_logged_in_)、
|
||||
<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>(换行分隔)。
|
||||
全粘进来即可,无需任何前缀。
|
||||
</p>
|
||||
<p class="text-xs text-rose-500 mb-3 leading-relaxed bg-rose-50 rounded px-3 py-2 border border-rose-200">
|
||||
📢 需要购买 Adobe 号请联系 QQ:28998306
|
||||
</p>
|
||||
<textarea v-model="input" rows="10"
|
||||
class="field font-mono text-xs resize-none"
|
||||
placeholder="直接粘 Cookie 字符串 / JWT / JSON,自动识别"></textarea>
|
||||
<div v-if="input.trim()" class="mt-2 flex items-center gap-2 text-xs">
|
||||
<template v-if="detected.total">
|
||||
<span class="text-emerald-600">✓ 识别到 <strong class="tabular-nums">{{ detected.total }}</strong> 个账号</span>
|
||||
<span v-if="detected.openai" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200">
|
||||
OpenAI · <span class="tabular-nums">{{ detected.openai }}</span>
|
||||
</span>
|
||||
<span v-if="detected.adobe" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-rose-700 bg-rose-50 ring-1 ring-rose-200">
|
||||
Adobe · <span class="tabular-nums">{{ detected.adobe }}</span>
|
||||
</span>
|
||||
<span v-if="detected.runway" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-orange-700 bg-orange-50 ring-1 ring-orange-200">
|
||||
Runway · <span class="tabular-nums">{{ detected.runway }}</span>
|
||||
</span>
|
||||
<span v-if="detected.leonardo" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-amber-700 bg-amber-50 ring-1 ring-amber-200">
|
||||
Leonardo · <span class="tabular-nums">{{ detected.leonardo }}</span>
|
||||
</span>
|
||||
<span v-if="detected.krea" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-sky-700 bg-sky-50 ring-1 ring-sky-200">
|
||||
Krea · <span class="tabular-nums">{{ detected.krea }}</span>
|
||||
</span>
|
||||
<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>
|
||||
</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>
|
||||
<span v-if="detected.cf" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-rose-700 bg-rose-50 ring-1 ring-rose-200">
|
||||
Creative Fabrica · <span class="tabular-nums">{{ detected.cf }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-rose-600">未识别到任何 Cookie 或 JWT</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<label class="text-xs text-slate-500 whitespace-nowrap">权重(本批账号,高的优先)</label>
|
||||
<input v-model.number="weight" type="number" class="field !w-24" placeholder="0" />
|
||||
</div>
|
||||
<button @click="doSmartImport" :disabled="submitting || !detected.total" class="btn-primary w-full mt-3">
|
||||
{{ submitting ? '导入中…' : (detected.total ? `识别并导入 (${detected.total})` : '识别并导入') }}
|
||||
</button>
|
||||
<p v-if="status" class="text-xs mt-2" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,6 +15,14 @@ const isEdit = computed(() => !!props.model)
|
||||
|
||||
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
|
||||
|
||||
// provider=custom 的模型没有目录约束,档位由价格表本身决定——编辑时给出
|
||||
// 完整候选面板(与 CustomModelModal 的 VID_RES/IMG_RES/DUR_OPTS 保持一致),
|
||||
// 留空的档位 = 不支持。已存的自定义档(如 "540p"/"12s")也会并入显示。
|
||||
const CUSTOM_IMG_RES = ['1K', '2K', '4K']
|
||||
const CUSTOM_VID_RES = ['720p', '768p', '1080p', '2K', '4K']
|
||||
const CUSTOM_DUR = ['5s', '6s', '8s', '10s', '15s']
|
||||
const bySeconds = (a, b) => parseFloat(a) - parseFloat(b)
|
||||
|
||||
const catalog = ref([])
|
||||
const loading = ref(true)
|
||||
const selectedId = ref(props.model?.id || '')
|
||||
@@ -37,15 +45,28 @@ const entry = computed(() => {
|
||||
return catalog.value.find((e) => e.id === selectedId.value) || null
|
||||
})
|
||||
const isVideo = computed(() => entry.value?.type === 'video')
|
||||
const isCustom = computed(() => entry.value?.provider === 'custom')
|
||||
// Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K)
|
||||
// regardless of how the catalog/stored record happens to list them.
|
||||
const resolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
|
||||
// regardless of how the catalog/stored record happens to list them. For
|
||||
// custom models the stored list is just the currently-priced subset — merge
|
||||
// in the full palette so the admin can light up a new tier (e.g. 768p) by
|
||||
// typing a price, or retire one by blanking it.
|
||||
const resolutions = computed(() => {
|
||||
const stored = entry.value?.resolutions || []
|
||||
if (!isCustom.value) return sortResolutions(stored)
|
||||
const palette = isVideo.value ? CUSTOM_VID_RES : CUSTOM_IMG_RES
|
||||
return sortResolutions([...new Set([...palette, ...stored])])
|
||||
})
|
||||
// Read-only summary shows what's actually supported (stored list), not the
|
||||
// offerable palette — for custom models the palette is just the input grid.
|
||||
const storedResolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
|
||||
// 按秒计价的视频模型(一个 /s 价,实付 = 分辨率价 + 每秒价 × 秒数)。目录用
|
||||
// per_second 声明;已入库的模型则看它是否已存了 per_second 价。
|
||||
// per_second 声明;已入库的模型则看它是否已存了 per_second 价。渠道导入的
|
||||
// duration range 模型(duration_min/max,无固定档)也只能走 per_second。
|
||||
const isPerSecond = computed(() => {
|
||||
const e = entry.value
|
||||
if (!e) return false
|
||||
return !!e.per_second || e.duration_prices?.per_second != null
|
||||
return !!e.per_second || e.duration_prices?.per_second != null || (e.type === 'video' && (e.duration_max || 0) > 0 && !(e.durations || []).length)
|
||||
})
|
||||
|
||||
const durationTiers = computed(() => {
|
||||
@@ -53,7 +74,9 @@ const durationTiers = computed(() => {
|
||||
if (!e) return []
|
||||
if (isPerSecond.value) return ['per_second']
|
||||
const ds = e.durations || []
|
||||
return ds.length ? ds : Object.keys(e.duration_prices || {})
|
||||
const stored = ds.length ? ds : Object.keys(e.duration_prices || {})
|
||||
if (e.provider !== 'custom') return stored
|
||||
return [...new Set([...stored, ...CUSTOM_DUR])].sort(bySeconds)
|
||||
})
|
||||
|
||||
// Dropdown options: catalog rows not already in the managed store.
|
||||
@@ -71,14 +94,9 @@ function resetPrices(e) {
|
||||
if (!e) return
|
||||
// Both image and video price per resolution; video ALSO prices per duration
|
||||
// (real video price = resolution price + duration price).
|
||||
for (const r of (e.resolutions || [])) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' }
|
||||
for (const r of resolutions.value) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' }
|
||||
if (e.type === 'video') {
|
||||
if (isPerSecond.value) {
|
||||
videoPrices.value['per_second'] = ''
|
||||
videoPricesAgent.value['per_second'] = ''
|
||||
} else {
|
||||
for (const d of (e.durations || [])) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
|
||||
}
|
||||
for (const d of durationTiers.value) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,20 +112,16 @@ onMounted(async () => {
|
||||
const m = props.model
|
||||
// Resolution prices apply to both; video additionally has duration prices.
|
||||
// Agent prices are an optional overlay (blank = follows the normal price).
|
||||
for (const r of (m.resolutions || [])) {
|
||||
// Iterate the DISPLAYED tier lists (for custom models that's the full
|
||||
// palette ∪ stored), so unpriced-but-offerable tiers get blank inputs.
|
||||
for (const r of resolutions.value) {
|
||||
imagePrices.value[r] = m.prices?.[r] ?? ''
|
||||
imagePricesAgent.value[r] = m.prices_agent?.[r] ?? ''
|
||||
}
|
||||
if (m.type === 'video') {
|
||||
if (m.duration_prices?.per_second != null) {
|
||||
videoPrices.value['per_second'] = m.duration_prices.per_second
|
||||
videoPricesAgent.value['per_second'] = m.duration_prices_agent?.per_second ?? ''
|
||||
} else {
|
||||
const durs = (m.durations && m.durations.length) ? m.durations : Object.keys(m.duration_prices || {})
|
||||
for (const d of durs) {
|
||||
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
|
||||
videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
|
||||
}
|
||||
for (const d of durationTiers.value) {
|
||||
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
|
||||
videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,9 +146,9 @@ async function save() {
|
||||
|
||||
let payload
|
||||
if (e.type === 'video') {
|
||||
const prices = collect(imagePrices.value, e.resolutions || [])
|
||||
const prices = collect(imagePrices.value, resolutions.value)
|
||||
const duration_prices = collect(videoPrices.value, durationTiers.value)
|
||||
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
|
||||
const prices_agent = collect(imagePricesAgent.value, resolutions.value)
|
||||
const duration_prices_agent = collect(videoPricesAgent.value, durationTiers.value)
|
||||
if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return }
|
||||
if (!Object.keys(duration_prices).length) { error.value = '至少填写一个时长价格'; return }
|
||||
@@ -142,9 +156,12 @@ async function save() {
|
||||
type: 'video',
|
||||
provider: e.provider,
|
||||
ratios: e.ratios || [],
|
||||
resolutions: e.resolutions || [],
|
||||
// catalog models keep their declared tier list; custom models' supported
|
||||
// tiers ARE the priced keys — a blanked price retires the tier.
|
||||
resolutions: isCustom.value ? sortResolutions(Object.keys(prices)) : (e.resolutions || []),
|
||||
prices,
|
||||
durations: isPerSecond.value ? (e.durations || []) : Object.keys(duration_prices),
|
||||
durations: isPerSecond.value ? (e.durations || [])
|
||||
: (isCustom.value ? Object.keys(duration_prices).sort(bySeconds) : Object.keys(duration_prices)),
|
||||
duration_prices,
|
||||
prices_agent,
|
||||
duration_prices_agent,
|
||||
@@ -154,8 +171,8 @@ async function save() {
|
||||
weight: Number(weight.value) || 0,
|
||||
}
|
||||
} else {
|
||||
const prices = collect(imagePrices.value, e.resolutions || [])
|
||||
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
|
||||
const prices = collect(imagePrices.value, resolutions.value)
|
||||
const prices_agent = collect(imagePricesAgent.value, resolutions.value)
|
||||
if (!Object.keys(prices).length) { error.value = '至少填写一个画质价格'; return }
|
||||
payload = {
|
||||
type: 'image',
|
||||
@@ -205,7 +222,7 @@ async function save() {
|
||||
placeholder="选择一个支持的模型" />
|
||||
<div v-else class="field font-mono !cursor-default opacity-90">{{ entry?.id }}</div>
|
||||
<p v-if="!isEdit && !addOptions.length" class="text-[11px] text-amber-300/80 mt-1.5">
|
||||
所有支持的模型都已添加。
|
||||
模型经「渠道管理 → 拉取上游模型」导入。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -224,7 +241,7 @@ async function save() {
|
||||
{{ isVideo ? '生视频' : '生图' }}
|
||||
</span>
|
||||
<span class="text-[11px] text-white/45 capitalize">{{ entry.provider }}</span>
|
||||
<span class="ml-auto text-[10px] text-white/30">参数自动加载,不可改</span>
|
||||
<span class="ml-auto text-[10px] text-white/30">{{ isCustom ? '档位由价格表决定 · 留空=不支持' : '参数自动加载,不可改' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[3.5rem_1fr] gap-x-3 gap-y-1.5 text-[11px]">
|
||||
@@ -237,13 +254,14 @@ async function save() {
|
||||
<template v-if="isVideo">
|
||||
<span class="text-white/40">分辨率</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
|
||||
<span v-if="!resolutions.length" class="text-white/30">—</span>
|
||||
<span v-for="r in storedResolutions" :key="r" class="ro-chip">{{ r }}</span>
|
||||
<span v-if="!storedResolutions.length" class="text-white/30">—</span>
|
||||
</div>
|
||||
<span class="text-white/40">时长</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="d in (entry.durations || [])" :key="d" class="ro-chip">{{ d }}</span>
|
||||
<span v-if="!(entry.durations || []).length" class="text-white/30">—</span>
|
||||
<span v-if="!(entry.durations || []).length && (entry.duration_max || 0) > 0" class="ro-chip">{{ entry.duration_min }}–{{ entry.duration_max }}s(区间)</span>
|
||||
<span v-if="!(entry.durations || []).length && !(entry.duration_max || 0)" class="text-white/30">—</span>
|
||||
</div>
|
||||
<span class="text-white/40">参考图</span>
|
||||
<div class="text-white/70">
|
||||
@@ -256,8 +274,8 @@ async function save() {
|
||||
<template v-else>
|
||||
<span class="text-white/40">画质</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
|
||||
<span v-if="!resolutions.length" class="text-white/30">—</span>
|
||||
<span v-for="r in storedResolutions" :key="r" class="ro-chip">{{ r }}</span>
|
||||
<span v-if="!storedResolutions.length" class="text-white/30">—</span>
|
||||
</div>
|
||||
<span class="text-white/40">参考图</span>
|
||||
<div class="text-white/70">{{ entry.max_reference_images > 0 ? `${entry.max_reference_images} 张` : (entry.image_to_image ? '支持' : '不支持') }}</div>
|
||||
|
||||
@@ -16,11 +16,16 @@ const isVideo = props.model.type === 'video'
|
||||
// (core/video_models.FAMILIES), not the user-edited managed-model record
|
||||
// which may have stale max_reference_images / reference_mode values.
|
||||
const familyPreset = ref(null)
|
||||
const channels = ref([]) // 渠道 pin 候选(空 = 自动调度)
|
||||
const channelId = ref('')
|
||||
onMounted(async () => {
|
||||
if (!isVideo) return
|
||||
const r = await api('/video-presets')
|
||||
const list = r.data?.data || []
|
||||
familyPreset.value = list.find((p) => p.key === props.model.id) || null
|
||||
if (isVideo) {
|
||||
const r = await api('/video-presets')
|
||||
const list = r.data?.data || []
|
||||
familyPreset.value = list.find((p) => p.key === props.model.id) || null
|
||||
}
|
||||
const cr = await api('/channels')
|
||||
channels.value = (cr.data?.data || []).filter((c) => c.enabled)
|
||||
})
|
||||
|
||||
const ratios = (props.model.ratios && props.model.ratios.length)
|
||||
@@ -137,6 +142,7 @@ async function run() {
|
||||
// Backend accepts raw base64 only — strip the "data:...;base64," prefix.
|
||||
payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
|
||||
}
|
||||
if (channelId.value) payload.channel_id = channelId.value
|
||||
recoverSubmitTs = Date.now()
|
||||
const r = await api('/test', jsonBody('POST', payload))
|
||||
if (r.ok && r.data?.url) {
|
||||
@@ -239,6 +245,15 @@ async function recover() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 渠道 pin:默认自动调度,选定了就只走该渠道(不故障转移) -->
|
||||
<div v-if="channels.length > 0">
|
||||
<label class="lbl">渠道</label>
|
||||
<select v-model="channelId" class="field">
|
||||
<option value="">自动调度</option>
|
||||
<option v-for="c in channels" :key="c.id" :value="c.id">{{ c.name }}({{ c.base_url }})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- reference images -->
|
||||
<div v-if="maxRefs > 0">
|
||||
<label class="lbl">
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = defineProps({ account: { type: Object, default: null } }) // edit mode when set
|
||||
const emit = defineEmits(['close', 'imported'])
|
||||
|
||||
const isEdit = !!props.account
|
||||
const name = ref(props.account?.email || '')
|
||||
const baseUrl = ref(props.account?.base_url || '')
|
||||
const key = ref('') // edit: blank = keep existing key
|
||||
const allModels = ref([]) // existing models to pick from
|
||||
const selected = ref(props.account?.models ? String(props.account.models).split(',').map((x) => x.trim()).filter(Boolean) : [])
|
||||
const weight = ref(Number(props.account?.weight) || 0)
|
||||
const concurrency = ref(Number(props.account?.concurrency) || 1)
|
||||
const status = ref('')
|
||||
const isError = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await api('/managed-models')
|
||||
allModels.value = (r.data?.data || []).map((m) => ({ id: m.id, type: m.type, alias: m.alias }))
|
||||
} catch (_) {}
|
||||
})
|
||||
|
||||
function toggle(id) {
|
||||
const i = selected.value.indexOf(id)
|
||||
if (i >= 0) selected.value.splice(i, 1)
|
||||
else selected.value.push(id)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!baseUrl.value.trim() || (!isEdit && !key.value.trim())) {
|
||||
status.value = isEdit ? '请填写 URL' : '请填写 URL 和 Key'; isError.value = true; return
|
||||
}
|
||||
submitting.value = true; status.value = ''; isError.value = false
|
||||
try {
|
||||
const r = await api('/tokens/import-custom-account', jsonBody('POST', {
|
||||
id: isEdit ? props.account.id : undefined,
|
||||
name: name.value.trim(),
|
||||
base_url: baseUrl.value.trim(),
|
||||
key: key.value.trim(), // blank in edit = keep existing
|
||||
models: selected.value.join(','),
|
||||
weight: Number(weight.value) || 0,
|
||||
concurrency: Number(concurrency.value) || 1,
|
||||
}))
|
||||
if (r.ok) {
|
||||
status.value = isEdit ? '✓ 已保存' : '✓ 已添加上游'; emit('imported')
|
||||
setTimeout(() => emit('close'), 700)
|
||||
} else {
|
||||
status.value = r.data?.detail || '保存失败'; isError.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
status.value = String(e); isError.value = true
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-lg">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">{{ isEdit ? '编辑上游' : '添加上游(自定义 OpenAI 兼容)' }}</h2>
|
||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<p class="text-xs text-slate-500 leading-relaxed">
|
||||
上游就是一个账号:填 v1 URL + Key。模型按 <strong class="text-slate-700">id 相同</strong>自动路由 ——
|
||||
在「模型管理」加一个 provider=custom、id 与上游一致的模型即可从这个上游调用。调用<strong class="text-slate-700">直连不走代理</strong>。
|
||||
</p>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">备注名</label>
|
||||
<input v-model="name" class="field" placeholder="例如:我的中转 / xx-api" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">v1 URL <span class="text-rose-500">*</span></label>
|
||||
<input v-model="baseUrl" class="field font-mono text-xs" placeholder="https://api.example.com(无需 /v1 结尾)" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Key <span v-if="!isEdit" class="text-rose-500">*</span><span v-else class="text-white/40">(留空=不改)</span></label>
|
||||
<input v-model="key" class="field font-mono text-xs" :placeholder="isEdit ? '留空保持原 key' : 'sk-...'" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<label class="text-xs text-slate-500">支持的模型(多选,不选 = 全部)</label>
|
||||
<span class="text-[11px] text-slate-400">{{ selected.length ? `已选 ${selected.length}` : '全部' }}</span>
|
||||
</div>
|
||||
<div v-if="!allModels.length" class="text-xs text-slate-400 rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-3">
|
||||
暂无模型 —— 可先去模型管理加自定义模型
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-1.5 max-h-44 overflow-y-auto rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-2">
|
||||
<button v-for="m in allModels" :key="m.id" type="button" @click="toggle(m.id)"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs ring-1 transition-colors"
|
||||
:class="selected.includes(m.id) ? 'bg-sky-500/15 text-sky-700 ring-sky-300 font-medium' : 'bg-white text-slate-600 ring-slate-200 hover:ring-slate-300'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="m.type === 'video' ? 'bg-orange-400' : 'bg-emerald-400'"></span>{{ m.alias || m.id }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500">权重(高的优先)</label>
|
||||
<input v-model.number="weight" type="number" class="field" placeholder="0" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500">并发数(单账号)</label>
|
||||
<input v-model.number="concurrency" type="number" min="1" class="field" placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full mt-1">
|
||||
{{ submitting ? '保存中…' : (isEdit ? '保存' : '添加上游') }}
|
||||
</button>
|
||||
<p v-if="status" class="text-xs" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,7 +11,7 @@ const route = useRoute()
|
||||
const tabs = [
|
||||
{ label: '概览', to: '/admin/overview', icon: 'overview' },
|
||||
{ label: '模型管理', to: '/admin/models', icon: 'models' },
|
||||
{ label: '账号管理', to: '/admin/accounts', icon: 'plug' },
|
||||
{ label: '渠道管理', to: '/admin/channels', icon: 'plug' },
|
||||
{ label: '用户管理', to: '/admin/users', icon: 'accounts' },
|
||||
{ label: '并发分组', to: '/admin/concurrency', icon: 'shield' },
|
||||
{ label: '违禁词管理', icon: 'ban', children: [
|
||||
@@ -55,7 +55,7 @@ watch(() => route.path, () => {
|
||||
<Logo v-else :size="32" />
|
||||
<div class="leading-tight min-w-0">
|
||||
<div class="text-sm font-extrabold truncate tracking-tight text-[color:var(--fg)]">{{ site.title }}</div>
|
||||
<div class="text-[11px] font-bold uppercase tracking-widest text-[color:var(--m-orange)] truncate">Admin</div>
|
||||
<div class="admin-tag text-[11px] font-bold uppercase tracking-widest truncate">Admin</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
@@ -118,7 +118,7 @@ watch(() => route.path, () => {
|
||||
</div>
|
||||
|
||||
<header class="relative z-10 h-14 shrink-0 border-b border-b-[color:var(--ink-soft)] bg-[var(--app-bg)]/60 backdrop-blur-xl flex items-center px-8">
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--m-orange)] font-extrabold mr-3">Admin</div>
|
||||
<div class="admin-badge text-[10px] uppercase tracking-[0.25em] font-extrabold mr-3 px-2.5 py-1 rounded-lg text-white select-none">Admin</div>
|
||||
<div class="text-[color:var(--fg-faint)] mr-3">/</div>
|
||||
<h1 class="text-sm font-semibold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</h1>
|
||||
</header>
|
||||
@@ -137,6 +137,16 @@ watch(() => route.path, () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-tag {
|
||||
background: linear-gradient(120deg, #7c5cfc, #22d3ee);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.admin-badge {
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 60%, #22d3ee 140%);
|
||||
box-shadow: 0 4px 14px var(--accent-glow);
|
||||
}
|
||||
.admin-link {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -24,7 +24,7 @@ const DocsView = () => import('./views/DocsView.vue')
|
||||
const AboutView = () => import('./views/AboutView.vue')
|
||||
const OverviewView = () => import('./views/OverviewView.vue')
|
||||
const ModelsView = () => import('./views/ModelsView.vue')
|
||||
const AccountsView = () => import('./views/AccountsView.vue')
|
||||
const ChannelsView = () => import('./views/ChannelsView.vue')
|
||||
const UsersView = () => import('./views/UsersView.vue')
|
||||
const ConcurrencyView = () => import('./views/ConcurrencyView.vue')
|
||||
const BannedWordsView = () => import('./views/BannedWordsView.vue')
|
||||
@@ -61,7 +61,7 @@ const routes = [
|
||||
{ path: '', redirect: '/admin/overview' },
|
||||
{ path: 'overview', component: OverviewView, meta: { label: '概览' } },
|
||||
{ path: 'models', component: ModelsView, meta: { label: '模型管理' } },
|
||||
{ path: 'accounts', component: AccountsView, meta: { label: '账号管理' } },
|
||||
{ path: 'channels', component: ChannelsView, meta: { label: '渠道管理' } },
|
||||
{ path: 'users', component: UsersView, meta: { label: '用户管理' } },
|
||||
{ path: 'concurrency', component: ConcurrencyView, meta: { label: '并发分组' } },
|
||||
{ path: 'banned-words', component: BannedWordsView, meta: { label: '违禁词列表' } },
|
||||
@@ -81,7 +81,7 @@ const routes = [
|
||||
{ path: '/overview', redirect: '/admin/overview' },
|
||||
{ path: '/models', redirect: '/admin/models' },
|
||||
{ path: '/video-models', redirect: '/admin/models' },
|
||||
{ path: '/accounts', redirect: '/admin/accounts' },
|
||||
{ path: '/accounts', redirect: '/admin/channels' },
|
||||
// NOTE: /images is the generated-artifact path (served by the backend), so the
|
||||
// old "/images → /admin/images" shortcut is gone. Use /files for that shortcut.
|
||||
{ path: '/files', redirect: '/admin/images' },
|
||||
|
||||
+30
-6
@@ -327,6 +327,28 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi
|
||||
own utility so vertical clipping / rounded corners are preserved. */
|
||||
.card:has(> table) { overflow-x: auto; }
|
||||
|
||||
/* ===== Admin table polish =====
|
||||
Every data table lives in a `.card` and views hand-roll their own
|
||||
th/td utilities — these rules unify the reading of all of them:
|
||||
a faint violet band on the header row and a violet wash on row hover.
|
||||
Scoped to `.theme-text` (the admin main wrapper) so public pages with
|
||||
over-media tables are untouched; specificity outranks the per-view
|
||||
Tailwind hover utilities and the light-mode white rescue. */
|
||||
.theme-text .card table thead {
|
||||
background: linear-gradient(180deg, rgb(124 92 252 / 0.07), rgb(124 92 252 / 0.025));
|
||||
}
|
||||
html.dark .theme-text .card table thead {
|
||||
background: linear-gradient(180deg, rgb(139 92 246 / 0.13), rgb(139 92 246 / 0.05));
|
||||
}
|
||||
.theme-text .card table tbody tr { transition: background-color 0.15s ease; }
|
||||
.theme-text .card table tbody tr:hover { background-color: rgb(124 92 252 / 0.08); }
|
||||
html.dark .theme-text .card table tbody tr:hover { background-color: rgb(139 92 246 / 0.11); }
|
||||
/* Checkbox column + first data column get a hair more air against the
|
||||
card edge so dense tables don't feel cramped. */
|
||||
.theme-text .card table tbody tr:hover :is(td, th):first-child {
|
||||
box-shadow: inset 2px 0 0 rgb(124 92 252 / 0.55);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
::-webkit-scrollbar-thumb { background: rgb(24 21 36 / 0.2); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgb(24 21 36 / 0.38); background-clip: content-box; }
|
||||
@@ -545,7 +567,7 @@ html.dark .memphis-dots {
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
color: rgb(255 255 255 / 0.92);
|
||||
box-shadow: var(--glass-shadow);
|
||||
box-shadow: var(--glass-shadow), inset 0 1px 0 rgb(255 255 255 / 0.07);
|
||||
}
|
||||
.public-dark .field {
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
@@ -626,14 +648,16 @@ html.dark .memphis-dots {
|
||||
.public-dark .hover\:bg-slate-100:hover { background: rgb(255 255 255 / 0.06); }
|
||||
|
||||
@layer components {
|
||||
/* surfaces — frosted card with a hairline glass edge + soft shadow */
|
||||
/* surfaces — frosted card with a hairline glass edge + soft shadow.
|
||||
The inset top line is the light's "sheen" on the glass — barely
|
||||
visible on white, reads as a subtle rim-light in dark mode. */
|
||||
.card {
|
||||
@apply rounded-xl;
|
||||
@apply rounded-2xl;
|
||||
background: var(--surface);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
box-shadow: var(--glass-shadow), inset 0 1px 0 rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
/* buttons — each variant is self-contained (Tailwind v4 can't @apply a custom class) */
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
// Smart parsing of pasted credentials (Adobe cookies / ChatGPT JWTs),
|
||||
// ported verbatim from admin.html so import behaviour is unchanged.
|
||||
|
||||
export function looksLikeJwt(s) {
|
||||
s = (s || '').replace(/^Bearer\s+/i, '').trim()
|
||||
const parts = s.split('.')
|
||||
if (parts.length !== 3) return false
|
||||
return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 4)
|
||||
}
|
||||
|
||||
function decodeJwtPayload(s) {
|
||||
try {
|
||||
let p = (s || '').replace(/^Bearer\s+/i, '').trim().split('.')[1]
|
||||
if (!p) return null
|
||||
p = p.replace(/-/g, '+').replace(/_/g, '/')
|
||||
p += '='.repeat((4 - (p.length % 4)) % 4)
|
||||
return JSON.parse(atob(p))
|
||||
} catch (_) { return null }
|
||||
}
|
||||
|
||||
// Runway JWTs carry a top-level numeric `id` plus an `sso` claim and, crucially,
|
||||
// no OpenAI (https://api.openai.com/*) claims — that's what distinguishes them
|
||||
// from a ChatGPT JWT, which is otherwise also an opaque three-part token.
|
||||
export function looksLikeRunwayJwt(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
|
||||
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
|
||||
// apart from an Adobe cookie (both are otherwise opaque cookie strings).
|
||||
export function looksLikeLeonardoCookie(s) {
|
||||
return /better-auth\.session_token/.test(s || '') || /better-auth\.session_data/.test(s || '')
|
||||
}
|
||||
|
||||
// Krea cookies carry the Supabase auth cookie.
|
||||
export function looksLikeKreaCookie(s) {
|
||||
return /sb-superb-auth-token/.test(s || '')
|
||||
}
|
||||
|
||||
// Creative Fabrica session cookies carry the cfauth_* family (cfauth_uid /
|
||||
// cfauth_sig / cfauth_utp) plus the WordPress SSO cookie — those markers tell
|
||||
// them apart from an Adobe/Krea/Leonardo cookie (all otherwise opaque strings).
|
||||
export function looksLikeCreativeFabricaCookie(s) {
|
||||
return /cfauth_/.test(s || '') || /cfAmpAnonymousId/.test(s || '') || /wordpress_logged_in_/.test(s || '')
|
||||
}
|
||||
|
||||
// An Imagine.art credential is a JSON object { token, refreshToken } (both JWTs).
|
||||
function isImagineObj(o) {
|
||||
return !!o && typeof o === 'object' &&
|
||||
typeof o.token === 'string' && looksLikeJwt(o.token) &&
|
||||
typeof o.refreshToken === 'string' && looksLikeJwt(o.refreshToken)
|
||||
}
|
||||
|
||||
// String form (a pasted JSON object on a line).
|
||||
export function looksLikeImagineToken(s) {
|
||||
try { return isImagineObj(JSON.parse(s)) } catch (_) { return false }
|
||||
}
|
||||
|
||||
// Classify an opaque credential string by its distinctive shape. Imagine is
|
||||
// JSON-shaped, so it must be checked before the cookie heuristics.
|
||||
function cookieType(v) {
|
||||
if (looksLikeImagineToken(v)) return 'imagine'
|
||||
if (looksLikeKreaCookie(v)) return 'krea'
|
||||
if (looksLikeLeonardoCookie(v)) return 'leonardo'
|
||||
if (looksLikeCreativeFabricaCookie(v)) return 'creativefabrica'
|
||||
return 'adobe'
|
||||
}
|
||||
|
||||
function cookieFromAny(item) {
|
||||
if (typeof item === 'string') return item.trim()
|
||||
if (item && typeof item === 'object') {
|
||||
if (typeof item.cookie === 'string') return item.cookie.trim()
|
||||
if (typeof item.value === 'string' && !('name' in item)) return item.value.trim()
|
||||
if (Array.isArray(item.cookies)) {
|
||||
return item.cookies.filter((c) => c && c.name).map((c) => `${c.name}=${c.value}`).join('; ')
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Returns a list of { type: 'adobe' | 'openai' | 'runway' | 'leonardo', value }. */
|
||||
export function parseImportInput(text) {
|
||||
text = (text || '').trim()
|
||||
if (!text) return []
|
||||
// Try JSON first.
|
||||
try {
|
||||
const j = JSON.parse(text)
|
||||
if (Array.isArray(j) && j.length > 0) {
|
||||
// Chrome cookie export: array of {name,value} → one cookie account.
|
||||
if (j.every((it) => it && typeof it === 'object' && 'name' in it && 'value' in it)) {
|
||||
const joined = j.filter((c) => c && c.name).map((c) => `${c.name}=${c.value}`).join('; ')
|
||||
return joined ? [{ type: cookieType(joined), value: joined }] : []
|
||||
}
|
||||
// Otherwise treat as multiple accounts. An Imagine credential is itself a
|
||||
// JSON object {token,refreshToken} — keep it as its JSON string value.
|
||||
return j.map((it) => {
|
||||
if (isImagineObj(it)) return { type: 'imagine', value: JSON.stringify(it) }
|
||||
const v = cookieFromAny(it)
|
||||
return { type: cookieType(v), value: v }
|
||||
}).filter((x) => x.value)
|
||||
}
|
||||
if (j && typeof j === 'object') {
|
||||
if (isImagineObj(j)) return [{ type: 'imagine', value: JSON.stringify(j) }]
|
||||
const v = cookieFromAny(j)
|
||||
return v ? [{ type: cookieType(v), value: v }] : []
|
||||
}
|
||||
} catch (_) { /* not JSON */ }
|
||||
// Not JSON → split per line, identify each. A JWT is either a Runway token
|
||||
// (top-level id+sso, no openai claims) or a ChatGPT token; anything else is
|
||||
// treated as an Adobe cookie string.
|
||||
const lines = text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
|
||||
return lines.map((line) => {
|
||||
// Accept a bare JWT or one with a leading `sso=` (grok cookie value form).
|
||||
const stripped = line.replace(/^Bearer\s+/i, '').replace(/^sso=/, '')
|
||||
if (looksLikeJwt(stripped)) {
|
||||
const value = stripped
|
||||
return looksLikeRunwayJwt(value)
|
||||
? { type: 'runway', value }
|
||||
: looksLikeGrokJwt(value)
|
||||
? { type: 'grok', value }
|
||||
: { type: 'openai', value }
|
||||
}
|
||||
return { type: cookieType(line), value: line }
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
// 视频模型的参考资产分类上限(图片 / 视频 / 音频)。
|
||||
// 单一真源是后端 /video-presets 的 max_images / max_videos / max_audios(服务端
|
||||
// leonardoVideoSpecs 校验的就是这套数),预设没声明音视频上限的 seedance 系回落
|
||||
// 到 3 / 3,与画图台 (PlaygroundView) 的判定保持一致。
|
||||
const SEEDANCE_FALLBACK = { videos: 3, audios: 3 }
|
||||
|
||||
// 单一真源是后端 /video-presets 的 max_videos / max_audios(渠道导入时从上游
|
||||
// 模型规格带入);预设没声明的回落到模型行自己的 max_reference_videos/audios。
|
||||
function declared(v) {
|
||||
return v === undefined || v === null ? null : Number(v)
|
||||
}
|
||||
@@ -12,13 +9,10 @@ function declared(v) {
|
||||
// 参考资产时返回 null。model 兼容 /managed-models(type) 与 /models(kind) 两种字段。
|
||||
export function mediaCaps(model, preset) {
|
||||
if ((model?.type || model?.kind) !== 'video') return null
|
||||
// creativefabrica 上游只收图片参考(VIDEO_FRAME_TYPE_REFERENCE),不收视频/音频;
|
||||
// 它虽然叫 seedance,但要排除,否则会错误地显示「视频 3 音频 3」。
|
||||
const isSeedance = /^seedance/.test(model?.id || '') && (model?.provider || '') !== 'creativefabrica'
|
||||
const videos = declared(preset?.max_videos) ?? (isSeedance ? SEEDANCE_FALLBACK.videos : 0)
|
||||
const audios = declared(preset?.max_audios) ?? (isSeedance ? SEEDANCE_FALLBACK.audios : 0)
|
||||
const videos = declared(preset?.max_videos) ?? declared(model?.max_reference_videos) ?? 0
|
||||
const audios = declared(preset?.max_audios) ?? declared(model?.max_reference_audios) ?? 0
|
||||
if (!videos && !audios) return null
|
||||
return { images: declared(preset?.max_images) ?? 0, videos, audios }
|
||||
return { images: declared(preset?.max_images) ?? declared(model?.max_reference_images) ?? 0, videos, audios }
|
||||
}
|
||||
|
||||
// presetMap 把 /video-presets 的列表转成 key → preset,便于按模型 id 取预设。
|
||||
|
||||
@@ -1,759 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { fmtTs, fmtIso, fmtDate, fmtClock } from '../utils/format'
|
||||
import ImportModal from '../components/ImportModal.vue'
|
||||
import UpstreamModal from '../components/UpstreamModal.vue'
|
||||
import AccountEditModal from '../components/AccountEditModal.vue'
|
||||
import AccountTestModal from '../components/AccountTestModal.vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const quotaStatus = ref('')
|
||||
const showImport = ref(false)
|
||||
const showUpstream = ref(false)
|
||||
const editingUpstream = ref(null)
|
||||
function editUpstream(a) { editingUpstream.value = a; showUpstream.value = true }
|
||||
const editingAccount = ref(null)
|
||||
function editAccount(a) { editingAccount.value = a }
|
||||
const testingAccount = ref(null)
|
||||
function testAccount(a) { testingAccount.value = a }
|
||||
// 预加载模型列表,让「生图测试」弹窗即开即用(不显示加载中)。
|
||||
const allModels = ref([])
|
||||
async function loadModelList() {
|
||||
const r = await api('/managed-models')
|
||||
allModels.value = r.data?.data || []
|
||||
}
|
||||
// Reflect the saved values in the table without a full reload.
|
||||
function applyEdit(payload) {
|
||||
const row = editingAccount.value
|
||||
if (!row) return
|
||||
if (payload.weight != null) row.weight = payload.weight
|
||||
if (payload.concurrency != null) row.concurrency = payload.concurrency
|
||||
}
|
||||
|
||||
const typeFilter = ref('') // '' | 'openai' | 'adobe' | 'runway' | 'leonardo'
|
||||
const statusFilter = ref('') // '' | 'active' | 'quota' | 'disabled'
|
||||
const planFilter = ref('') // '' | 'vip' | 'free' | 'sub' | 'master'
|
||||
const search = ref('')
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
// Typing a search term must jump back to page 1 and re-query the server —
|
||||
// search is cross-page now (server-side).
|
||||
let searchTimer = null
|
||||
watch(search, () => {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => { resetAndLoad() }, 300)
|
||||
})
|
||||
|
||||
const EMPTY_TYPE = { n: 0, ok: 0, dead: 0, quota: 0 }
|
||||
// 每个类型的 成功/失败/限额 三个数 — 由后端对全量账号统计(与筛选/分页无关)。
|
||||
const stats = ref({
|
||||
total: 0, dead_total: 0,
|
||||
openai: { ...EMPTY_TYPE }, adobe: { ...EMPTY_TYPE }, runway: { ...EMPTY_TYPE },
|
||||
leonardo: { ...EMPTY_TYPE }, krea: { ...EMPTY_TYPE }, imagine: { ...EMPTY_TYPE },
|
||||
grok: { ...EMPTY_TYPE }, creativefabrica: { ...EMPTY_TYPE },
|
||||
})
|
||||
|
||||
// 异常账号 = 已失效(401)被锁定的号(红色锁定行)。用于「一键删除异常账号」。
|
||||
const deadCount = computed(() => stats.value.dead_total || 0)
|
||||
|
||||
function typePill(t) {
|
||||
return {
|
||||
adobe: 'bg-rose-500/10 text-rose-300 ring-rose-400/30',
|
||||
openai: 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30',
|
||||
runway: 'bg-orange-500/10 text-orange-300 ring-orange-400/30',
|
||||
leonardo: 'bg-amber-500/10 text-amber-300 ring-amber-400/30',
|
||||
krea: 'bg-sky-500/10 text-sky-300 ring-sky-400/30',
|
||||
imagine: 'bg-teal-500/10 text-teal-300 ring-teal-400/30',
|
||||
creativefabrica: 'bg-rose-500/10 text-rose-300 ring-rose-400/30',
|
||||
}[t] || 'bg-white/[0.06] text-white/70 ring-white/15'
|
||||
}
|
||||
// 与后端 accountConcurrency 保持一致:adobe 普号固定 1 并发,会员号取配置值
|
||||
// (未配置时默认 5);custom 上游取配置值 (默认 1);grok 固定 10,其余 1。
|
||||
function acctConcurrency(a) {
|
||||
if (a.type === 'adobe') return a.plan === 'free' ? 1 : (Number(a.concurrency) || 5)
|
||||
if (a.type === 'custom') return Number(a.concurrency) || 1
|
||||
return a.type === 'grok' ? 10 : 1
|
||||
}
|
||||
|
||||
const STATUS_LABEL = { active: '正常', quota: '额度耗尽', disabled: '已禁用', pending: '检测中' }
|
||||
|
||||
// Server-side pagination: rows IS the current page, already filtered/sorted
|
||||
// by the backend. total = server-side filtered count.
|
||||
const filtered = computed(() => rows.value)
|
||||
const pagedItems = computed(() => rows.value)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target !== page.value) page.value = target
|
||||
}
|
||||
function setFilter(fn) { fn(); resetAndLoad() }
|
||||
function resetAndLoad() {
|
||||
if (page.value !== 1) page.value = 1 // the page watcher triggers the load
|
||||
else loadAccounts()
|
||||
}
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value
|
||||
const cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
let pendingTimer = null
|
||||
|
||||
function buildQs() {
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(pageSize.value),
|
||||
offset: String((page.value - 1) * pageSize.value),
|
||||
})
|
||||
if (typeFilter.value) qs.set('type', typeFilter.value)
|
||||
if (statusFilter.value) qs.set('status', statusFilter.value)
|
||||
if (planFilter.value && typeFilter.value === 'adobe') qs.set('plan', planFilter.value)
|
||||
if (search.value.trim()) qs.set('q', search.value.trim())
|
||||
return qs.toString()
|
||||
}
|
||||
|
||||
async function fetchAccounts() {
|
||||
const r = await api('/accounts?' + buildQs())
|
||||
rows.value = r.data?.data || []
|
||||
total.value = Number(r.data?.total ?? rows.value.length)
|
||||
if (r.data?.stats) stats.value = r.data.stats
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
loading.value = true
|
||||
quotaStatus.value = ''
|
||||
await fetchAccounts()
|
||||
loading.value = false
|
||||
if (rows.value.length) reconcile()
|
||||
schedulePendingPoll()
|
||||
}
|
||||
|
||||
// While any imported account is still being checked server-side, re-fetch the
|
||||
// list so it flips pending → active/dead on its own (no manual refresh).
|
||||
function schedulePendingPoll() {
|
||||
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
|
||||
if (!rows.value.some((r) => r.pending)) return
|
||||
pendingTimer = setTimeout(async () => {
|
||||
await fetchAccounts()
|
||||
schedulePendingPoll()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Background reconciliation: openai → live quota; adobe → reset_after;
|
||||
// adobe without email → fetch email. Only ACTIVE accounts — pending ones are
|
||||
// handled by the import worker, dead/disabled ones need no re-check.
|
||||
//
|
||||
// Scope: ONLY the accounts visible on the current page. Probing all 100+ rows on
|
||||
// every open floods the backend; the user only ever sees ~20 at a time, so we
|
||||
// re-check just those and re-run when the page (or filters) change. New imports
|
||||
// are hydrated server-side by the import worker and surfaced via the pending
|
||||
// poll reading the store — they don't need a frontend probe.
|
||||
let reconcileToken = 0
|
||||
async function reconcile() {
|
||||
const myToken = ++reconcileToken // supersede any in-flight run (fast page flips)
|
||||
const visible = pagedItems.value
|
||||
// NEW accounts (still pending the import worker's server-side check) are never
|
||||
// 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
|
||||
// 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' || r.type === 'grok'))
|
||||
const adobeNeedEmail = visible.filter((r) => !r.pending && r.type === 'adobe' && !r.email)
|
||||
const total = quotaRows.length + adobeNeedEmail.length
|
||||
if (total === 0) { quotaStatus.value = ''; return }
|
||||
|
||||
let done = 0, updates = 0
|
||||
quotaStatus.value = `后台校对… 0/${total}`
|
||||
const bump = () => {
|
||||
done++
|
||||
if (myToken !== reconcileToken) return // a newer page-flip superseded us
|
||||
quotaStatus.value = `后台校对… ${done}/${total}${updates ? ` · 更新 ${updates}` : ''}`
|
||||
}
|
||||
|
||||
// Build thunks (NOT immediately-invoked) so the pool controls how many run at
|
||||
// once. Each /accounts/.../quota probe is a *synchronous* backend call to
|
||||
// OpenAI/Adobe. We only probe the visible page (≤ pageSize rows), so the
|
||||
// limit below is effectively bounded by that — no full-list flood.
|
||||
const jobs = []
|
||||
for (const row of quotaRows) {
|
||||
jobs.push(async () => {
|
||||
const result = await fetchOneQuota(row.pool, row.id)
|
||||
if (result && result.auth_failed) {
|
||||
// backend auto-disabled this dead (401) token — reflect it immediately
|
||||
row.status = result.status || 'disabled'
|
||||
row.dead = true
|
||||
row.remaining = null
|
||||
row._unknown = true
|
||||
updates++
|
||||
} else if (result && result.unchanged === false) {
|
||||
if (row.type === 'adobe') {
|
||||
row.reset_after = result.reset_after
|
||||
applyQuota(row, result)
|
||||
} else applyQuota(row, result)
|
||||
updates++
|
||||
}
|
||||
bump()
|
||||
})
|
||||
}
|
||||
for (const row of adobeNeedEmail) {
|
||||
jobs.push(async () => {
|
||||
const result = await fetchOneEmail(row.pool, row.id)
|
||||
if (result && result.email && (!row.email || row.email === '—')) {
|
||||
row.email = result.email
|
||||
updates++
|
||||
}
|
||||
bump()
|
||||
})
|
||||
}
|
||||
await runWithLimit(jobs, Infinity) // no JS-side cap — fire all visible-page probes at once (browser still limits ~6 conns/origin)
|
||||
// clear the indicator when done — but only if we're still the current run
|
||||
// (a page flip mid-reconcile starts a fresh one that owns the indicator).
|
||||
if (myToken === reconcileToken) quotaStatus.value = ''
|
||||
}
|
||||
|
||||
// Flipping pages re-queries the server for the new page; loadAccounts() then
|
||||
// reconciles just the freshly visible rows. Filter buttons go through
|
||||
// setFilter → resetAndLoad, so everything funnels into loadAccounts.
|
||||
watch(page, () => { loadAccounts() })
|
||||
|
||||
// Bounded-concurrency runner: keeps at most `limit` thunks in flight at once.
|
||||
async function runWithLimit(thunks, limit) {
|
||||
let next = 0
|
||||
const workers = Array.from({ length: Math.min(limit, thunks.length) }, async () => {
|
||||
while (next < thunks.length) {
|
||||
const idx = next++
|
||||
await thunks[idx]()
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
async function fetchOneQuota(pool, id) {
|
||||
try { return (await api(`/accounts/${pool}/${id}/quota`)).data || {} }
|
||||
catch (e) { return { error: String(e) } }
|
||||
}
|
||||
async function fetchOneEmail(pool, id) {
|
||||
try { return (await api(`/accounts/${pool}/${id}/email`)).data || {} }
|
||||
catch (e) { return { error: String(e) } }
|
||||
}
|
||||
|
||||
function applyQuota(row, result) {
|
||||
// A transient probe error (e.g. connection reset when OpenAI is unreachable
|
||||
// without a proxy) must NOT blank the cached number — keep the last-known
|
||||
// value so a network blip doesn't turn the whole column into "—".
|
||||
if (result.error) { row._quotaError = result.error; return }
|
||||
row._quotaError = null
|
||||
if (result.unknown && result.remaining === null) { row.remaining = null; row._unknown = true; return }
|
||||
row._unknown = false
|
||||
row.remaining = result.remaining
|
||||
if (result.image_remaining !== undefined) row.image_remaining = result.image_remaining
|
||||
if (result.video_remaining !== undefined) row.video_remaining = result.video_remaining
|
||||
row.reset_after = result.reset_after
|
||||
// 会员身份随额度一起回写:改过会员/掉了会员的号,查一次额度徽章就跟着更新。
|
||||
if (result.plan !== undefined) row.plan = result.plan
|
||||
if (result.sub_account !== undefined) row.sub_account = result.sub_account
|
||||
}
|
||||
|
||||
async function toggleAccountStatus(pool, id, current) {
|
||||
const row = rows.value.find((r) => r.pool === pool && r.id === id)
|
||||
const next = current === 'active' ? 'disabled' : 'active'
|
||||
// Optimistic: flip the switch instantly so the UI never waits on the network.
|
||||
// The PATCH itself is a cheap in-memory update server-side; the old 5s lag came
|
||||
// from the follow-up loadAccounts() → reconcile() probing every account's quota.
|
||||
if (row) row.status = next
|
||||
try {
|
||||
const r = await api(`/tokens/${pool}/${id}`, jsonBody('PATCH', { status: next }))
|
||||
if (!r.ok && row) row.status = current // revert on server rejection
|
||||
} catch (e) {
|
||||
if (row) row.status = current // revert on network error
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount(pool, id) {
|
||||
if (!confirm(`确认删除 ${pool} / ${id}?`)) return
|
||||
await api(`/tokens/${pool}/${id}`, { method: 'DELETE' })
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// 一键删除全部异常(已失效/红色锁定)账号。先向服务端要全量 dead 列表(跨页),再逐个删除。
|
||||
async function deleteDeadAccounts() {
|
||||
if (!deadCount.value) return
|
||||
if (!confirm(`确认删除全部 ${deadCount.value} 个异常(已失效)账号?此操作不可撤销。`)) return
|
||||
const r = await api('/accounts?dead=1&limit=0')
|
||||
const dead = r.data?.data || []
|
||||
if (!dead.length) return
|
||||
await Promise.all(dead.map((a) => api(`/tokens/${a.pool}/${a.id}`, { method: 'DELETE' })))
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// ===== 多选删除 =====
|
||||
const selected = ref(new Set())
|
||||
function toggleSelect(id) {
|
||||
const s = new Set(selected.value)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
selected.value = s
|
||||
}
|
||||
// Header checkbox selects/deselects the CURRENT PAGE only.
|
||||
const allSelected = computed(() =>
|
||||
pagedItems.value.length > 0 && pagedItems.value.every((a) => selected.value.has(a.id)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) pagedItems.value.forEach((a) => s.delete(a.id))
|
||||
else pagedItems.value.forEach((a) => s.add(a.id))
|
||||
selected.value = s
|
||||
}
|
||||
async function deleteSelected() {
|
||||
const ids = [...selected.value]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`确认删除选中的 ${ids.length} 个账号?此操作不可撤销。`)) return
|
||||
const r = await api('/tokens/delete-bulk', jsonBody('POST', { ids }))
|
||||
if (r.ok) {
|
||||
selected.value = new Set()
|
||||
loadAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadAccounts(); loadModelList() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — 每个类型显示 成功/失败/限额 三个数(绿/红/琥珀) -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-7 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/45">账号总数</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>
|
||||
<div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-orange-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'],['creativefabrica','Creative Fabrica','text-rose-300/80']]"
|
||||
:key="t[0]" class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider" :class="t[2]">{{ t[1] }}</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">
|
||||
<span class="text-emerald-300">{{ stats[t[0]].ok }}</span><span class="text-white/30">/</span><span class="text-rose-300">{{ stats[t[0]].dead }}</span><span class="text-white/30">/</span><span class="text-amber-300">{{ stats[t[0]].quota }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5">共 {{ stats[t[0]].n }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button>
|
||||
<button @click="setFilter(() => typeFilter = 'openai')" class="fp" :class="typeFilter === 'openai' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>OpenAI
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'adobe')" class="fp" :class="typeFilter === 'adobe' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>Adobe
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'runway')" class="fp" :class="typeFilter === 'runway' && 'fp-orange'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-orange-400"></span>Runway
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'leonardo')" class="fp" :class="typeFilter === 'leonardo' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>Leonardo
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'krea')" class="fp" :class="typeFilter === 'krea' && 'fp-sky'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-sky-400"></span>Krea
|
||||
</button>
|
||||
<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
|
||||
</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>
|
||||
<button @click="setFilter(() => typeFilter = 'creativefabrica')" class="fp" :class="typeFilter === 'creativefabrica' && 'fp-on'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>Creative Fabrica
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>正常
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'quota')" class="fp" :class="statusFilter === 'quota' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>额度耗尽
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'disabled')" class="fp" :class="statusFilter === 'disabled' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>已禁用
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="typeFilter === 'adobe'">
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => planFilter = '')" class="fp" :class="planFilter === '' && 'fp-on'">全部账号</button>
|
||||
<button @click="setFilter(() => planFilter = 'free')" class="fp" :class="planFilter === 'free' && 'fp-slate'">普号</button>
|
||||
<button @click="setFilter(() => planFilter = 'sub')" class="fp" :class="planFilter === 'sub' && 'fp-sky'">子号</button>
|
||||
<button @click="setFilter(() => planFilter = 'master')" class="fp" :class="planFilter === 'master' && 'fp-pink'">会员号</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / ID / 类型…" />
|
||||
</div>
|
||||
<button v-if="selected.size" @click="deleteSelected" class="btn-soft danger" title="删除选中的账号">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
|
||||
</button>
|
||||
<button v-if="deadCount" @click="deleteDeadAccounts" class="btn-soft danger" title="删除全部已失效(401)账号">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除异常账号 ({{ deadCount }})
|
||||
</button>
|
||||
<button @click="loadAccounts" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button @click="showImport = true" class="btn-primary">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 导入账号
|
||||
</button>
|
||||
<button @click="editingUpstream = null; showUpstream = true" class="btn-soft">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 添加上游
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-x-auto">
|
||||
<div v-if="loading && !rows.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
||||
<Icon name="accounts" class="w-6 h-6" />
|
||||
</span>
|
||||
<span class="text-sm">{{ stats.total ? '没有匹配的账号' : '还没有账号' }}</span>
|
||||
<button v-if="!stats.total" @click="showImport = true" class="btn-soft mt-1">导入第一个</button>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm table-fixed min-w-[1080px]">
|
||||
<colgroup>
|
||||
<col class="w-9" /> <!-- select -->
|
||||
<col /> <!-- identity (flex) -->
|
||||
<col class="w-20" /> <!-- type -->
|
||||
<col class="w-24" /> <!-- remaining -->
|
||||
<col class="w-16" /> <!-- weight -->
|
||||
<col class="w-16" /> <!-- concurrency -->
|
||||
<col class="w-32" /> <!-- reset -->
|
||||
<col class="w-28" /> <!-- created -->
|
||||
<col class="w-28" /> <!-- last used -->
|
||||
<col class="w-40" /> <!-- inflight/success/fail -->
|
||||
<col class="w-16" /> <!-- status switch -->
|
||||
<col class="w-32" /> <!-- actions -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-center px-3 py-3 font-medium">
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
|
||||
class="chk" title="全选" />
|
||||
</th>
|
||||
<th class="text-left px-5 py-3 font-medium">账户</th>
|
||||
<th class="text-left px-3 py-3 font-medium">类型</th>
|
||||
<th class="text-right px-3 py-3 font-medium">额度</th>
|
||||
<th class="text-center px-3 py-3 font-medium">权重</th>
|
||||
<th class="text-center px-3 py-3 font-medium">并发</th>
|
||||
<th class="text-left px-3 py-3 font-medium">恢复时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">创建时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">最后使用</th>
|
||||
<th class="text-center px-3 py-3 font-medium">在途 / 成功 / 失败</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-right px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in pagedItems" :key="a.pool + '/' + a.id"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"
|
||||
:class="a.dead && 'dead-row'">
|
||||
<!-- select -->
|
||||
<td class="px-3 py-3.5 align-middle text-center">
|
||||
<input type="checkbox" :checked="selected.has(a.id)" @change="toggleSelect(a.id)" @click.stop
|
||||
class="chk" />
|
||||
</td>
|
||||
<!-- identity -->
|
||||
<td class="px-5 py-3.5 align-middle">
|
||||
<!-- email + per-kind quota markers on one line. Both-limited shows as
|
||||
额度耗尽 in the status column, so here we only surface the single
|
||||
case. -->
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-white/90 truncate" :title="a.email || '-'">{{ a.email || '-' }}</span>
|
||||
<!-- 身份互斥:子号必然是会员号,所以 子号 > 母号 > 普号 只取一个。
|
||||
plan 未知(还没查过额度)时不猜,先不显示。 -->
|
||||
<span v-if="a.sub_account" class="acct-tag acct-tag-sub"
|
||||
:title="'子号 · 会员 (剩余 ' + (a.remaining ?? '—') + ' 积分)'">子号</span>
|
||||
<span v-else-if="a.plan && a.plan !== 'free'" class="acct-tag acct-tag-master"
|
||||
:title="'会员号 (' + a.plan + ')'">会员号</span>
|
||||
<span v-else-if="a.plan === 'free'" class="acct-tag acct-tag-free"
|
||||
title="普号 (无会员)">普号</span>
|
||||
<span v-if="a.image_limited && a.status !== 'quota' && a.plan !== 'free'" class="acct-tag acct-tag-limit"
|
||||
title="图片额度耗尽,仅视频可用">图片限额</span>
|
||||
<span v-if="a.video_limited && a.status !== 'quota' && a.plan !== 'free'" class="acct-tag acct-tag-limit"
|
||||
title="视频额度耗尽,仅图片可用">视频限额</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- type -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
|
||||
:class="typePill(a.type)">{{ a.type }}</span>
|
||||
</td>
|
||||
<!-- remaining -->
|
||||
<td class="px-3 py-3.5 align-middle text-right text-sm tabular-nums whitespace-nowrap">
|
||||
<!-- quota column: 数字 / — (never "未知"/"失败"/"检测中") -->
|
||||
<!-- remaining === -1 is the provider "unlimited" sentinel → show — not a scary red -1 -->
|
||||
<!-- grok 额度是本地写死的两份计数:图 / 视频,用完即判死,没有恢复时间 -->
|
||||
<span v-if="a.type === 'grok' && a.image_remaining != null"
|
||||
class="font-mono font-semibold" title="图片剩余 / 视频剩余">
|
||||
<span :class="a.image_remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.image_remaining }}</span>
|
||||
<span class="text-white/20">/</span>
|
||||
<span :class="a.video_remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.video_remaining }}</span>
|
||||
</span>
|
||||
<span v-else-if="(a.type === 'openai' || a.type === 'adobe' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine' || a.type === 'creativefabrica') && a.remaining != null && a.remaining !== -1"
|
||||
class="font-mono font-semibold"
|
||||
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}</span>
|
||||
<span v-else class="text-white/25" :title="a._quotaError || ''">—</span>
|
||||
</td>
|
||||
<!-- weight (edit via modal) -->
|
||||
<td class="px-3 py-3.5 align-middle text-center whitespace-nowrap text-xs tabular-nums text-white/70">
|
||||
{{ a.weight ?? 0 }}
|
||||
</td>
|
||||
<!-- concurrency (custom = configured value; others = system fixed) -->
|
||||
<td class="px-3 py-3.5 align-middle text-center whitespace-nowrap text-xs tabular-nums">
|
||||
<span v-if="a.type === 'custom'" class="text-white/70">{{ acctConcurrency(a) }}</span>
|
||||
<span v-else class="text-white/25" title="系统固定">{{ acctConcurrency(a) }}</span>
|
||||
</td>
|
||||
<!-- reset_after -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="a.reset_after" class="leading-tight" :title="fmtIso(a.reset_after)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.reset_after) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.reset_after) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">—</span>
|
||||
</td>
|
||||
<!-- created_at -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div class="leading-tight" :title="fmtTs(a.created_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.created_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.created_at) }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- last_used_at -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="a.last_used_at" class="leading-tight" :title="fmtTs(a.last_used_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.last_used_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.last_used_at) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">从未</span>
|
||||
</td>
|
||||
<!-- inflight / success / fail -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<div class="flex items-center justify-center gap-1.5 text-xs tabular-nums">
|
||||
<span class="px-1.5 py-0.5 rounded"
|
||||
:class="a.in_flight ? 'bg-sky-500/15 text-sky-300 font-semibold' : 'text-white/25'"
|
||||
title="在途">{{ a.in_flight || 0 }}</span>
|
||||
<span class="text-white/20">/</span>
|
||||
<span class="px-1.5 py-0.5 rounded text-emerald-300 font-medium" title="成功">{{ a.success_total || 0 }}</span>
|
||||
<span class="text-white/20">/</span>
|
||||
<span class="px-1.5 py-0.5 rounded"
|
||||
:class="a.fail_total ? 'bg-rose-500/15 text-rose-300 font-medium' : 'text-white/25'"
|
||||
title="失败">{{ a.fail_total || 0 }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- status (switch) -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<button class="sw"
|
||||
:class="{ 'sw-on': a.status === 'active', 'sw-dead': a.dead, 'sw-pending': a.status === 'pending', 'sw-quota': a.status === 'quota', 'sw-locked': a.dead || a.status === 'pending' || a.status === 'quota' }"
|
||||
:disabled="a.dead || a.status === 'pending' || a.status === 'quota'"
|
||||
:aria-pressed="a.status === 'active'"
|
||||
:title="a.status === 'pending' ? '正在检测额度…(暂不调度)' : (a.dead ? '号已失效(401) · 已锁定(删除后重新导入有效令牌)' : (a.status === 'quota' ? '额度耗尽 · 已锁定,到恢复时间自动解开' : (a.status === 'active' ? '点击禁用' : '点击启用')))"
|
||||
@click="!(a.dead || a.status === 'pending' || a.status === 'quota') && toggleAccountStatus(a.pool, a.id, a.status)">
|
||||
<span class="sw-thumb"></span>
|
||||
</button>
|
||||
</td>
|
||||
<!-- actions -->
|
||||
<td class="px-3 py-3.5 align-middle whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button @click="testAccount(a)" class="act" title="生图测试">
|
||||
<Icon name="spark" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button v-if="a.type !== 'custom'" @click="editAccount(a)" class="act" title="编辑">
|
||||
<Icon name="config" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button v-if="a.type === 'custom'" @click="editUpstream(a)" class="act" title="编辑上游">
|
||||
<Icon name="config" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="deleteAccount(a.pool, a.id)" class="act danger" title="删除">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination -->
|
||||
<div v-if="!loading && totalPages > 1"
|
||||
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
||||
<div>
|
||||
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
|
||||
<span class="ml-1">/ {{ total }} 条</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-white/35">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImportModal v-if="showImport" @close="showImport = false" @imported="loadAccounts" />
|
||||
<UpstreamModal v-if="showUpstream" :account="editingUpstream" @close="showUpstream = false; editingUpstream = null" @imported="loadAccounts" />
|
||||
<AccountEditModal v-if="editingAccount" :account="editingAccount" @saved="applyEdit" @close="editingAccount = null" />
|
||||
<AccountTestModal v-if="testingAccount" :account="testingAccount" :all-models="allModels" @close="testingAccount = null" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* --- filter pills (mirrors LogsView/UsersView/ModelsView) */
|
||||
.fp {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem; font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-rose {
|
||||
background: rgb(244 63 94 / 0.22);
|
||||
color: rgb(253 164 175);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45);
|
||||
}
|
||||
.fp-amber {
|
||||
background: rgb(245 158 11 / 0.22);
|
||||
color: rgb(253 224 71);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 224 71 / 0.4);
|
||||
}
|
||||
.fp-orange {
|
||||
background: rgb(249 115 22 / 0.22);
|
||||
color: rgb(253 186 116);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 186 116 / 0.45);
|
||||
}
|
||||
.fp-sky {
|
||||
background: rgb(56 189 248 / 0.22);
|
||||
color: rgb(125 211 252);
|
||||
box-shadow: inset 0 0 0 1px rgb(125 211 252 / 0.45);
|
||||
}
|
||||
.fp-teal {
|
||||
background: rgb(20 184 166 / 0.22);
|
||||
color: rgb(94 234 212);
|
||||
box-shadow: inset 0 0 0 1px rgb(94 234 212 / 0.45);
|
||||
}
|
||||
.fp-pink {
|
||||
background: rgb(244 114 182 / 0.22);
|
||||
color: rgb(249 168 212);
|
||||
box-shadow: inset 0 0 0 1px rgb(249 168 212 / 0.45);
|
||||
}
|
||||
.fp-slate {
|
||||
background: rgb(148 163 184 / 0.22);
|
||||
color: rgb(203 213 225);
|
||||
box-shadow: inset 0 0 0 1px rgb(203 213 225 / 0.4);
|
||||
}
|
||||
|
||||
/* --- icon-only action buttons */
|
||||
.act {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.9rem; height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.act.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
|
||||
/* toolbar 「删除异常账号」按钮 — rose 变体 */
|
||||
.btn-soft.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.btn-soft.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
|
||||
/* iOS-style switch (mirrors UsersView/ModelsView) */
|
||||
.sw {
|
||||
position: relative;
|
||||
width: 2.25rem; height: 1.3rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.sw-thumb {
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw-on {
|
||||
background: rgb(16 185 129 / 0.7);
|
||||
box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5);
|
||||
}
|
||||
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
/* dead account (401) — red, thumb stays left */
|
||||
.sw-dead {
|
||||
background: rgb(244 63 94 / 0.8);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.6);
|
||||
}
|
||||
/* pending (import quota probe in flight) — neutral sky, thumb stays left */
|
||||
.sw-pending {
|
||||
background: rgb(14 165 233 / 0.45);
|
||||
box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.4);
|
||||
}
|
||||
/* quota exhausted — amber (NOT red/dead), thumb stays left, locked until reset */
|
||||
.sw-quota {
|
||||
background: rgb(245 158 11 / 0.5);
|
||||
box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.45);
|
||||
}
|
||||
/* dead / pending toggle is locked — can't be flipped */
|
||||
.sw-locked { cursor: not-allowed; }
|
||||
/* tint the whole row so a dead account is obvious at a glance */
|
||||
.dead-row { background: rgb(244 63 94 / 0.07); }
|
||||
.dead-row:hover { background: rgb(244 63 94 / 0.12); }
|
||||
|
||||
/* --- numbered pagination buttons */
|
||||
.pg {
|
||||
min-width: 1.75rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 0.45rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
</style>
|
||||
@@ -73,9 +73,24 @@ const countOptions = [1, 2, 3, 4].map((n) => ({ value: n, label: `${n} 张` }))
|
||||
const canvasOptions = computed(() =>
|
||||
canvases.value.map((c) => ({ value: c.id, label: c.title || '未命名画布' })),
|
||||
)
|
||||
const maxRefs = computed(() => Math.min(8, Math.max(
|
||||
Number(model.value?.max_reference_images || 0), model.value?.image_to_image ? 1 : 0,
|
||||
)))
|
||||
// 参考文件分类上限:custom 模型直接读模型三个字段;老模型只有
|
||||
// max_reference_images(各类合计)或 image_to_image 兜底 1 张图。
|
||||
const refCaps = computed(() => {
|
||||
const m = model.value || {}
|
||||
const imgs = Number(m.max_reference_images || 0) || (m.image_to_image ? 1 : 0)
|
||||
const vids = Number(m.max_reference_videos || 0)
|
||||
const auds = Number(m.max_reference_audios || 0)
|
||||
return { imgs, vids, auds, total: imgs + vids + auds }
|
||||
})
|
||||
const maxRefs = computed(() => Math.min(20, refCaps.value.total))
|
||||
const supportsMediaRefs = computed(() => refCaps.value.vids > 0 || refCaps.value.auds > 0)
|
||||
const refFileAccept = computed(() => (supportsMediaRefs.value ? 'image/*,video/*,audio/*' : 'image/*'))
|
||||
const refCounts = computed(() => {
|
||||
const imgs = refImages.value.filter((f) => !f.fileType || f.fileType === 'image').length
|
||||
const vids = refImages.value.filter((f) => f.fileType === 'video').length
|
||||
const auds = refImages.value.filter((f) => f.fileType === 'audio').length
|
||||
return { imgs, vids, auds }
|
||||
})
|
||||
// 换类型/模型时重置参数默认值(和画图台一致)
|
||||
watch(() => gen.kind, () => {
|
||||
const first = kindModels.value[0]
|
||||
@@ -576,7 +591,7 @@ function onPaste(e) {
|
||||
// ---- 参考图上传(与画图台一致:直接传文件,不再用选中节点) ----
|
||||
const refImages = ref([]) // { name, dataUrl }
|
||||
const refInput = ref(null)
|
||||
const MAX_REF_BYTES = 15 * 1024 * 1024
|
||||
const MAX_REF_BYTES = 20 * 1024 * 1024
|
||||
|
||||
function fileToDataUrl(f) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -587,23 +602,37 @@ function fileToDataUrl(f) {
|
||||
})
|
||||
}
|
||||
async function addRefFile(f) {
|
||||
if (!f?.type?.startsWith('image/')) return
|
||||
const t = f?.type || ''
|
||||
const isImage = t.startsWith('image/')
|
||||
const isVideo = t.startsWith('video/')
|
||||
const isAudio = t.startsWith('audio/')
|
||||
if (!isImage && !(supportsMediaRefs.value && (isVideo || isAudio))) return
|
||||
if (refImages.value.length >= maxRefs.value) {
|
||||
genMsg.value = `参考图最多 ${maxRefs.value} 张`
|
||||
genMsg.value = `参考文件最多 ${maxRefs.value} 个`
|
||||
return
|
||||
}
|
||||
if (f.size > MAX_REF_BYTES) {
|
||||
genMsg.value = '单张参考图不能超过 15MB'
|
||||
genMsg.value = '单个参考文件不能超过 20MB'
|
||||
return
|
||||
}
|
||||
refImages.value.push({ name: f.name, dataUrl: await fileToDataUrl(f) })
|
||||
const caps = refCaps.value
|
||||
const counts = refCounts.value
|
||||
const kind = isVideo ? 'video' : isAudio ? 'audio' : 'image'
|
||||
if (isVideo && counts.vids >= caps.vids) { genMsg.value = `视频参考最多 ${caps.vids} 个`; return }
|
||||
if (isAudio && counts.auds >= caps.auds) { genMsg.value = `音频参考最多 ${caps.auds} 个`; return }
|
||||
if (isImage && counts.imgs >= caps.imgs) { genMsg.value = `图片参考最多 ${caps.imgs} 张`; return }
|
||||
refImages.value.push({ name: f.name, dataUrl: await fileToDataUrl(f), fileType: kind })
|
||||
}
|
||||
// 画布图片节点 → 参考图(记录 nodeId,生成时自动连边标明来源)
|
||||
async function addNodeAsRef(n) {
|
||||
if (!n || n.type !== 'image' || !n.src) return
|
||||
if (refImages.value.some((r) => r.nodeId === n.id)) return
|
||||
if (refImages.value.length >= maxRefs.value) {
|
||||
genMsg.value = `参考图最多 ${maxRefs.value} 张`
|
||||
genMsg.value = `参考文件最多 ${maxRefs.value} 个`
|
||||
return
|
||||
}
|
||||
if (refCounts.value.imgs >= refCaps.value.imgs) {
|
||||
genMsg.value = `图片参考最多 ${refCaps.value.imgs} 张`
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -952,21 +981,22 @@ const saveLabel = computed(() => ({
|
||||
<div class="cv-composer">
|
||||
<div v-if="maxRefs > 0" class="cv-composer__refs">
|
||||
<div v-for="(r, i) in refImages" :key="i" class="cv-refthumb">
|
||||
<img :src="r.dataUrl" :alt="r.name">
|
||||
<img v-if="!r.fileType || r.fileType === 'image'" :src="r.dataUrl" :alt="r.name">
|
||||
<span v-else class="cv-refthumb__media">{{ r.fileType === 'video' ? '🎬' : '🔊' }}</span>
|
||||
<button type="button" class="cv-refthumb__x" title="移除" @click="removeRef(i)">✕</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="refImages.length < maxRefs"
|
||||
type="button"
|
||||
class="cv-refadd"
|
||||
title="上传参考图"
|
||||
:title="supportsMediaRefs ? '上传参考图/视频/音频' : '上传参考图'"
|
||||
@click="refInput?.click()"
|
||||
>
|
||||
<span class="cv-refadd__icon">+</span>
|
||||
<span>参考图</span>
|
||||
<span class="cv-refadd__n">{{ refImages.length }}/{{ maxRefs }}</span>
|
||||
</button>
|
||||
<input ref="refInput" type="file" accept="image/*" multiple hidden @change="onRefPicked">
|
||||
<input ref="refInput" type="file" :accept="refFileAccept" multiple hidden @change="onRefPicked">
|
||||
</div>
|
||||
<textarea
|
||||
v-model="gen.prompt"
|
||||
@@ -1340,6 +1370,13 @@ const saveLabel = computed(() => ({
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.cv-refthumb__media {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
}
|
||||
.cv-refthumb__x {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { fmtTs, fmtDate } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const KIND_LABEL = { image: '图像', video: '视频', text: '文本' }
|
||||
|
||||
const channels = ref([])
|
||||
const loading = ref(false)
|
||||
const err = ref('')
|
||||
|
||||
// create/edit form
|
||||
const form = ref(null)
|
||||
const saving = ref(false)
|
||||
const formErr = ref('')
|
||||
|
||||
// fetch-models → pick → import
|
||||
const fetchTarget = ref(null) // channel snapshot being imported into
|
||||
const fetching = ref(false)
|
||||
const fetchErr = ref('')
|
||||
const specs = ref([])
|
||||
const picked = ref(new Set())
|
||||
const importing = ref(false)
|
||||
const managedIds = ref(new Set())
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
err.value = ''
|
||||
const r = await api('/channels')
|
||||
if (r.ok) channels.value = r.data?.data || []
|
||||
else err.value = r.data?.detail || '渠道加载失败'
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadManaged() {
|
||||
const r = await api('/managed-models')
|
||||
managedIds.value = new Set((r.data?.data || []).map((m) => m.id))
|
||||
}
|
||||
|
||||
function modelSummary(c) {
|
||||
const names = c.models || []
|
||||
if (!names.length) return ''
|
||||
return names.length <= 2 ? names.join('、') : `${names[0]}、${names[1]} 等`
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
formErr.value = ''
|
||||
form.value = { id: '', name: '', base_url: '', api_key: '', concurrency: 5, weight: 1, enabled: true }
|
||||
}
|
||||
function openEdit(c) {
|
||||
formErr.value = ''
|
||||
form.value = { id: c.id, name: c.name, base_url: c.base_url, api_key: '', concurrency: c.concurrency || 0, weight: c.weight || 1, enabled: !!c.enabled }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const f = form.value
|
||||
if (!f) return
|
||||
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
|
||||
if (!f.id && !f.api_key) { formErr.value = '请填写 API Key'; return }
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
formErr.value = ''
|
||||
const r = await api('/channels', jsonBody('POST', {
|
||||
id: f.id, name: f.name, base_url: f.base_url, api_key: f.api_key,
|
||||
enabled: !!f.enabled, concurrency: f.concurrency || 0, weight: f.weight || 1,
|
||||
}))
|
||||
saving.value = false
|
||||
if (!r.ok) { formErr.value = r.data?.detail || '保存失败'; return }
|
||||
form.value = null
|
||||
load()
|
||||
}
|
||||
|
||||
async function toggle(c) {
|
||||
const prev = !!c.enabled
|
||||
c.enabled = !prev
|
||||
const r = await api('/channels', jsonBody('POST', {
|
||||
id: c.id, name: c.name, base_url: c.base_url, api_key: '',
|
||||
enabled: c.enabled, concurrency: c.concurrency || 0, weight: c.weight || 1,
|
||||
}))
|
||||
if (!r.ok) { c.enabled = prev; err.value = r.data?.detail || '状态更新失败' }
|
||||
}
|
||||
|
||||
async function del(c) {
|
||||
if (!confirm(`确认删除渠道「${c.name || c.base_url}」?其绑定的模型若没有其他渠道接入,将一并删除。`)) return
|
||||
const r = await api('/channels/delete', jsonBody('POST', { id: c.id }))
|
||||
if (!r.ok) { err.value = r.data?.detail || '删除失败'; return }
|
||||
channels.value = channels.value.filter((x) => x.id !== c.id)
|
||||
}
|
||||
|
||||
// 表单内拉模型:编辑态走库内渠道(密钥不用重填);新渠道用表单里的地址+key 预览。
|
||||
function fetchFromForm() {
|
||||
const f = form.value
|
||||
if (!f) return
|
||||
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
|
||||
if (!f.id && !f.api_key) { formErr.value = '请填写 API Key'; return }
|
||||
openFetch({ id: f.id || '', name: f.name || f.base_url, base_url: f.base_url, api_key: f.api_key, concurrency: f.concurrency, weight: f.weight, enabled: !!f.enabled })
|
||||
}
|
||||
|
||||
async function openFetch(c) {
|
||||
fetchTarget.value = c
|
||||
specs.value = []
|
||||
picked.value = new Set()
|
||||
fetchErr.value = ''
|
||||
fetching.value = true
|
||||
const r = await api('/channels/fetch-models', jsonBody('POST', {
|
||||
id: c.id || '', base_url: c.base_url || '', api_key: c.api_key || '',
|
||||
}))
|
||||
fetching.value = false
|
||||
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
|
||||
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', imported: managedIds.value.has(s.id) }))
|
||||
picked.value = new Set(specs.value.filter((s) => !s.imported).map((s) => s.id))
|
||||
if (!picked.value.size) picked.value = new Set(specs.value.map((s) => s.id))
|
||||
}
|
||||
|
||||
function togglePick(id) {
|
||||
const next = new Set(picked.value)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
picked.value = next
|
||||
}
|
||||
|
||||
function specText(s) {
|
||||
const parts = []
|
||||
if (s.qualities?.length) parts.push(s.qualities.join('/'))
|
||||
if (s.duration) {
|
||||
if (s.duration.mode === 'fixed') parts.push(`固定 ${s.duration.seconds}s`)
|
||||
else if (s.duration.mode === 'range') parts.push(`${s.duration.min}-${s.duration.max}s`)
|
||||
else if (s.duration.mode === 'options') parts.push((s.duration.options || []).map((d) => `${d}s`).join('/'))
|
||||
}
|
||||
const refs = []
|
||||
if (s.refImages > 0) refs.push(`图片${s.refImages}`)
|
||||
if (s.refVideos > 0) refs.push(`视频${s.refVideos}${s.refVideoSeconds > 0 ? `≤${s.refVideoSeconds}s` : ''}`)
|
||||
if (s.refAudios > 0) refs.push(`音频${s.refAudios}${s.refAudioSeconds > 0 ? `≤${s.refAudioSeconds}s` : ''}`)
|
||||
if (refs.length) parts.push(`参考 ${refs.join('+')}`)
|
||||
return parts.join(' · ') || '-'
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (importing.value || !picked.value.size) return
|
||||
const models = specs.value
|
||||
.filter((s) => picked.value.has(s.id))
|
||||
.map((s) => {
|
||||
const { imported, ...rest } = s
|
||||
return rest
|
||||
})
|
||||
const target = fetchTarget.value
|
||||
importing.value = true
|
||||
try {
|
||||
if (!target.id) {
|
||||
// 表单预览路径:渠道尚未入库,先按快照保存再绑定;失败中止不建孤儿。
|
||||
const sr = await api('/channels', jsonBody('POST', {
|
||||
id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key,
|
||||
enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1,
|
||||
}))
|
||||
if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return }
|
||||
target.id = sr.data?.id
|
||||
form.value = null
|
||||
}
|
||||
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models }))
|
||||
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
|
||||
fetchTarget.value = null
|
||||
load()
|
||||
loadManaged()
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load(); loadManaged() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="text-sm font-medium">渠道</div>
|
||||
<p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 模型经「拉取上游模型」导入 · 余额实时探测</p>
|
||||
</div>
|
||||
<button @click="openAdd" class="btn-primary">
|
||||
<Icon name="plus" class="w-4 h-4" /> 添加渠道
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="err" class="text-xs text-rose-300">{{ err }}</p>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>渠道</th>
|
||||
<th>上游地址</th>
|
||||
<th>模型</th>
|
||||
<th>并发</th>
|
||||
<th>配比</th>
|
||||
<th class="text-right">成功调用</th>
|
||||
<th class="text-right">累计消耗</th>
|
||||
<th class="text-right">上游余额</th>
|
||||
<th>状态</th>
|
||||
<th>最近使用</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in channels" :key="c.id">
|
||||
<td>
|
||||
<div class="font-medium text-white/85">{{ c.name }}</div>
|
||||
<div class="text-[11px] text-white/35 font-mono">{{ c.id }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="max-w-[240px] truncate font-mono text-xs text-white/55" :title="c.base_url">{{ c.base_url }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="tabular-nums text-white/80">{{ (c.models || []).length }}</div>
|
||||
<div class="text-[11px] text-white/35 max-w-[180px] truncate" :title="(c.models || []).join('、')">{{ modelSummary(c) }}</div>
|
||||
</td>
|
||||
<td class="tabular-nums">{{ c.concurrency || '默认' }}</td>
|
||||
<td class="tabular-nums">{{ c.weight || 1 }}</td>
|
||||
<td class="text-right tabular-nums">{{ (c.success_total || 0).toLocaleString() }}</td>
|
||||
<td class="text-right tabular-nums">{{ (c.used_credits || 0).toLocaleString() }}</td>
|
||||
<td class="text-right tabular-nums">{{ c.upstream_balance == null ? '-' : c.upstream_balance.toLocaleString() }}</td>
|
||||
<td>
|
||||
<button @click="toggle(c)" class="inline-flex items-center gap-1.5 text-xs"
|
||||
:class="c.enabled ? 'text-emerald-300' : 'text-white/40'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="c.enabled ? 'bg-emerald-400' : 'bg-white/25'"></span>
|
||||
{{ c.enabled ? '启用' : '停用' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="text-xs text-white/45 whitespace-nowrap">{{ c.last_used_at ? fmtTs(c.last_used_at) : '-' }}</td>
|
||||
<td class="text-right whitespace-nowrap">
|
||||
<button @click="openFetch(c)" class="op" title="拉取上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button>
|
||||
<button @click="openEdit(c)" class="op" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button>
|
||||
<button @click="del(c)" class="op danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="loading"><td colspan="11" class="py-10 text-center text-white/40 text-sm">正在加载渠道…</td></tr>
|
||||
<tr v-else-if="!channels.length"><td colspan="11" class="py-10 text-center text-white/40 text-sm">没有渠道,点右上角「添加渠道」接入上游实例</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加 / 编辑渠道 -->
|
||||
<div v-if="form" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="form = null">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-lg">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">{{ form.id ? '编辑渠道' : '添加渠道' }}</h2>
|
||||
<button @click="form = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="lbl">渠道名称</label>
|
||||
<input v-model.trim="form.name" type="text" maxlength="64" class="field" placeholder="例如:主站" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">上游地址 <span class="text-rose-300">*</span></label>
|
||||
<input v-model.trim="form.base_url" type="text" maxlength="300" class="field" placeholder="https://example.com" />
|
||||
<p class="mt-1 text-[11px] text-white/35">同构 /v1 协议实例的站点地址,不带路径</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">API Key <span v-if="!form.id" class="text-rose-300">*</span></label>
|
||||
<input v-model.trim="form.api_key" type="text" class="field" :placeholder="form.id ? '留空保持不变' : 'vk_…'" autocomplete="off" />
|
||||
<p class="mt-1 text-[11px] text-white/35">上游站点的用户 API Key(vk_ 前缀),密文存储,不回显</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="lbl">并发数</label>
|
||||
<input v-model.number="form.concurrency" type="number" min="0" max="100" class="field" />
|
||||
<p class="mt-1 text-[11px] text-white/35">0 = 默认 5</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">配比</label>
|
||||
<input v-model.number="form.weight" type="number" min="1" max="100" class="field" />
|
||||
<p class="mt-1 text-[11px] text-white/35">同模型多渠道按配比分单</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="lbl !mb-0">状态</label>
|
||||
<button type="button" @click="form.enabled = !form.enabled" class="inline-flex items-center gap-1.5 text-xs"
|
||||
:class="form.enabled ? 'text-emerald-300' : 'text-white/40'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="form.enabled ? 'bg-emerald-400' : 'bg-white/25'"></span>
|
||||
{{ form.enabled ? '已启用' : '已停用' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" @click="fetchFromForm" :disabled="fetching" class="btn-ghost text-xs">
|
||||
{{ fetching ? '拉取中…' : '获取模型列表' }}
|
||||
</button>
|
||||
<span class="text-[11px] text-white/35">{{ form.id ? '用已保存的地址与密钥拉取' : '用上方的地址与 API Key 拉取;导入时会先自动保存渠道' }}</span>
|
||||
</div>
|
||||
<p v-if="formErr" class="text-xs text-rose-300">{{ formErr }}</p>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
|
||||
<button @click="form = null" class="btn-ghost">取消</button>
|
||||
<button @click="save" :disabled="saving" class="btn-primary">{{ saving ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拉取上游模型 → 勾选导入 -->
|
||||
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-2xl">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">上游模型 · {{ fetchTarget.name || fetchTarget.base_url }}</h2>
|
||||
<button @click="fetchTarget = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
|
||||
<div v-else-if="fetchErr" class="py-10 text-center text-rose-300 text-sm">{{ fetchErr }}</div>
|
||||
<template v-else>
|
||||
<p class="text-xs text-white/45 mb-3">勾选要导入的模型:能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名);已导入的模型自动标记(重复导入会刷新参数)。</p>
|
||||
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
|
||||
<div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto">
|
||||
<div v-for="s in specs" :key="s.id" @click="togglePick(s.id)"
|
||||
class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors"
|
||||
:class="!picked.has(s.id) && 'opacity-50'">
|
||||
<input type="checkbox" :checked="picked.has(s.id)" @click.stop @change="togglePick(s.id)" class="mt-1 accent-sky-400" />
|
||||
<div class="flex-1 min-w-0 space-y-1.5">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<code class="font-mono text-xs text-white/85">{{ s.id }}</code>
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px] font-semibold"
|
||||
:class="s.kind === 'video' ? 'bg-rose-400/15 text-rose-300' : 'bg-sky-400/15 text-sky-300'">
|
||||
{{ KIND_LABEL[s.kind] || s.kind }}
|
||||
</span>
|
||||
<span class="text-[11px] text-white/40">{{ specText(s) }}</span>
|
||||
<span v-if="s.imported" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已导入</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" @click.stop>
|
||||
<span class="text-[11px] text-white/35 shrink-0">别名</span>
|
||||
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="!picked.has(s.id)"
|
||||
class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40"
|
||||
placeholder="留空 = 与上游同名" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
|
||||
<button @click="fetchTarget = null" class="btn-ghost">关闭</button>
|
||||
<button @click="doImport" :disabled="importing || !picked.size" class="btn-primary">
|
||||
{{ importing ? '导入中…' : (fetchTarget?.id ? `导入选中(${picked.size})` : `保存渠道并导入(${picked.size})`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tbl { width: 100%; border-collapse: collapse; min-width: 1000px; }
|
||||
.tbl thead th { padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 600; color: rgb(255 255 255 / 0.45); border-bottom: 1px solid rgb(255 255 255 / 0.06); white-space: nowrap; }
|
||||
.tbl tbody td { padding: 10px 14px; border-bottom: 1px solid rgb(255 255 255 / 0.04); font-size: 12.5px; color: rgb(255 255 255 / 0.7); vertical-align: middle; }
|
||||
.tbl tbody tr:last-child td { border-bottom: none; }
|
||||
.tbl tbody tr:hover { background: rgb(255 255 255 / 0.02); }
|
||||
|
||||
.lbl { display: block; font-size: 0.72rem; font-weight: 500; color: rgb(255 255 255 / 0.55); margin-bottom: 0.4rem; }
|
||||
|
||||
.op { display: inline-grid; place-items: center; min-width: 28px; height: 26px; padding: 0 7px; margin-left: 4px; border-radius: 7px; background: rgb(255 255 255 / 0.05); color: rgb(255 255 255 / 0.6); }
|
||||
.op:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.op.danger:hover { background: rgb(244 63 94 / 0.15); color: rgb(251 113 133); }
|
||||
</style>
|
||||
+107
-252
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
// API 对接文档 — OpenAI-compatible. Lists live models and shows ready-to-run
|
||||
// curl / Python(openai SDK) examples for image + video, wired to this
|
||||
// deployment's base URL and the caller's model ids.
|
||||
// API 对接文档 — Vivid 协议(/v1/generate NDJSON 长连接)。列出在售模型
|
||||
// 与计费档位,示例代码直接可跑。
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
@@ -10,7 +9,7 @@ import { points } from '../credits'
|
||||
import { mediaCaps, presetMap } from '../videoCaps'
|
||||
|
||||
const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy)
|
||||
const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'YOUR_API_KEY')
|
||||
const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'vk_xxxx')
|
||||
|
||||
const models = ref([])
|
||||
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
|
||||
@@ -31,8 +30,10 @@ const videoModels = computed(() => models.value.filter((m) => m.type === 'video'
|
||||
function pubName(m) {
|
||||
return m?.alias || m?.id || ''
|
||||
}
|
||||
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'firefly-image-4')
|
||||
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'firefly-kling3')
|
||||
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'gpt-image-2')
|
||||
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'video-model')
|
||||
const sampleQuality = computed(() => (imageModels.value[0]?.resolutions || [])[0] || '1K')
|
||||
const sampleVQuality = computed(() => (videoModels.value[0]?.resolutions || [])[0] || '720p')
|
||||
const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] || '8s').replace(/s$/, ''))
|
||||
|
||||
function modeEmpty(m) {
|
||||
@@ -61,183 +62,118 @@ function priceEmpty(m) {
|
||||
if (perSecondPrice(m) != null) return false
|
||||
return !(m.durations || []).some((d) => durPrice(m, d) != null)
|
||||
}
|
||||
// 视频时长展示:固定/离散档列出来;区间档(duration_min/max)显示 min–max s。
|
||||
function durText(m) {
|
||||
const ds = m.durations || []
|
||||
if (ds.length) return ds.join('/')
|
||||
if ((m.duration_max || 0) > 0) return `${m.duration_min}–${m.duration_max}s`
|
||||
return ''
|
||||
}
|
||||
|
||||
// ---- request parameter tables ----
|
||||
const imageParams = [
|
||||
['model', 'string', '必填', '模型名(别名优先),见上表(图像)'],
|
||||
['prompt', 'string', '必填', '文字描述'],
|
||||
['size', 'string', '可选', '宽x高,如 "1024x1024"。同时决定「比例」+「分辨率档」(按长边)。具体怎么填见下方对照表;留空 = 1:1 · 2K'],
|
||||
]
|
||||
const editParams = [
|
||||
['image', 'file', '必填', '输入图;多张参考图重复 image[] 字段(multipart 文件上传)'],
|
||||
['prompt', 'string', '必填', '编辑/参考描述'],
|
||||
['model', 'string', '必填', '模型名(别名优先,需支持图生图)'],
|
||||
['size', 'string', '可选', '同图像:决定比例 + 分辨率档(见下方对照表)'],
|
||||
]
|
||||
const videoParams = [
|
||||
['model', 'string', '必填', '模型名(别名优先),见上表(视频)'],
|
||||
['prompt', 'string', '必填', '文字描述'],
|
||||
['seconds', 'string|int', '必填', '时长秒数,如 "5" "8"(取决于模型支持)'],
|
||||
['size', 'string', '可选', '如 "1280x720" / "720x1280" → 决定比例与分辨率'],
|
||||
['input_reference', 'file', '可选', '参考图/首尾帧(multipart 文件上传;多张时重复 input_reference[] 字段)。runway 图生视频必填 1 张,张数上限见上方模型表'],
|
||||
['reference_mode', 'string', '可选', '参考图用途:"frame"=首尾帧(最多 2 张,第 1 张为首帧、第 2 张为尾帧,只传 1 张即仅首帧),"asset"=普通参考图。留空用模型默认;仅支持单一模式的模型不可传'],
|
||||
const generateParams = [
|
||||
['model', 'string', '必填', '模型名(别名优先),见上表 /v1/models 的 id'],
|
||||
['prompt', 'string', '必填', '文本描述'],
|
||||
['quality', 'string', '可选', '清晰度档位,取该模型 qualities 之一(图像如 1K/2K/4K,视频如 720p/1080p);不传 = 该模型最低档'],
|
||||
['ratio', 'string', '可选', '宽高比,如 "16:9";不传 = 类型默认'],
|
||||
['duration', 'int64', '可选', '视频时长(秒);range/options 模型必填,图片忽略'],
|
||||
['refs', 'array<string>', '可选', '参考素材 key 数组:先 POST /v1/uploads 拿预签名直传,PUT 上传后把返回的 key 放这里。30 分钟内有效,张数上限见模型表'],
|
||||
['jobId', 'string', '可选', '重连模式:流意外断开后只带 jobId 原样重发,不创建新任务、不重复扣费;已完成直接回终态帧'],
|
||||
]
|
||||
|
||||
// ---- size → 比例 × 分辨率档 对照表(用 size 该传的值)----
|
||||
// size 的长边映射档位:<1800→1K · 1800–3499→2K · ≥3500→4K;宽高比映射比例。
|
||||
const sizeTable = [
|
||||
{ ratio: '1:1 · 方', k1: '1024x1024', k2: '2048x2048', k4: '4096x4096' },
|
||||
{ ratio: '5:4 · 横', k1: '1280x1024', k2: '2560x2048', k4: '3840x3072' },
|
||||
{ ratio: '4:3 · 横', k1: '1024x768', k2: '2048x1536', k4: '4096x3072' },
|
||||
{ ratio: '3:2 · 横', k1: '1200x800', k2: '2400x1600', k4: '3600x2400' },
|
||||
{ ratio: '16:9 · 横', k1: '1280x720', k2: '2048x1152', k4: '4096x2304' },
|
||||
{ ratio: '2:1 · 横', k1: '1440x720', k2: '2880x1440', k4: '4096x2048' },
|
||||
{ ratio: '21:9 · 超宽', k1: '1680x720', k2: '2520x1080', k4: '5040x2160' },
|
||||
{ ratio: '3:1 · 超宽', k1: '1536x512', k2: '2304x768', k4: '3840x1280' },
|
||||
{ ratio: '4:1 · 超宽', k1: '1728x432', k2: '2880x720', k4: '4096x1024' },
|
||||
{ ratio: '8:1 · 超宽', k1: '1728x216', k2: '2880x360', k4: '4096x512' },
|
||||
{ ratio: '4:5 · 竖', k1: '1024x1280', k2: '2048x2560', k4: '3072x3840' },
|
||||
{ ratio: '3:4 · 竖', k1: '768x1024', k2: '1536x2048', k4: '3072x4096' },
|
||||
{ ratio: '2:3 · 竖', k1: '800x1200', k2: '1600x2400', k4: '2400x3600' },
|
||||
{ ratio: '9:16 · 竖', k1: '720x1280', k2: '1152x2048', k4: '2304x4096' },
|
||||
{ ratio: '1:3 · 竖', k1: '512x1536', k2: '768x2304', k4: '1280x3840' },
|
||||
{ ratio: '1:4 · 竖', k1: '432x1728', k2: '720x2880', k4: '1024x4096' },
|
||||
{ ratio: '1:8 · 竖', k1: '216x1728', k2: '360x2880', k4: '512x4096' },
|
||||
]
|
||||
|
||||
// ---- 视频 size → 比例 × 分辨率(720p / 1080p)----
|
||||
// 视频按「短边」判档:短边 <1080 → 720p,≥1080 → 1080p;宽高比映射比例。
|
||||
const videoSizeTable = [
|
||||
{ ratio: '16:9 · 横', p720: '1280x720', p1080: '1920x1080' },
|
||||
{ ratio: '9:16 · 竖', p720: '720x1280', p1080: '1080x1920' },
|
||||
{ ratio: '1:1 · 方', p720: '720x720', p1080: '1080x1080' },
|
||||
{ ratio: '4:3 · 横', p720: '960x720', p1080: '1440x1080' },
|
||||
{ ratio: '3:4 · 竖', p720: '720x960', p1080: '1080x1440' },
|
||||
{ ratio: '3:2 · 横', p720: '1080x720', p1080: '1620x1080' },
|
||||
{ ratio: '2:3 · 竖', p720: '720x1080', p1080: '1080x1620' },
|
||||
const uploadParams = [
|
||||
['kind', 'string', '必填', '素材大类:image / video / audio(与文件实际类型一致)'],
|
||||
['mime', 'string', '必填', '文件 MIME(如 image/png),须与 kind 自洽;PUT 上传的 Content-Type 必须与它一字不差(已签进 URL)'],
|
||||
]
|
||||
|
||||
// ---- examples (built in script so refs resolve correctly) ----
|
||||
const examples = computed(() => [
|
||||
{
|
||||
title: '文生图 · curl',
|
||||
title: '文生图 · curl (NDJSON 流)',
|
||||
code:
|
||||
`curl ${base.value}/v1/images/generations \\
|
||||
`curl -N ${base.value}/v1/generate \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "${sampleImage.value}",
|
||||
"prompt": "a corgi running in a golden wheat field, cinematic",
|
||||
"size": "2048x2048"
|
||||
}'`,
|
||||
"quality": "${sampleQuality.value}",
|
||||
"ratio": "1:1"
|
||||
}'
|
||||
# 响应是 NDJSON:首行 {"jobId":"...","status":"running"},空行为心跳(忽略),
|
||||
# 末行终态 {"status":"succeeded","result":"<预签名URL>","creditsCharged":10}`,
|
||||
},
|
||||
{
|
||||
title: '文生图 · Python (openai SDK)',
|
||||
title: '生图 · Python (流式读终态)',
|
||||
code:
|
||||
`import urllib.request
|
||||
from openai import OpenAI
|
||||
`import json, requests
|
||||
|
||||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||||
r = requests.post("${base.value}/v1/generate", stream=True, timeout=600,
|
||||
headers={"Authorization": "Bearer ${keyHint.value}"},
|
||||
json={"model": "${sampleImage.value}", "prompt": "a cute cat", "quality": "${sampleQuality.value}"})
|
||||
|
||||
resp = client.images.generate(
|
||||
model="${sampleImage.value}",
|
||||
prompt="a corgi running in a golden wheat field, cinematic",
|
||||
size="2048x2048", # 2K · 1:1,见下方对照表
|
||||
)
|
||||
# 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存)
|
||||
urllib.request.urlretrieve(resp.data[0].url, "out.png")`,
|
||||
job_id, final = None, None
|
||||
for line in r.iter_lines():
|
||||
if not line: # 空行 = 心跳,忽略
|
||||
continue
|
||||
frame = json.loads(line)
|
||||
job_id = frame.get("jobId") or job_id
|
||||
if frame.get("status") in ("succeeded", "failed"):
|
||||
final = frame
|
||||
break
|
||||
|
||||
# 断流重连:只带 jobId 重发即继续等同一任务(不重复扣费)
|
||||
# requests.post(..., json={"jobId": job_id})
|
||||
print(final)`,
|
||||
},
|
||||
{
|
||||
title: '图生图 / 参考图 · curl (multipart)',
|
||||
title: '参考素材上传 · curl (预签名直传)',
|
||||
code:
|
||||
`curl ${base.value}/v1/images/edits \\
|
||||
`# 1) 申请上传会话 → 拿 {key, url}
|
||||
resp=$(curl -s ${base.value}/v1/uploads \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-F model="${sampleImage.value}" \\
|
||||
-F prompt="把这张图改成赛博朋克风格" \\
|
||||
-F size="2048x2048" \\
|
||||
-F image=@input.png
|
||||
# 多张参考图:重复 -F image=@a.png -F image=@b.png`,
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"kind":"image","mime":"image/png"}')
|
||||
|
||||
# 2) PUT 直传(Content-Type 必须与申请的 mime 完全一致)
|
||||
curl -X PUT -H "Content-Type: image/png" --data-binary @input.png "<url>"
|
||||
|
||||
# 3) key 放进 /v1/generate 的 refs
|
||||
curl -N ${base.value}/v1/generate \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"model":"${sampleImage.value}","prompt":"改成赛博朋克风格","refs":["<key>"]}'`,
|
||||
},
|
||||
{
|
||||
title: '图生图 · Python (openai SDK)',
|
||||
title: '视频 · curl',
|
||||
code:
|
||||
`import urllib.request
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||||
|
||||
resp = client.images.edit(
|
||||
model="${sampleImage.value}",
|
||||
image=open("input.png", "rb"), # 多张:image=[open("a.png","rb"), open("b.png","rb")]
|
||||
prompt="把这张图改成赛博朋克风格",
|
||||
)
|
||||
# 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存)
|
||||
urllib.request.urlretrieve(resp.data[0].url, "out.png")`,
|
||||
},
|
||||
{
|
||||
title: '视频 · curl(创建 → 轮询 → 下载)',
|
||||
code:
|
||||
`# 1) 创建任务 → 立即返回 {"id": "...", "status": "queued"}
|
||||
curl ${base.value}/v1/videos \\
|
||||
`curl -N ${base.value}/v1/generate \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "${sampleVideo.value}",
|
||||
"prompt": "a paper boat sailing down a rainy street, cinematic",
|
||||
"seconds": "${sampleSeconds.value}",
|
||||
"size": "1280x720"
|
||||
"quality": "${sampleVQuality.value}",
|
||||
"ratio": "16:9",
|
||||
"duration": ${sampleSeconds.value}
|
||||
}'
|
||||
|
||||
# 2) 轮询状态,直到 status=completed
|
||||
curl ${base.value}/v1/videos/<VIDEO_ID> \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"
|
||||
|
||||
# 3) 下载 mp4(完成后)
|
||||
curl ${base.value}/v1/videos/<VIDEO_ID>/content \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" -o out.mp4`,
|
||||
},
|
||||
{
|
||||
title: '视频 · Python (requests, 轮询)',
|
||||
code:
|
||||
`import time, requests
|
||||
|
||||
base = "${base.value}/v1"
|
||||
h = {"Authorization": "Bearer ${keyHint.value}"}
|
||||
|
||||
# 1) 创建
|
||||
job = requests.post(f"{base}/videos", headers=h, json={
|
||||
"model": "${sampleVideo.value}",
|
||||
"prompt": "a paper boat sailing down a rainy street",
|
||||
"seconds": "${sampleSeconds.value}",
|
||||
"size": "1280x720",
|
||||
}).json()
|
||||
vid = job["id"]
|
||||
|
||||
# 2) 轮询
|
||||
while True:
|
||||
s = requests.get(f"{base}/videos/{vid}", headers=h).json()
|
||||
if s["status"] in ("completed", "failed"):
|
||||
break
|
||||
time.sleep(5)
|
||||
|
||||
# 3) 下载
|
||||
if s["status"] == "completed":
|
||||
mp4 = requests.get(f"{base}/videos/{vid}/content", headers=h).content
|
||||
open("out.mp4", "wb").write(mp4)`,
|
||||
# 与图像同一端点:quality 传分辨率档,duration 传秒数`,
|
||||
},
|
||||
{
|
||||
title: '列出模型 · curl',
|
||||
code:
|
||||
`curl ${base.value}/v1/models \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"`,
|
||||
-H "Authorization: Bearer ${keyHint.value}"
|
||||
|
||||
# => [{"id":"...","kind":"image","qualities":["1K","2K"],"refImages":4}, ...]`,
|
||||
},
|
||||
{
|
||||
title: '查询余额 · curl',
|
||||
code:
|
||||
`curl ${base.value}/v1/user/balance \\
|
||||
`curl ${base.value}/v1/balance \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"
|
||||
|
||||
# => {"object":"user.balance","balance":12000,"used":680,"total":12680}`,
|
||||
# => {"balance":980}`,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -255,7 +191,7 @@ async function copy(text) {
|
||||
<header>
|
||||
<span class="sticker">✦ 开发者</span>
|
||||
<h1 class="mt-3 text-4xl md:text-5xl font-bold tracking-tight">接口文档</h1>
|
||||
<p class="text-white/45 mt-2">完全兼容 OpenAI 接口规范 — 改个 <code class="text-white/70">base_url</code> 和 <code class="text-white/70">api_key</code> 即可直接调用。图像 / 视频 / 图生图全支持。</p>
|
||||
<p class="text-white/45 mt-2">Vivid 协议 — 创建任务后连接保持到任务完成(NDJSON 流),一次性返回终态结果,无需轮询。图像 / 视频 / 参考素材全支持。</p>
|
||||
</header>
|
||||
|
||||
<!-- quickstart -->
|
||||
@@ -264,10 +200,10 @@ async function copy(text) {
|
||||
<h2 class="text-sm font-semibold text-white/80">基础信息</h2>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">Base URL</dt><dd class="font-mono text-white/90">{{ base }}/v1</dd>
|
||||
<dt class="text-white/45">Base URL</dt><dd class="font-mono text-white/90">{{ base }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">鉴权</dt><dd class="font-mono text-white/90">Authorization: Bearer <key></dd>
|
||||
<dt class="text-white/45">鉴权</dt><dd class="font-mono text-white/90">Authorization: Bearer vk_…</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">你的 Key</dt><dd class="font-mono text-white/70">{{ keyHint }}</dd>
|
||||
@@ -279,13 +215,10 @@ async function copy(text) {
|
||||
<div class="card p-6">
|
||||
<h2 class="text-sm font-semibold text-white/80">端点</h2>
|
||||
<ul class="mt-4 space-y-2.5 text-sm font-mono">
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/user/balance</span><span class="text-white/35 font-sans text-xs">查余额</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/generations</span><span class="text-white/35 font-sans text-xs">文生图</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/edits</span><span class="text-white/35 font-sans text-xs">图生图(multipart)</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/videos</span><span class="text-white/35 font-sans text-xs">建视频任务</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}</span><span class="text-white/35 font-sans text-xs">查状态</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}/content</span><span class="text-white/35 font-sans text-xs">下载 mp4</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/generate</span><span class="text-white/35 font-sans text-xs">创建任务(NDJSON 流)</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/uploads</span><span class="text-white/35 font-sans text-xs">参考素材上传会话</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span><span class="text-white/35 font-sans text-xs">可售模型 + 能力规格</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/balance</span><span class="text-white/35 font-sans text-xs">查余额</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -323,13 +256,11 @@ async function copy(text) {
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<template v-if="m.type === 'image'">
|
||||
<span v-for="r in (m.resolutions || [])" :key="r" class="cap cap-k">{{ r }}</span>
|
||||
<template v-for="r in (m.resolutions || [])" :key="r">
|
||||
<span class="cap cap-k">{{ r }}</span>
|
||||
</template>
|
||||
<template v-if="m.type === 'video'">
|
||||
<span v-for="r in (m.resolutions || [])" :key="r" class="cap cap-k">{{ r }}</span>
|
||||
<span v-if="(m.durations || []).length >= 2" class="cap cap-dur">{{ m.durations[0] }}–{{ m.durations[m.durations.length - 1] }}</span>
|
||||
<span v-else-if="(m.durations || []).length === 1" class="cap cap-dur">{{ m.durations[0] }}</span>
|
||||
<span v-if="durText(m)" class="cap cap-dur">{{ durText(m) }}</span>
|
||||
</template>
|
||||
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cap cap-frame">首尾帧 {{ Math.min(m.max_reference_images, 2) }}</span>
|
||||
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap cap-ref">参考图 {{ refImages(m) }}</span>
|
||||
@@ -353,14 +284,14 @@ async function copy(text) {
|
||||
<!-- parameters -->
|
||||
<section class="grid lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">文生图参数 <span class="text-xs font-normal text-white/40">/v1/images/generations</span></h2>
|
||||
<h2 class="text-lg font-semibold mb-3">生成参数 <span class="text-xs font-normal text-white/40">POST /v1/generate</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in imageParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<tr v-for="p in generateParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
@@ -372,14 +303,14 @@ async function copy(text) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">图生图参数 <span class="text-xs font-normal text-white/40">/v1/images/edits · multipart</span></h2>
|
||||
<h2 class="text-lg font-semibold mb-3">上传参数 <span class="text-xs font-normal text-white/40">POST /v1/uploads</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in editParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<tr v-for="p in uploadParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
@@ -388,85 +319,13 @@ async function copy(text) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-3">
|
||||
用法:POST 拿 <code class="text-white/70">url</code> → PUT 直传 → 返回的 <code class="text-white/70">key</code> 放进
|
||||
<code class="text-white/70">/v1/generate</code> 的 <code class="text-white/70">refs</code>。
|
||||
预签名 URL 仅用于 PUT(不可 GET 读回),30 分钟内有效。
|
||||
支持:图 png/jpeg/webp,视频 mp4/webm/mov,音频 mp3/wav/m4a/ogg。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="text-lg font-semibold mb-3">视频参数 <span class="text-xs font-normal text-white/40">/v1/videos · 异步</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in videoParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- size 对照表(课时表)—— 解决"传错分辨率" -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-1">图像分辨率对照表 · <code class="text-white/70 text-sm">size</code> 该传什么</h2>
|
||||
<p class="text-xs text-white/45 mb-3">
|
||||
左边选比例,上面选分辨率档,交叉格里就是 <code class="text-white/70">size</code> 要传的值(直接复制)。
|
||||
没有 <code class="text-white/70">quality</code> 参数,图像分辨率只看 <code class="text-white/70">size</code> 的<strong class="text-white/70">长边</strong>。
|
||||
档位必须是该模型支持的(见上方「可用模型」的分辨率列),不支持会自动回退到该模型最低档。
|
||||
</p>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">比例</th>
|
||||
<th class="px-4 py-2.5 font-medium">1K</th>
|
||||
<th class="px-4 py-2.5 font-medium">2K</th>
|
||||
<th class="px-4 py-2.5 font-medium">4K</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="row in sizeTable" :key="row.ratio" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 text-white/75">{{ row.ratio }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k1 }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k2 }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k4 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2">
|
||||
例:想要 <strong class="text-white/70">2K 的 16:9 横图</strong> → <code class="text-white/70">"size": "2048x1152"</code>。
|
||||
留空 size = 默认 <strong class="text-white/70">1:1 · 2K</strong>。
|
||||
</p>
|
||||
|
||||
<!-- 视频分辨率(720p / 1080p,按短边判) -->
|
||||
<h2 class="text-lg font-semibold mb-1 mt-8">视频分辨率对照表 · <code class="text-white/70 text-sm">size</code> 该传什么</h2>
|
||||
<p class="text-xs text-white/45 mb-3">
|
||||
视频用 <code class="text-white/70">720p</code> / <code class="text-white/70">1080p</code> 两档,只看 <code class="text-white/70">size</code> 的<strong class="text-white/70">短边</strong>(短边 ≥1080 = 1080p,否则 720p)。
|
||||
档位必须是该视频模型支持的(如 grok-video 仅 720p),不支持会被拒。
|
||||
</p>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">比例</th>
|
||||
<th class="px-4 py-2.5 font-medium">720p</th>
|
||||
<th class="px-4 py-2.5 font-medium">1080p</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="row in videoSizeTable" :key="row.ratio" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 text-white/75">{{ row.ratio }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.p720 }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.p1080 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2">
|
||||
例:想要 <strong class="text-white/70">720p 的 16:9 横版视频</strong> → <code class="text-white/70">"size": "1280x720"</code>;
|
||||
竖版 9:16 → <code class="text-white/70">"720x1280"</code>。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- examples -->
|
||||
@@ -488,23 +347,19 @@ async function copy(text) {
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
|
||||
<div class="card p-6 space-y-3 text-sm text-white/70">
|
||||
<p><strong class="text-white/90">图像</strong>(generations / edits)返回 OpenAI 图片格式:<code class="text-white/85 font-mono">{{ '{ "created": ..., "data": [{ "url": "..." }] }' }}</code> —— <code class="text-white/85 font-mono">data[0].url</code> 是产物 URL,服务端不留存(<strong class="text-white/90">不返回 base64</strong>)。多数模型返回上游<strong class="text-white/90">原始直链</strong>;少数上游需鉴权的(如 gpt-image)会返回一个本站转发链 <code class="text-white/85 font-mono">/v1/images/{id}/content</code>,由服务端带账号凭据取回。<strong class="text-white/90">两种链接都会过期</strong>,请<strong class="text-white/90">尽快下载或转存到你自己的存储</strong>。</p>
|
||||
<p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-white/65 pl-1">
|
||||
<li><code class="text-white/85 font-mono">POST /v1/videos</code> 立即返回任务对象 <code class="text-white/85 font-mono">{{ '{ "id": "...", "object": "video", "status": "queued", ... }' }}</code></li>
|
||||
<li>轮询 <code class="text-white/85 font-mono">GET /v1/videos/{id}</code>,<code class="text-white/70">status</code> 从 <code class="text-white/70">queued → in_progress → completed</code>(或 <code class="text-white/70">failed</code>)</li>
|
||||
<li>完成后 <code class="text-white/85 font-mono">GET /v1/videos/{id}/content</code> 返回 <strong class="text-white/90">mp4 原始二进制</strong>(非 base64、非 URL)</li>
|
||||
</ol>
|
||||
<p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90">前</strong>按上表价格从你的 Key 账号预扣积分;图像或视频上游失败会自动退回 —— 失败不扣费。</p>
|
||||
<p><strong class="text-white/90">参数映射</strong>:<code class="text-white/70">size</code>(宽x高)同时决定<strong class="text-white/90">比例 + 分辨率档</strong>(长边:<1800→1K · 1800–3499→2K · ≥3500→4K),<code class="text-white/70">seconds</code>→视频时长。<strong class="text-white/90">没有 quality 参数</strong>,分辨率只看 size。档位须是该模型支持的(不支持会回退到该模型最低档);参数须落在定价表内否则 400,余额不足 402。</p>
|
||||
<p><strong class="text-white/90">NDJSON 流</strong>(<code class="text-white/85 font-mono">application/x-ndjson</code>):连接建立即回执首行
|
||||
<code class="text-white/85 font-mono">{{ '{"jobId":"…","status":"running"}' }}</code>;
|
||||
等待期间每 25 秒一个空行心跳(请忽略);末行为终态
|
||||
<code class="text-white/85 font-mono">succeeded</code> / <code class="text-white/85 font-mono">failed</code> —— 读最后一行 JSON 即结果。</p>
|
||||
<p><strong class="text-white/90">断流重连</strong>:流意外断开时,只带 <code class="text-white/85 font-mono">jobId</code> 重发同一请求即可重连续等 —— 不会重复创建任务、不重复扣费;已完成直接回终态帧。</p>
|
||||
<p><strong class="text-white/90">产物 URL</strong>:<code class="text-white/85 font-mono">result</code> 是只读预签名 URL(30 分钟),过期即焚,请及时下载转存。任务类型即文件类型。</p>
|
||||
<p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90">前</strong>按上表价格从你的 Key 账号预扣积分;终态 <code class="text-white/85 font-mono">failed</code> 时 <code class="text-white/85 font-mono">refunded:true</code>,预扣积分自动退回 —— 失败不扣费。</p>
|
||||
<div class="pt-2 grid sm:grid-cols-2 gap-2 text-xs">
|
||||
<div class="flex items-center gap-2"><span class="badge-err">401</span> Key 无效 / 上游需重新授权</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">404</span> 未知 model / 视频任务不存在</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">400</span> 参数缺失 / 不支持或未定价</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">402</span> 积分不足</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">409</span> 视频尚未完成(content 未就绪)</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">429</span> 账号并发已满,请重试</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">503</span> 上游繁忙,请重试</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">4001</span> 参数/业务拒绝:模型不可用、quality 不在白名单、refs 超限等</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">4002</span> 积分不足,请先充值</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">4011</span> API Key 无效或未授权</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">4293</span> 并发任务数已达上限 / 上游繁忙,请重试</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">5001</span> 服务异常,请稍后重试</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -241,7 +241,7 @@ const sourcePill = (s) => ({
|
||||
<th class="text-center px-4 py-3 font-medium">预览</th>
|
||||
<th class="text-left px-4 py-3 font-medium">时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-left px-3 py-3 font-medium">用户 / 账号</th>
|
||||
<th class="text-left px-3 py-3 font-medium">用户 / 渠道</th>
|
||||
<th class="text-left px-3 py-3 font-medium">模型</th>
|
||||
<th class="text-left px-3 py-3 font-medium">提示词 / 错误</th>
|
||||
<th class="text-left px-3 py-3 font-medium">参数</th>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import ModelFormModal from '../components/ModelFormModal.vue'
|
||||
import CustomModelModal from '../components/CustomModelModal.vue'
|
||||
import TestModal from '../components/TestModal.vue'
|
||||
import { points } from '../credits'
|
||||
import { mediaCaps, presetMap } from '../videoCaps'
|
||||
@@ -11,7 +10,6 @@ import { mediaCaps, presetMap } from '../videoCaps'
|
||||
const models = ref([])
|
||||
const loading = ref(false)
|
||||
const showForm = ref(false)
|
||||
const showCustom = ref(false)
|
||||
const editing = ref(null) // null = add, object = edit
|
||||
const testing = ref(null) // model being tested, or null
|
||||
|
||||
@@ -41,7 +39,6 @@ async function loadModels() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function openAdd() { editing.value = null; showForm.value = true }
|
||||
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
|
||||
function onSaved() { showForm.value = false; loadModels() }
|
||||
|
||||
@@ -79,7 +76,7 @@ const filtered = computed(() => {
|
||||
if (kindFilter.value && m.type !== kindFilter.value) return false
|
||||
if (statusFilter.value === 'enabled' && m.enabled === false) return false
|
||||
if (statusFilter.value === 'disabled' && m.enabled !== false) return false
|
||||
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.provider || '').toLowerCase().includes(q))) return false
|
||||
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q))) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
@@ -132,12 +129,9 @@ onMounted(loadModels)
|
||||
<button @click="loadModels" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button @click="openAdd" class="btn-primary">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 新增模型
|
||||
</button>
|
||||
<button @click="showCustom = true" class="btn-soft">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 自定义模型
|
||||
</button>
|
||||
<router-link to="/admin/channels" class="btn-primary inline-flex items-center gap-1">
|
||||
<Icon name="plug" class="w-3.5 h-3.5" /> 渠道导入模型
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
@@ -145,7 +139,7 @@ onMounted(loadModels)
|
||||
<div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span>
|
||||
<span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,点右上角「新增模型」' }}</span>
|
||||
<span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,先到「渠道管理」拉取上游模型导入' }}</span>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm table-fixed">
|
||||
@@ -180,7 +174,7 @@ onMounted(loadModels)
|
||||
<div class="font-mono text-xs text-white/90 truncate" :title="m.id">{{ m.id }}</div>
|
||||
<span v-if="m.alias" class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/20 shrink-0">{{ m.alias }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[10px] text-white/45 capitalize truncate">{{ m.provider || '—' }}</div>
|
||||
<div class="mt-1 text-[10px] text-white/45 truncate">{{ m.provider === 'channel' ? '渠道导入' : (m.provider || '—') }}</div>
|
||||
</td>
|
||||
|
||||
<!-- Type chip with the same shape as Logs/Provider 健康 -->
|
||||
@@ -303,7 +297,6 @@ onMounted(loadModels)
|
||||
</div>
|
||||
|
||||
<ModelFormModal v-if="showForm" :model="editing" @close="showForm = false" @saved="onSaved" />
|
||||
<CustomModelModal v-if="showCustom" @close="showCustom = false" @saved="() => { showCustom = false; loadModels() }" />
|
||||
<TestModal v-if="testing" :model="testing" @close="testing = null" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -75,12 +75,12 @@ const cdk = computed(() => dash.value?.cdk || {})
|
||||
const invites = computed(() => dash.value?.invites || {})
|
||||
const checkin = computed(() => dash.value?.checkin || {})
|
||||
|
||||
// ---- token / model / provider summaries (from /providers + /managed-models) ----
|
||||
// ---- channel / model summaries (from /providers — now a channel summary — + /managed-models) ----
|
||||
const tokens = computed(() => {
|
||||
let active = 0, total = 0
|
||||
for (const p of providers.value) {
|
||||
active += p.tokens_active || 0
|
||||
total += (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
|
||||
total += 1
|
||||
if (p.enabled) active += 1
|
||||
}
|
||||
return { active, total }
|
||||
})
|
||||
@@ -93,20 +93,19 @@ const modelTypes = computed(() => {
|
||||
|
||||
const providerHealth = computed(() =>
|
||||
providers.value.map((p) => {
|
||||
const total = (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
|
||||
let status = 'down'
|
||||
if ((p.tokens_active || 0) > 0) status = 'healthy'
|
||||
else if (total > 0) status = 'warning'
|
||||
return { ...p, status, total }
|
||||
if (p.enabled && (p.model_count || 0) > 0) status = 'healthy'
|
||||
else if (p.enabled) status = 'warning'
|
||||
return { ...p, status }
|
||||
})
|
||||
)
|
||||
|
||||
// One-glance system health badge derived from provider token availability.
|
||||
// One-glance system health badge derived from channel availability.
|
||||
const overallHealth = computed(() => {
|
||||
const list = providerHealth.value
|
||||
if (!list.length) return { label: '未配置 Provider', tone: 'down' }
|
||||
if (list.some((p) => p.status === 'down')) return { label: 'Provider 异常', tone: 'down' }
|
||||
if (list.some((p) => p.status === 'warning')) return { label: 'Provider 告警', tone: 'warning' }
|
||||
if (!list.length) return { label: '未配置渠道', tone: 'down' }
|
||||
if (list.some((p) => p.status === 'down')) return { label: '渠道停用', tone: 'down' }
|
||||
if (list.some((p) => p.status === 'warning')) return { label: '渠道未绑定模型', tone: 'warning' }
|
||||
return { label: '系统健康', tone: 'healthy' }
|
||||
})
|
||||
|
||||
@@ -272,13 +271,13 @@ onUnmounted(() => clearInterval(timer))
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-white/55">Token</div>
|
||||
<div class="text-xs text-white/55">渠道</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
|
||||
<span class="text-emerald-300">{{ tokens.active }}</span>
|
||||
<span class="text-white/30">/</span>
|
||||
<span>{{ tokens.total }}</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">活跃 / 已配置</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">启用 / 已配置</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-white/55">模型</div>
|
||||
@@ -353,7 +352,7 @@ onUnmounted(() => clearInterval(timer))
|
||||
<div class="grid lg:grid-cols-2 gap-3">
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">Provider 健康</h2>
|
||||
<h2 class="text-sm font-semibold">渠道健康</h2>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="statusPill(overallHealth.tone)">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
|
||||
@@ -361,16 +360,15 @@ onUnmounted(() => clearInterval(timer))
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div v-if="!providerHealth.length" class="text-center text-xs text-white/40 py-8">未注册 Provider</div>
|
||||
<div v-if="!providerHealth.length" class="text-center text-xs text-white/40 py-8">未配置渠道</div>
|
||||
<div v-else class="space-y-0.5">
|
||||
<div v-for="p in providerHealth" :key="p.name"
|
||||
<div v-for="p in providerHealth" :key="p.id"
|
||||
class="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04] transition-colors">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium capitalize truncate">{{ p.name }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-0.5">
|
||||
{{ p.model_count }} 模型 · token {{ p.tokens_active }}/{{ p.total || 0 }} 活跃
|
||||
<span v-if="p.tokens_quota"> · {{ p.tokens_quota }} 限额</span>
|
||||
<span v-if="p.tokens_disabled"> · {{ p.tokens_disabled }} 停用</span>
|
||||
<div class="text-sm font-medium truncate">{{ p.name }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-0.5 truncate">
|
||||
{{ p.model_count }} 模型 · 成功 {{ p.success_total || 0 }}
|
||||
<span v-if="p.upstream_balance != null"> · 余额 {{ p.upstream_balance }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 tabular-nums"
|
||||
|
||||
@@ -110,9 +110,11 @@ const maxRefs = computed(() => {
|
||||
if (mode.value === 'video') {
|
||||
const a = Number(familyPreset.value?.max_reference_images || 0)
|
||||
const b = Number(model.value?.max_reference_images || 0)
|
||||
const raw = Math.max(a, b)
|
||||
if (refMode.value === 'frame') return Math.min(raw, 2)
|
||||
return raw
|
||||
if (refMode.value === 'frame') return Math.min(Math.max(a, b), 2)
|
||||
// preset 模型的 max_reference_images 已是各类资产合计;custom 上游模型
|
||||
// 按类分别配置(图/视/音),总上限要相加。
|
||||
const extra = familyPreset.value ? 0 : maxVideos.value + maxAudios.value
|
||||
return Math.max(a, b) + extra
|
||||
}
|
||||
// Image-to-image: honor the model's configured max (gpt-image-2=3,
|
||||
// seedream-4.5=6, flux-klein-2=4 …). Fall back to 1 when image_to_image is on
|
||||
@@ -126,18 +128,23 @@ const supportsBoth = computed(() => { const m = model.value; return m && m.refer
|
||||
const refMode = ref(refModeDefault.value)
|
||||
watch(refModeDefault, (v) => { refMode.value = v })
|
||||
|
||||
// 预设里声明的音/视频参考上限(未声明 = null,走 seedance 默认值)
|
||||
// 预设里声明的音/视频参考上限;custom 上游模型直接读模型字段
|
||||
// (max_reference_videos/audios);两者都没声明 = null → 按模型类型回落
|
||||
// (seedance 默认 3,其它 0)。
|
||||
const maxVideosRaw = computed(() => {
|
||||
const n = familyPreset.value?.max_videos
|
||||
return n === undefined || n === null ? null : Number(n)
|
||||
const p = familyPreset.value?.max_videos
|
||||
if (p !== undefined && p !== null) return Number(p)
|
||||
const m = Number(model.value?.max_reference_videos || 0)
|
||||
return m > 0 ? m : null
|
||||
})
|
||||
const maxAudiosRaw = computed(() => {
|
||||
const n = familyPreset.value?.max_audios
|
||||
return n === undefined || n === null ? null : Number(n)
|
||||
const p = familyPreset.value?.max_audios
|
||||
if (p !== undefined && p !== null) return Number(p)
|
||||
const m = Number(model.value?.max_reference_audios || 0)
|
||||
return m > 0 ? m : null
|
||||
})
|
||||
// creativefabrica 上游只收图片参考,没有首尾帧/视频/音频,虽叫 seedance 也要排除。
|
||||
const isSeedanceModel = computed(() =>
|
||||
/^seedance/.test(model.value?.id || '') && (model.value?.provider || '') !== 'creativefabrica')
|
||||
// seedance 系兜底 3/3 参考上限(模型字段显式声明的上限优先,见 maxVideosRaw)。
|
||||
const isSeedanceModel = computed(() => /^seedance/.test(model.value?.id || ''))
|
||||
// 支持图片以外的参考资产(视频/音频)的模型:seedance 系 + 预设声明了音视频上限的
|
||||
const supportsMediaRefs = computed(() =>
|
||||
isSeedanceModel.value || maxVideosRaw.value > 0 || maxAudiosRaw.value > 0)
|
||||
@@ -235,20 +242,32 @@ function openPicker() { fileInput.value && fileInput.value.click() }
|
||||
// of charging + failing upstream after the upload.
|
||||
const MAX_REF_BYTES = 20 * 1024 * 1024
|
||||
// seedance 参考图模式各类上限(预设可覆盖:Leonardo 私有 seedance 只吃 1 段音频)
|
||||
const maxImages = computed(() => Number(familyPreset.value?.max_images) || maxRefs.value)
|
||||
const maxVideos = computed(() => (maxVideosRaw.value === null ? 3 : maxVideosRaw.value))
|
||||
const maxAudios = computed(() => (maxAudiosRaw.value === null ? 3 : maxAudiosRaw.value))
|
||||
// Leonardo 私有 seedance:单个视频参考 3-10 秒,总时长须短于 15 秒。
|
||||
// 服务端同样校验,这里只是提交前先拦一下,避免白跑一次生成。
|
||||
const maxImages = computed(() =>
|
||||
Number(familyPreset.value?.max_images) ||
|
||||
Number(model.value?.max_reference_images) ||
|
||||
maxRefs.value)
|
||||
// 参考文件徽标:只列出上限 >0 的类别(图片总是显示)。
|
||||
const refKindsLabel = computed(() => {
|
||||
const parts = [`图片${refCounts.value.imgs}/${maxImages.value}`]
|
||||
if (maxVideos.value > 0) parts.push(`视频${refCounts.value.vids}/${maxVideos.value}`)
|
||||
if (maxAudios.value > 0) parts.push(`音频${refCounts.value.auds}/${maxAudios.value}`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
// 未声明时的回落只适用于 seedance 系(老协议默认 3/3);custom 等显式声明的
|
||||
// 模型没填 = 0,不再兜底成 3。
|
||||
const maxVideos = computed(() => (maxVideosRaw.value === null ? (isSeedanceModel.value ? 3 : 0) : maxVideosRaw.value))
|
||||
const maxAudios = computed(() => (maxAudiosRaw.value === null ? (isSeedanceModel.value ? 3 : 0) : maxAudiosRaw.value))
|
||||
// 参考视频/音频累计秒数上限(渠道导入的能力规格 max_video_seconds /
|
||||
// max_audio_seconds)。服务端同样校验,这里只是提交前先拦一下。
|
||||
function videoRefDurationError() {
|
||||
if (mode.value !== 'video' || familyPreset.value?.provider !== 'leonardo') return ''
|
||||
const vids = refImages.value.filter((r) => r.fileType === 'video')
|
||||
for (const v of vids) {
|
||||
if (v.duration && (v.duration < 3 || v.duration > 10))
|
||||
return `单个视频参考需 3-10 秒:${v.name} 为 ${v.duration.toFixed(1)} 秒`
|
||||
if (mode.value !== 'video') return ''
|
||||
const videoLimit = Number(familyPreset.value?.max_video_seconds) || 0
|
||||
if (videoLimit > 0) {
|
||||
const vTotal = refImages.value
|
||||
.filter((r) => r.fileType === 'video')
|
||||
.reduce((n, v) => n + (v.duration || 0), 0)
|
||||
if (vTotal > videoLimit) return `视频参考总时长 ${vTotal.toFixed(1)} 秒,最多 ${videoLimit} 秒`
|
||||
}
|
||||
const vTotal = vids.reduce((n, v) => n + (v.duration || 0), 0)
|
||||
if (vids.length && vTotal >= 15) return `视频参考总时长 ${vTotal.toFixed(1)} 秒,需短于 15 秒`
|
||||
const audioLimit = Number(familyPreset.value?.max_audio_seconds) || 0
|
||||
if (audioLimit > 0) {
|
||||
const aTotal = refImages.value
|
||||
@@ -908,7 +927,7 @@ onUnmounted(() => {
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">
|
||||
{{ refMode === 'frame' && mode === 'video' ? '首尾帧' : '参考图' }}
|
||||
<span class="text-slate-400 font-normal">
|
||||
(最多 {{ maxRefs }} 张{{ supportsMediaRefs && refMode !== 'frame' && mode === 'video' ? ` · 图片${refCounts.imgs}/${maxImages} · 视频${refCounts.vids}/${maxVideos} · 音频${refCounts.auds}/${maxAudios}` : '' }}{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单文件 ≤20MB)
|
||||
(最多 {{ maxRefs }} 张{{ supportsMediaRefs && refMode !== 'frame' && mode === 'video' ? ` · ${refKindsLabel}` : '' }}{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单文件 ≤20MB)
|
||||
</span>
|
||||
<span v-if="refsRequired" class="text-rose-500">*</span>
|
||||
</label>
|
||||
|
||||
@@ -41,7 +41,7 @@ async function loadKey() {
|
||||
onMounted(loadKey)
|
||||
|
||||
// Uniform mask for the hidden / persisted state — always "***" so the just-minted
|
||||
// preview and the server-stored preview read the same (no sk-xxx…yyyy mismatch).
|
||||
// preview and the server-stored preview read the same (no vk_xxx…yyyy mismatch).
|
||||
const maskedKey = computed(() => (apiKey.value || keyPreview.value) ? '***' : '')
|
||||
|
||||
async function generateKey() {
|
||||
|
||||
Reference in New Issue
Block a user