Compare commits
10
Commits
babfc71f13
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4efe6b192 | ||
|
|
ecdbc0b281 | ||
|
|
b17cc3e9c8 | ||
|
|
effe76d83f | ||
|
|
e7a4613bac | ||
|
|
1fee3e5461 | ||
|
|
0732dfddef | ||
|
|
ab8ddc8354 | ||
|
|
e69455fbfc | ||
|
|
1be1b00511 |
@@ -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"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"backend/internal/config"
|
"backend/internal/config"
|
||||||
"backend/internal/http/handler"
|
"backend/internal/http/handler"
|
||||||
"backend/internal/http/router"
|
"backend/internal/http/router"
|
||||||
"backend/internal/model"
|
"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/repo"
|
||||||
"backend/internal/service"
|
"backend/internal/service"
|
||||||
"backend/internal/storage"
|
"backend/internal/storage"
|
||||||
|
"backend/internal/upstream"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/redis/go-redis/v9"
|
"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)
|
return nil, fmt.Errorf("create generated root %s: %w", cfg.GeneratedRoot, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成 gorm.ErrDuplicatedKey,
|
// TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成
|
||||||
// 否则各 import-*(krea/adobe/leonardo/runway)里的 errors.Is(err, gorm.ErrDuplicatedKey)
|
// gorm.ErrDuplicatedKey,否则 errors.Is(err, gorm.ErrDuplicatedKey) 兜底命不中,
|
||||||
// 兜底命不中,重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。
|
// 重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。
|
||||||
db, err := gorm.Open(postgres.Open(cfg.PostgresDSN), &gorm.Config{TranslateError: true})
|
db, err := gorm.Open(postgres.Open(cfg.PostgresDSN), &gorm.Config{TranslateError: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open postgres: %w", err)
|
return nil, fmt.Errorf("open postgres: %w", err)
|
||||||
@@ -97,8 +88,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
eventRepo := repo.NewEventRepository(db)
|
eventRepo := repo.NewEventRepository(db)
|
||||||
cdkRepo := repo.NewCDKRepository(db)
|
cdkRepo := repo.NewCDKRepository(db)
|
||||||
apiKeyRepo := repo.NewAPIKeyRepository(db)
|
apiKeyRepo := repo.NewAPIKeyRepository(db)
|
||||||
tokenRepo := repo.NewTokenRepository(db)
|
channelRepo := repo.NewChannelRepository(db)
|
||||||
refreshRepo := repo.NewRefreshProfileRepository(db)
|
|
||||||
cgroupRepo := repo.NewConcurrencyGroupRepository(db)
|
cgroupRepo := repo.NewConcurrencyGroupRepository(db)
|
||||||
// Seed the "默认并发" group (cap 10) and bind any ungrouped users to it.
|
// Seed the "默认并发" group (cap 10) and bind any ungrouped users to it.
|
||||||
if err := cgroupRepo.EnsureDefault(ctx); err != nil {
|
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)
|
authSvc := service.NewAuthService(userRepo, siteRepo, sessionSvc, emailCodeSvc, smtpSvc, cgroupRepo)
|
||||||
appSettingsSvc := service.NewAppSettingsService(siteRepo, eventRepo, smtpSvc, rustfsClient)
|
appSettingsSvc := service.NewAppSettingsService(siteRepo, eventRepo, smtpSvc, rustfsClient)
|
||||||
imageAccessSvc := service.NewImageAccessService(cfg.GeneratedRoot, showcaseRepo, authSvc)
|
imageAccessSvc := service.NewImageAccessService(cfg.GeneratedRoot, showcaseRepo, authSvc)
|
||||||
adobeClient := adobe.NewClient("projectx_webapp", "")
|
upClient := upstream.NewClient()
|
||||||
chatGPTClient := chatgpt.NewClient("")
|
channelSecret := cfg.ChannelSecret
|
||||||
runwayClient := runway.NewClient("")
|
if channelSecret == "" {
|
||||||
leonardoClient := leonardo.NewClient("")
|
// Stable fallback so a restarted process can still decrypt existing
|
||||||
kreaClient := krea.NewClient("")
|
// rows when CHANNEL_SECRET isn't set.
|
||||||
imagineClient := imagine.NewClient("")
|
channelSecret = cfg.PostgresDSN
|
||||||
grokClient := grok.NewClient("")
|
}
|
||||||
// Keep grok's x-statsig-id anti-bot recipe current by reading grok's own
|
channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret)
|
||||||
// headless-browser signer output. Event-driven: seed from the persisted
|
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
|
||||||
// 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)
|
|
||||||
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
||||||
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
||||||
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient, showcaseRepo)
|
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, channelRepo, cdkRepo, rustfsClient, showcaseRepo)
|
||||||
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo, orderRepo)
|
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, channelRepo, orderRepo)
|
||||||
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo, orderRepo)
|
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo, orderRepo)
|
||||||
apiKeySvc := service.NewAPIKeyService(apiKeyRepo)
|
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)
|
bannedWordRepo := repo.NewBannedWordRepository(db)
|
||||||
v1Svc.SetBannedWords(bannedWordRepo)
|
v1Svc.SetBannedWords(bannedWordRepo)
|
||||||
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
|
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
|
||||||
@@ -163,7 +142,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
CDK: handler.NewCDKHandler(cdkSvc),
|
CDK: handler.NewCDKHandler(cdkSvc),
|
||||||
UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc),
|
UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc),
|
||||||
UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc),
|
UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc),
|
||||||
ProviderAdmin: handler.NewProviderAdminHandler(tokenSvc, refreshSvc),
|
ChannelAdmin: handler.NewChannelAdminHandler(channelSvc),
|
||||||
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
|
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
|
||||||
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
||||||
Payment: handler.NewPaymentHandler(paymentSvc),
|
Payment: handler.NewPaymentHandler(paymentSvc),
|
||||||
@@ -171,9 +150,9 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
Canvas: handler.NewCanvasHandler(canvasSvc),
|
Canvas: handler.NewCanvasHandler(canvasSvc),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Background self-healing sweep (quota recovery, cookie refresh, stale-pending
|
// Background self-healing sweep (order expiry, stale-pending cleanup+refund,
|
||||||
// cleanup, log retention) — the Go equivalent of the Python daemon thread.
|
// log/media retention).
|
||||||
maintenanceSvc := service.NewMaintenanceService(tokenRepo, tokenSvc, eventRepo, userRepo, refreshSvc, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo, orderRepo)
|
maintenanceSvc := service.NewMaintenanceService(eventRepo, userRepo, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo, orderRepo)
|
||||||
loopCtx, loopCancel := context.WithCancel(context.Background())
|
loopCtx, loopCancel := context.WithCancel(context.Background())
|
||||||
go maintenanceSvc.Run(loopCtx)
|
go maintenanceSvc.Run(loopCtx)
|
||||||
|
|
||||||
@@ -186,33 +165,6 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
}, nil
|
}, 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 {
|
func (a *App) Close() error {
|
||||||
if a.maintenanceCancel != nil {
|
if a.maintenanceCancel != nil {
|
||||||
a.maintenanceCancel()
|
a.maintenanceCancel()
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type Config struct {
|
|||||||
RustFSBucket string
|
RustFSBucket string
|
||||||
RustFSAccessKey string
|
RustFSAccessKey string
|
||||||
RustFSSecretKey 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) {
|
func Load() (*Config, error) {
|
||||||
@@ -60,6 +63,7 @@ func Load() (*Config, error) {
|
|||||||
RustFSBucket: envString("RUSTFS_BUCKET", ""),
|
RustFSBucket: envString("RUSTFS_BUCKET", ""),
|
||||||
RustFSAccessKey: envString("RUSTFS_ACCESS_KEY", ""),
|
RustFSAccessKey: envString("RUSTFS_ACCESS_KEY", ""),
|
||||||
RustFSSecretKey: envString("RUSTFS_SECRET_KEY", ""),
|
RustFSSecretKey: envString("RUSTFS_SECRET_KEY", ""),
|
||||||
|
ChannelSecret: envString("CHANNEL_SECRET", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
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,544 +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"`
|
|
||||||
Protocol string `json:"protocol"` // openai (default) | vivid
|
|
||||||
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.Protocol, 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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
@@ -13,6 +14,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"backend/internal/model"
|
||||||
|
"backend/internal/repo"
|
||||||
"backend/internal/service"
|
"backend/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
@@ -248,7 +251,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
|||||||
Resolution string `json:"resolution"`
|
Resolution string `json:"resolution"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
ReferenceImages []string `json:"reference_images"`
|
ReferenceImages []string `json:"reference_images"`
|
||||||
AccountID string `json:"account_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||||
@@ -262,7 +265,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
|||||||
Resolution: body.Resolution,
|
Resolution: body.Resolution,
|
||||||
Duration: body.Duration,
|
Duration: body.Duration,
|
||||||
ReferenceImages: body.ReferenceImages,
|
ReferenceImages: body.ReferenceImages,
|
||||||
AccountID: body.AccountID,
|
ChannelID: body.ChannelID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
@@ -451,161 +454,19 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
models, err := h.admin.Models(c.Request.Context())
|
||||||
"data": []gin.H{
|
if err != nil {
|
||||||
{
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load video presets"})
|
||||||
"key": "gemini-veo31",
|
return
|
||||||
"label": "Veo31 Fast",
|
}
|
||||||
"type": "video",
|
out := make([]gin.H, 0, len(models))
|
||||||
"provider": "adobe",
|
for _, m := range models {
|
||||||
"durations": []string{"4s", "6s", "8s"},
|
if m.Type != "video" || !m.Enabled {
|
||||||
"ratios": []string{"16:9", "9:16"},
|
continue
|
||||||
"resolutions": []string{"720p", "1080p"},
|
}
|
||||||
"max_reference_images": 9,
|
out = append(out, modelPreset(m))
|
||||||
"reference_mode": "frame",
|
}
|
||||||
},
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||||
{
|
|
||||||
"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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserGenerationHandler) Catalog(c *gin.Context) {
|
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) {
|
func (h *UserGenerationHandler) Models(c *gin.Context) {
|
||||||
items, err := h.publicModels()
|
items, err := h.publicModels(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
||||||
return
|
return
|
||||||
@@ -628,537 +489,85 @@ func (h *UserGenerationHandler) Models(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) {
|
// modelPreset projects one model_configs row into the preset shape the studio
|
||||||
items := []gin.H{
|
// (画图台) / model pickers consume. Database-driven now: providers are gone and
|
||||||
{
|
// every model arrives via a channel import.
|
||||||
"id": "gpt-image-2",
|
func modelPreset(m model.ModelConfig) gin.H {
|
||||||
"provider": "chatgpt",
|
durations := repo.JSONStrings(m.Durations)
|
||||||
"type": "image",
|
if len(durations) == 0 && m.DurationMin > 0 && m.DurationMax >= m.DurationMin {
|
||||||
// ChatGPT web backend only reliably produces 1K and honors a limited
|
// Range spec → expand integer seconds (capped so absurd ranges can't
|
||||||
// ratio set; size params are advisory prompt hints. Mirrors the Python
|
// flood a dropdown).
|
||||||
// reference (providers/chatgpt/provider.py) — do not offer 2K/4K.
|
for d := m.DurationMin; d <= m.DurationMax && len(durations) < 61; d++ {
|
||||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
durations = append(durations, fmt.Sprintf("%ds", d))
|
||||||
"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 (文生图)",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
existing := map[string]bool{}
|
}
|
||||||
if h.admin != nil {
|
_, 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) {
|
||||||
models, err := h.admin.Models(c.Request.Context())
|
models, err := h.admin.Models(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, item := range models {
|
items := make([]gin.H, 0, len(models))
|
||||||
existing[item.ID] = true
|
for _, m := range models {
|
||||||
}
|
row := modelPreset(m)
|
||||||
}
|
row["id"] = m.EffectiveName()
|
||||||
for i := range items {
|
items = append(items, row)
|
||||||
items[i]["added"] = existing[items[i]["id"].(string)]
|
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
// publicModels — GET /api/models for the public site (docs/landing pickers).
|
||||||
items := []gin.H{
|
// Enabled models only, in the same shape as before minus the provider id.
|
||||||
{
|
func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
|
||||||
"id": "gpt-image-2",
|
models, err := h.admin.Models(c.Request.Context())
|
||||||
"provider": "chatgpt",
|
if err != nil {
|
||||||
"kind": "image",
|
return nil, err
|
||||||
// See catalogEntries — ChatGPT only reliably does 1K and a limited
|
}
|
||||||
// ratio set; matches the Python reference. Keep both lists in sync.
|
items := make([]gin.H, 0, len(models))
|
||||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
for _, m := range models {
|
||||||
"resolutions": []string{"1K"},
|
if !m.Enabled {
|
||||||
"description": "ChatGPT image generation",
|
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,
|
"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
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-336
@@ -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
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"backend/internal/model"
|
||||||
"backend/internal/service"
|
"backend/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// v1MaxWait 同步等待终态的上限。视频单渠道预算 30 分钟、最多换几个渠道,
|
||||||
|
// 留足余量取 2 小时(与 vivid 一致)。
|
||||||
|
const v1MaxWait = 2 * time.Hour
|
||||||
|
|
||||||
|
const v1PollInterval = time.Second
|
||||||
|
|
||||||
type V1Handler struct {
|
type V1Handler struct {
|
||||||
v1 *service.V1Service
|
v1 *service.V1Service
|
||||||
}
|
}
|
||||||
@@ -22,385 +36,213 @@ func NewV1Handler(v1 *service.V1Service) *V1Handler {
|
|||||||
return &V1Handler{v1: v1}
|
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"))
|
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeAuthError(c, err)
|
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
|
||||||
return
|
return nil, false
|
||||||
|
}
|
||||||
|
return principal, true
|
||||||
}
|
}
|
||||||
_ = principal
|
|
||||||
|
|
||||||
items, err := h.v1.ListModels(c.Request.Context())
|
// v1Err writes the {"error":{"code","message"}} envelope — the wire shape every
|
||||||
if err != nil {
|
// vivid-protocol caller parses.
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
func v1Err(c *gin.Context, httpStatus int, code, msg string) {
|
||||||
return
|
c.JSON(httpStatus, gin.H{
|
||||||
}
|
"error": gin.H{"code": code, "message": msg},
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"object": "list",
|
|
||||||
"data": items,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserBalance — GET /v1/user/balance. 返回 API Key 所属用户的账户级余额
|
// v1Fail maps a service error to the vivid code/HTTP status pair.
|
||||||
// (剩余 / 累计已用),与具体令牌无关。
|
func v1Fail(c *gin.Context, err error) {
|
||||||
func (h *V1Handler) UserBalance(c *gin.Context) {
|
switch {
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
case errors.Is(err, service.ErrMissingAPIKey), errors.Is(err, service.ErrInvalidAPIKey):
|
||||||
if err != nil {
|
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
|
||||||
h.writeAuthError(c, err)
|
case errors.Is(err, service.ErrInsufficientFunds):
|
||||||
return
|
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).
|
// ---- POST /v1/generate ----
|
||||||
// Accepts exactly OpenAI's fields; size→aspect ratio and quality→resolution tier
|
|
||||||
// are mapped server-side. Returns {created, data:[{b64_json}]}.
|
func (h *V1Handler) Generate(c *gin.Context) {
|
||||||
func (h *V1Handler) ImageGenerations(c *gin.Context) {
|
principal, ok := h.auth(c)
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
if !ok {
|
||||||
if err != nil {
|
|
||||||
h.writeAuthError(c, err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var body service.V1JobRequest
|
||||||
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"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var ev *model.EventLog
|
||||||
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
|
var err error
|
||||||
Model: body.Model,
|
// 重连模式:只带 jobId → 不建新任务,回读该任务并等待终态。
|
||||||
Prompt: body.Prompt,
|
if body.JobID != nil && strings.TrimSpace(*body.JobID) != "" {
|
||||||
N: body.N,
|
ev, err = h.v1.JobForUser(c.Request.Context(), principal, *body.JobID)
|
||||||
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[]")
|
|
||||||
} else {
|
} else {
|
||||||
var body struct {
|
ev, err = h.v1.CreateJob(c.Request.Context(), principal, body)
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modelID, prompt, size = body.Model, body.Prompt, body.Size
|
|
||||||
seconds = rawToString(body.Seconds)
|
|
||||||
referenceMode = strings.TrimSpace(body.ReferenceMode)
|
|
||||||
refs = append(body.InputReference, body.ReferenceImages...)
|
|
||||||
}
|
|
||||||
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 {
|
if err != nil {
|
||||||
h.writeV1Error(c, err, nil)
|
v1Fail(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVideo — OpenAI GET /v1/videos/{id}. Returns the job status object.
|
wait := 300 * time.Second
|
||||||
func (h *V1Handler) GetVideo(c *gin.Context) {
|
if h := strings.TrimSpace(c.GetHeader("X-Wait-Seconds")); h != "" {
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
if sec, serr := strconv.Atoi(h); serr == nil && sec > 0 && time.Duration(sec)*time.Second <= v1MaxWait {
|
||||||
if err != nil {
|
wait = time.Duration(sec) * time.Second
|
||||||
h.writeAuthError(c, err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
resp, err := h.v1.VideoJob(c.Request.Context(), principal, c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
h.writeV1Error(c, err, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVideoContent — OpenAI GET /v1/videos/{id}/content. Streams the rendered mp4
|
c.Writer.Header().Set("Content-Type", "application/x-ndjson")
|
||||||
// by proxying the stored upstream URL (downloaded on demand, never persisted).
|
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||||
func (h *V1Handler) GetVideoContent(c *gin.Context) {
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
c.Writer.WriteHeader(http.StatusOK)
|
||||||
if err != nil {
|
flusher, _ := c.Writer.(http.Flusher)
|
||||||
h.writeAuthError(c, err)
|
|
||||||
return
|
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
|
||||||
}
|
}
|
||||||
body, contentType, err := h.v1.OpenVideoContent(c.Request.Context(), principal, c.Param("id"))
|
if flusher != nil {
|
||||||
if err != nil {
|
flusher.Flush()
|
||||||
h.writeV1Error(c, err, nil)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer body.Close()
|
return true
|
||||||
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.
|
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
|
||||||
func (h *V1Handler) GetImageContent(c *gin.Context) {
|
writeFrame(ev)
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
|
||||||
if err != nil {
|
|
||||||
h.writeAuthError(c, err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, contentType, err := h.v1.OpenImageContent(c.Request.Context(), principal, c.Param("id"))
|
// 连上即回执 jobId(断流后携它重发的凭据)。
|
||||||
if err != nil {
|
if !writeFrame(ev) {
|
||||||
h.writeV1Error(c, err, nil)
|
|
||||||
return
|
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.
|
deadline := time.Now().Add(wait)
|
||||||
func readMultipartImages(c *gin.Context, keys ...string) []string {
|
poll := time.NewTicker(v1PollInterval)
|
||||||
var out []string
|
heartbeat := time.NewTicker(25 * time.Second)
|
||||||
form := c.Request.MultipartForm
|
defer poll.Stop()
|
||||||
if form == nil {
|
defer heartbeat.Stop()
|
||||||
return out
|
for {
|
||||||
}
|
select {
|
||||||
for _, key := range keys {
|
case <-c.Request.Context().Done():
|
||||||
for _, fh := range form.File[key] {
|
return // client gone — the job keeps running; caller may reconnect with jobId
|
||||||
f, e := fh.Open()
|
case <-poll.C:
|
||||||
if e != nil {
|
latest, lerr := h.v1.JobForUser(c.Request.Context(), principal, ev.ID)
|
||||||
|
if lerr != nil || latest == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b, _ := io.ReadAll(io.LimitReader(f, 20<<20+1))
|
ev = latest
|
||||||
f.Close()
|
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
|
||||||
if len(b) > 0 {
|
writeFrame(ev)
|
||||||
out = append(out, base64.StdEncoding.EncodeToString(b))
|
return
|
||||||
}
|
}
|
||||||
|
case <-heartbeat.C:
|
||||||
|
if _, werr := c.Writer.WriteString("\n"); werr != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
return out
|
case <-time.After(time.Until(deadline)):
|
||||||
|
writeFrame(ev) // snapshot; client reconnects with jobId
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// rawToString accepts OpenAI's `seconds` whether sent as a JSON string or number.
|
|
||||||
func rawToString(raw json.RawMessage) string {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
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), `"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
if w == 0 || h == 0 {
|
|
||||||
return "16:9", ""
|
|
||||||
}
|
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
return guessRatioWH(w, h), resolution
|
|
||||||
}
|
|
||||||
|
|
||||||
func guessRatioWH(w, h int) string {
|
|
||||||
if w == h {
|
|
||||||
return "1:1"
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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) writeV1Error(c *gin.Context, err error, payload map[string]any) {
|
// v1JobBody assembles one NDJSON frame (running receipt and terminal frame
|
||||||
switch {
|
// share the shape; succeeded adds result+creditsCharged, failed adds error).
|
||||||
case errors.Is(err, service.ErrUnknownModel):
|
func v1JobBody(v1 *service.V1Service, ev *model.EventLog) map[string]any {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
body := map[string]any{"jobId": ev.ID, "status": service.JobStatus(ev)}
|
||||||
case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt):
|
switch service.JobStatus(ev) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
case "succeeded":
|
||||||
case errors.Is(err, service.ErrInsufficientFunds):
|
body["result"] = v1.JobResultURL(ev)
|
||||||
c.JSON(http.StatusPaymentRequired, gin.H{"detail": err.Error()})
|
body["creditsCharged"] = ev.Cost
|
||||||
case errors.Is(err, service.ErrReferenceTooLarge):
|
case "failed":
|
||||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"detail": err.Error()})
|
body["error"] = ev.Error
|
||||||
case errors.Is(err, service.ErrNoProviderAccount):
|
body["refunded"] = true
|
||||||
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()})
|
|
||||||
}
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- POST /v1/uploads ----
|
||||||
|
|
||||||
|
func (h *V1Handler) Uploads(c *gin.Context) {
|
||||||
|
principal, ok := h.auth(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Mime string `json:"mime"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GET /v1/models ----
|
||||||
|
|
||||||
|
func (h *V1Handler) Models(c *gin.Context) {
|
||||||
|
if _, ok := h.auth(c); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.v1.SellableModels(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
v1Err(c, http.StatusInternalServerError, "5001", "模型加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GET /v1/balance ----
|
||||||
|
|
||||||
|
func (h *V1Handler) UserBalance(c *gin.Context) {
|
||||||
|
principal, ok := h.auth(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
// requestBaseURL derives the scheme+host of the inbound request so the service
|
||||||
// layer can build absolute, directly-downloadable output URLs. Honors
|
// layer can build absolute /images URLs for the session path.
|
||||||
// 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.
|
|
||||||
func requestBaseURL(c *gin.Context) string {
|
func requestBaseURL(c *gin.Context) string {
|
||||||
host := c.Request.Host
|
host := c.Request.Host
|
||||||
if host == "" {
|
if host == "" {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type Handlers struct {
|
|||||||
CDK *handler.CDKHandler
|
CDK *handler.CDKHandler
|
||||||
UserTools *handler.UserToolsHandler
|
UserTools *handler.UserToolsHandler
|
||||||
UserGen *handler.UserGenerationHandler
|
UserGen *handler.UserGenerationHandler
|
||||||
ProviderAdmin *handler.ProviderAdminHandler
|
ChannelAdmin *handler.ChannelAdminHandler
|
||||||
ConcGroups *handler.ConcurrencyGroupHandler
|
ConcGroups *handler.ConcurrencyGroupHandler
|
||||||
Announcement *handler.AnnouncementHandler
|
Announcement *handler.AnnouncementHandler
|
||||||
Payment *handler.PaymentHandler
|
Payment *handler.PaymentHandler
|
||||||
@@ -59,16 +59,12 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
|||||||
|
|
||||||
engine.GET("/health", handlers.Health.Handle)
|
engine.GET("/health", handlers.Health.Handle)
|
||||||
engine.GET("/images/:user/:name", handlers.Images.Serve)
|
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/models", handlers.V1.Models)
|
||||||
engine.GET("/v1/user/balance", handlers.V1.UserBalance)
|
engine.GET("/v1/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)
|
|
||||||
|
|
||||||
publicAdmin := engine.Group("/admin/api")
|
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.
|
// owner names is admin-only, see /stats/recent below.
|
||||||
publicAdmin.GET("/stats", handlers.AdminRead.Stats)
|
publicAdmin.GET("/stats", handlers.AdminRead.Stats)
|
||||||
publicAdmin.GET("/video-presets", handlers.UserGen.VideoPresets)
|
publicAdmin.GET("/video-presets", handlers.UserGen.VideoPresets)
|
||||||
publicAdmin.GET("/catalog", handlers.UserGen.Catalog)
|
|
||||||
publicAdmin.GET("/models", handlers.UserGen.Models)
|
publicAdmin.GET("/models", handlers.UserGen.Models)
|
||||||
publicAdmin.GET("/deai-pricing", handlers.AppSettings.DeAIGet)
|
publicAdmin.GET("/deai-pricing", handlers.AppSettings.DeAIGet)
|
||||||
// 易支付 async notify — called server-to-server by the pay platform, no auth.
|
// 易支付 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", handlers.CDK.Create)
|
||||||
authed.POST("/cdks/delete-bulk", handlers.CDK.DeleteBulk)
|
authed.POST("/cdks/delete-bulk", handlers.CDK.DeleteBulk)
|
||||||
authed.DELETE("/cdks/:code", handlers.CDK.Delete)
|
authed.DELETE("/cdks/:code", handlers.CDK.Delete)
|
||||||
authed.GET("/tokens", handlers.ProviderAdmin.TokensList)
|
authed.GET("/channels", handlers.ChannelAdmin.List)
|
||||||
authed.POST("/tokens", handlers.ProviderAdmin.TokensCreate)
|
authed.POST("/channels", handlers.ChannelAdmin.Save)
|
||||||
authed.POST("/tokens/import-chatgpt-token", handlers.ProviderAdmin.ImportChatGPTToken)
|
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
|
||||||
authed.POST("/tokens/import-adobe-cookie", handlers.ProviderAdmin.ImportAdobeCookie)
|
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
|
||||||
authed.POST("/tokens/import-creativefabrica-cookie", handlers.ProviderAdmin.ImportCreativeFabricaCookie)
|
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
|
||||||
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("/providers", handlers.AdminRead.Providers)
|
authed.GET("/providers", handlers.AdminRead.Providers)
|
||||||
authed.GET("/images", handlers.AdminRead.Images)
|
authed.GET("/images", handlers.AdminRead.Images)
|
||||||
authed.DELETE("/images", handlers.AdminRead.DeleteImage)
|
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.POST("/banned-words/import", handlers.BannedWords.Import)
|
||||||
authed.DELETE("/banned-words/:id", handlers.BannedWords.Delete)
|
authed.DELETE("/banned-words/:id", handlers.BannedWords.Delete)
|
||||||
authed.GET("/banned-word-hits", handlers.BannedWords.Hits)
|
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.POST("/managed-models", handlers.AdminWrite.CreateModel)
|
||||||
authed.PATCH("/managed-models/:model_id", handlers.AdminWrite.UpdateModel)
|
authed.PATCH("/managed-models/:model_id", handlers.AdminWrite.UpdateModel)
|
||||||
authed.DELETE("/managed-models/:model_id", handlers.AdminWrite.DeleteModel)
|
authed.DELETE("/managed-models/:model_id", handlers.AdminWrite.DeleteModel)
|
||||||
|
|||||||
@@ -97,15 +97,16 @@ type EventLog struct {
|
|||||||
DeAI bool `gorm:"not null;default:false"` // 去AI特征 was applied (image only)
|
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"`
|
Source string `gorm:"size:32;index"`
|
||||||
// AccountID is the provider token/account chosen to fulfil this generation,
|
// AccountID is the channel chosen to fulfil this generation, stamped when
|
||||||
// stamped when the upstream call begins. Drives the accounts view's live
|
// the upstream call begins. (Field name kept from the pre-channel schema.)
|
||||||
// in-flight count (pending events per account) and lets an abandoned-event
|
|
||||||
// purge attribute the failure back to the account it was using.
|
|
||||||
AccountID string `gorm:"size:64;index"`
|
AccountID string `gorm:"size:64;index"`
|
||||||
// AccountEmail denormalizes the account's email at stamp time, so log rows
|
// AccountEmail denormalizes the channel's display name at stamp time, so log
|
||||||
// keep showing which mailbox fulfilled the generation even after the account
|
// rows keep showing which channel fulfilled the generation even after the
|
||||||
// is deleted or re-imported under a different ID.
|
// channel is deleted or renamed.
|
||||||
AccountEmail string `gorm:"size:255"`
|
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"`
|
UserID string `gorm:"size:32;index"`
|
||||||
Cost float64 `gorm:"not null;default:0"`
|
Cost float64 `gorm:"not null;default:0"`
|
||||||
// Refunded marks that this event's up-front charge has already been credited
|
// Refunded marks that this event's up-front charge has already been credited
|
||||||
@@ -138,12 +139,20 @@ type ModelConfig struct {
|
|||||||
PricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:prices_agent"`
|
PricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:prices_agent"`
|
||||||
DurationPricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:duration_prices_agent"`
|
DurationPricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:duration_prices_agent"`
|
||||||
Durations datatypes.JSON `gorm:"type:jsonb"`
|
Durations datatypes.JSON `gorm:"type:jsonb"`
|
||||||
|
// 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"`
|
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'"`
|
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:''"`
|
|
||||||
// Weight controls display order in the model dropdown / admin list: higher
|
// Weight controls display order in the model dropdown / admin list: higher
|
||||||
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
|
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
|
||||||
// back to created_at desc. Default 0.
|
// back to created_at desc. Default 0.
|
||||||
@@ -176,53 +185,35 @@ type CDKCode struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenAccount struct {
|
// Channel is one upstream vivid-protocol instance this gateway routes
|
||||||
ID string `gorm:"primaryKey;size:64"`
|
// generation to. Replaces the old per-provider token account pool: a channel
|
||||||
Pool string `gorm:"size:64;index;not null"`
|
// is just a base_url + api_key plus routing knobs (enabled/weight/concurrency)
|
||||||
Value string `gorm:"type:text"`
|
// and local usage counters. ApiKey is stored AES-GCM encrypted.
|
||||||
Status string `gorm:"size:32;index;not null"`
|
type Channel struct {
|
||||||
Fails int `gorm:"not null;default:0"`
|
ID string `gorm:"primaryKey;size:40"`
|
||||||
FailTotal int `gorm:"not null;default:0"`
|
Name string `gorm:"size:255;not null"`
|
||||||
SuccessTotal int `gorm:"not null;default:0"`
|
BaseURL string `gorm:"size:500;not null"`
|
||||||
Dead bool `gorm:"not null;default:false"`
|
APIKeyEnc string `gorm:"type:text;not null"` // AES-GCM encrypted upstream API key
|
||||||
Meta datatypes.JSONMap `gorm:"type:jsonb"`
|
Enabled bool `gorm:"not null;default:true;index"`
|
||||||
AddedAt *time.Time
|
// Concurrency caps simultaneous in-flight jobs on this channel. 0 = default.
|
||||||
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.
|
|
||||||
Concurrency int `gorm:"not null;default:0"`
|
Concurrency int `gorm:"not null;default:0"`
|
||||||
|
// 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
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type RefreshProfile struct {
|
// ChannelModel binds a local model_configs row to a channel and records the
|
||||||
ID string `gorm:"primaryKey;size:64"`
|
// upstream model name to send on the wire (the local row's alias stays the
|
||||||
Name string `gorm:"size:255;not null"`
|
// user-facing name).
|
||||||
Pool string `gorm:"size:64;index;not null"`
|
type ChannelModel struct {
|
||||||
Kind string `gorm:"size:64;index;not null"`
|
ChannelID string `gorm:"primaryKey;size:40"`
|
||||||
Cookie string `gorm:"type:text"`
|
ModelID string `gorm:"primaryKey;size:255;index"`
|
||||||
Enabled bool `gorm:"not null;default:true"`
|
UpstreamModel string `gorm:"size:255;not null"`
|
||||||
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
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -244,8 +235,8 @@ func AutoMigrateModels() []any {
|
|||||||
&EventLog{},
|
&EventLog{},
|
||||||
&ModelConfig{},
|
&ModelConfig{},
|
||||||
&CDKCode{},
|
&CDKCode{},
|
||||||
&TokenAccount{},
|
&Channel{},
|
||||||
&RefreshProfile{},
|
&ChannelModel{},
|
||||||
&SiteSetting{},
|
&SiteSetting{},
|
||||||
&StatCounter{},
|
&StatCounter{},
|
||||||
&ConcurrencyGroup{},
|
&ConcurrencyGroup{},
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
package adobe
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// captured from live firefly.adobe.com traffic:
|
|
||||||
//
|
|
||||||
// ark: 91818c89a54748463.1048135404|r=ap-southeast-1|…|rid=84|ag=101|…
|
|
||||||
// ftr: 40d7ba7c70ff478e9c212073ec8254c4_1791795834777_27064_UDF43-mnf-a4_31ck_s/Clo68HDL4=-3629-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-mnf-a4_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, Bfp string }
|
|
||||||
if err := json.Unmarshal(raw, &got); err != nil {
|
|
||||||
t.Fatalf("not json: %v", err)
|
|
||||||
}
|
|
||||||
if len(got.Bfp) != 36 {
|
|
||||||
t.Errorf("bfp not a uuid: %q", got.Bfp)
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A real browser keeps sid / devHex / bfp for the whole session; only the ftr
|
|
||||||
// timestamp advances. Re-randomizing them per call is a bot signal.
|
|
||||||
func TestIdentityStablePerToken(t *testing.T) {
|
|
||||||
decode := func(token string) map[string]string {
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(buildARPSessionID(token))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("not base64: %v", err)
|
|
||||||
}
|
|
||||||
var m map[string]string
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
t.Fatalf("not json: %v", err)
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
a, b := decode("tok-s"), decode("tok-s")
|
|
||||||
if a["sid"] != b["sid"] || a["bfp"] != b["bfp"] {
|
|
||||||
t.Error("sid/bfp changed across calls for the same token")
|
|
||||||
}
|
|
||||||
if a["ftr"][:39] != b["ftr"][:39] {
|
|
||||||
t.Error("ftr device hex changed across calls for the same token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CacheARP stores a cookie-derived arp which getARPSessionID prefers over
|
|
||||||
// synthesis.
|
|
||||||
func TestCacheARPPreferred(t *testing.T) {
|
|
||||||
c := NewClient("", "")
|
|
||||||
CacheARP("tok-c", "eyJjb29raWUiOiJhYXJkZWYa")
|
|
||||||
if got := c.getARPSessionID("tok-c"); got != "eyJjb29raWUiOiJhYXJkZWYa" {
|
|
||||||
t.Errorf("cached arp not preferred: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshARPTimestamp bumps only the ftr timestamp segment; devHex, pid, the
|
|
||||||
// Arkose slot, and every other payload field pass through untouched.
|
|
||||||
func TestRefreshARPTimestamp(t *testing.T) {
|
|
||||||
mk := func(ftr string) string {
|
|
||||||
b, _ := json.Marshal(map[string]string{
|
|
||||||
"sid": "4e54df9a-79cf-4569-b40d-fa793e38b727",
|
|
||||||
"ftr": ftr,
|
|
||||||
"ark": "ark_blob",
|
|
||||||
"bfp": "bf",
|
|
||||||
})
|
|
||||||
return base64.StdEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
old := "40d7ba7c70ff478e9c212073ec8254c4_1791795834777_27064_UDF43-mnf-a4_31ck__tt"
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(refreshARPTimestamp(mk(old)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("refreshed arp not base64: %v", err)
|
|
||||||
}
|
|
||||||
var m map[string]string
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
t.Fatalf("not json: %v", err)
|
|
||||||
}
|
|
||||||
parts := strings.Split(m["ftr"], "_")
|
|
||||||
if len(parts) != 7 {
|
|
||||||
t.Fatalf("ftr segment count changed: %q", m["ftr"])
|
|
||||||
}
|
|
||||||
if parts[0] != "40d7ba7c70ff478e9c212073ec8254c4" || parts[2] != "27064" || parts[4] != "31ck" || parts[6] != "tt" {
|
|
||||||
t.Errorf("ftr fields mangled: %q", m["ftr"])
|
|
||||||
}
|
|
||||||
if parts[1] == "1791795834777" {
|
|
||||||
t.Error("ftr timestamp not bumped")
|
|
||||||
}
|
|
||||||
if m["sid"] != "4e54df9a-79cf-4569-b40d-fa793e38b727" || m["ark"] != "ark_blob" || m["bfp"] != "bf" {
|
|
||||||
t.Errorf("non-ftr fields mangled: %v", m)
|
|
||||||
}
|
|
||||||
// malformed inputs pass through unchanged
|
|
||||||
if got := refreshARPTimestamp("!!!not-base64"); got != "!!!not-base64" {
|
|
||||||
t.Errorf("garbage input changed: %q", got)
|
|
||||||
}
|
|
||||||
if got := refreshARPTimestamp(mk("too_short")); got != mk("too_short") {
|
|
||||||
t.Error("short ftr changed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ARPFromCookie returns the sherlockToken verbatim when present, and rebuilds
|
|
||||||
// the JSON from component cookies otherwise.
|
|
||||||
func TestARPFromCookie(t *testing.T) {
|
|
||||||
if got := ARPFromCookie("a=1; sherlockToken=eyJhIjoxfQ==; b=2"); got != "eyJhIjoxfQ==" {
|
|
||||||
t.Errorf("sherlockToken not returned verbatim: %q", got)
|
|
||||||
}
|
|
||||||
if got := ARPFromCookie("a=1"); got != "" {
|
|
||||||
t.Errorf("expected empty for cookie without arp pieces, got %q", got)
|
|
||||||
}
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(ARPFromCookie(
|
|
||||||
"ff_session_guid=4e54df9a-79cf-4569-b40d-fa793e38b727; forterToken=ft_x%3D-v2; arkose=ark_blob; bfp=bf"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reassembled arp not base64: %v", err)
|
|
||||||
}
|
|
||||||
var m map[string]string
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
t.Fatalf("not json: %v", err)
|
|
||||||
}
|
|
||||||
if m["sid"] != "4e54df9a-79cf-4569-b40d-fa793e38b727" || m["ftr"] != "ft_x=-v2" || m["ark"] != "ark_blob" || m["bfp"] != "bf" {
|
|
||||||
t.Errorf("reassembled arp mismatch: %v", m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,386 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-token stable session identity: a real browser keeps sid / the ftr device
|
|
||||||
// hex / bfp constant for the whole session, only the ftr timestamp advances.
|
|
||||||
// Re-randomizing them every call is a bot signal, so they're cached per token
|
|
||||||
// alongside the pid.
|
|
||||||
var (
|
|
||||||
arpIdentMu sync.Mutex
|
|
||||||
arpIdent = map[string]arpSessionIdent{}
|
|
||||||
)
|
|
||||||
|
|
||||||
type arpSessionIdent struct {
|
|
||||||
sid string
|
|
||||||
devHex string
|
|
||||||
bfp string
|
|
||||||
arp string // cookie-derived value (sherlockToken), preferred over synthesis
|
|
||||||
}
|
|
||||||
|
|
||||||
// cookieValue extracts one cookie value from a raw cookie blob.
|
|
||||||
func cookieValue(cookie, name string) string {
|
|
||||||
decoded, err := url.QueryUnescape(cookie)
|
|
||||||
if err != nil {
|
|
||||||
decoded = cookie
|
|
||||||
}
|
|
||||||
for _, part := range strings.Split(decoded, ";") {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if k, v, ok := strings.Cut(part, "="); ok && k == name {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// ARPFromCookie rebuilds x-arp-session-id from a browser cookie blob. The
|
|
||||||
// sherlockToken cookie IS the arp value verbatim; when it's absent the same
|
|
||||||
// {sid,ark,ftr,bfp} JSON is reassembled from the component cookies —
|
|
||||||
// forterToken is the complete ftr (with the real Arkose payload), arkose the
|
|
||||||
// ark blob, ff_session_guid the sid, bfp the fingerprint id. Returns "" when
|
|
||||||
// no usable pieces are found.
|
|
||||||
func ARPFromCookie(cookie string) string {
|
|
||||||
if st := cookieValue(cookie, "sherlockToken"); st != "" {
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
ftr, ark := cookieValue(cookie, "forterToken"), cookieValue(cookie, "arkose")
|
|
||||||
if ftr == "" {
|
|
||||||
ftr = cookieValue(cookie, "forter")
|
|
||||||
}
|
|
||||||
if ftr == "" || ark == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
payload := map[string]any{"ftr": ftr, "ark": ark}
|
|
||||||
if v := cookieValue(cookie, "ff_session_guid"); v != "" {
|
|
||||||
payload["sid"] = v
|
|
||||||
}
|
|
||||||
if v := cookieValue(cookie, "bfp"); v != "" {
|
|
||||||
payload["bfp"] = v
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(payload)
|
|
||||||
return base64.StdEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshARPTimestamp decodes a cookie-derived arp payload and bumps the ftr
|
|
||||||
// timestamp field to now before re-encoding. ftr layout is
|
|
||||||
// <devHex>_<unix_ms>_<pid>_<ver>_31ck_<arkose>_tt — a stale timestamp reads as
|
|
||||||
// a dead session to the gateway; every other field stays verbatim so the
|
|
||||||
// Forter/Arkose linkage is preserved. Returns arp unchanged on any shape
|
|
||||||
// mismatch.
|
|
||||||
func refreshARPTimestamp(arp string) string {
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(arp)
|
|
||||||
if err != nil {
|
|
||||||
return arp
|
|
||||||
}
|
|
||||||
var m map[string]any
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
return arp
|
|
||||||
}
|
|
||||||
ftr, _ := m["ftr"].(string)
|
|
||||||
parts := strings.Split(ftr, "_")
|
|
||||||
if len(parts) < 3 {
|
|
||||||
return arp
|
|
||||||
}
|
|
||||||
parts[1] = strconv.FormatInt(time.Now().UnixMilli(), 10)
|
|
||||||
m["ftr"] = strings.Join(parts, "_")
|
|
||||||
b, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
return arp
|
|
||||||
}
|
|
||||||
return base64.StdEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CacheARP stores a cookie-derived arp value for token so getARPSessionID
|
|
||||||
// prefers the real (Arkose-bearing) value over the synthesized fallback.
|
|
||||||
func CacheARP(token, arp string) {
|
|
||||||
token, arp = strings.TrimSpace(token), strings.TrimSpace(arp)
|
|
||||||
if token == "" || arp == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
arpIdentMu.Lock()
|
|
||||||
defer arpIdentMu.Unlock()
|
|
||||||
id := arpIdent[token]
|
|
||||||
id.arp = arp
|
|
||||||
arpIdent[token] = id
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildARPSessionID returns the x-arp-session-id value: base64 of
|
|
||||||
// {"sid","ark","ftr","bfp"}, shaped to match live firefly.adobe.com traffic:
|
|
||||||
//
|
|
||||||
// ark: <17hex>.<10digits>|…|rid=<n>|…
|
|
||||||
// ftr: <32hex>_<unix_ms>_<pid>_UDF43-mnf-a4_31ck__tt
|
|
||||||
//
|
|
||||||
// pid, sid, the ftr device hex and bfp stay stable per token — a real browser
|
|
||||||
// keeps them for the whole session and re-randomizing is a bot signal.
|
|
||||||
//
|
|
||||||
// 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 by the native
|
|
||||||
// endpoints; 3p models may require the cookie-derived value (see CacheARP).
|
|
||||||
func buildARPSessionID(token string) string {
|
|
||||||
ftr := strings.Join([]string{
|
|
||||||
arpIdentity(token).devHex,
|
|
||||||
strconv.FormatInt(time.Now().UnixMilli(), 10),
|
|
||||||
strconv.Itoa(allocPID(token)),
|
|
||||||
"UDF43-mnf-a4",
|
|
||||||
"31ck",
|
|
||||||
"",
|
|
||||||
"tt",
|
|
||||||
}, "_")
|
|
||||||
|
|
||||||
id := arpIdentity(token)
|
|
||||||
b, _ := json.Marshal(map[string]any{
|
|
||||||
"sid": id.sid,
|
|
||||||
"ark": buildARKBlob(),
|
|
||||||
"ftr": ftr,
|
|
||||||
"bfp": id.bfp,
|
|
||||||
})
|
|
||||||
return base64.StdEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// arpIdentity returns (creating on first use) the stable per-token identity
|
|
||||||
// pieces, preferring a cached cookie-derived arp value when one exists.
|
|
||||||
func arpIdentity(token string) arpSessionIdent {
|
|
||||||
arpIdentMu.Lock()
|
|
||||||
defer arpIdentMu.Unlock()
|
|
||||||
id, ok := arpIdent[token]
|
|
||||||
if !ok || id.sid == "" {
|
|
||||||
id.sid = uuid.NewString()
|
|
||||||
}
|
|
||||||
if !ok || id.devHex == "" {
|
|
||||||
id.devHex = randomHex(16)
|
|
||||||
}
|
|
||||||
if !ok || id.bfp == "" {
|
|
||||||
id.bfp = uuid.NewString()
|
|
||||||
}
|
|
||||||
arpIdent[token] = id
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,629 +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 (
|
|
||||||
"bufio"
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Vivid 协议 ──────────────────────────────────────────────────────────────
|
|
||||||
// Vivid 上游用自家 API:POST /v1/generate 返回 application/x-ndjson 流 —— 首行
|
|
||||||
// {"jobId":...,"status":"running"},等待期间每 25s 一个空行心跳,末行为终态
|
|
||||||
// (succeeded 带 result 预签名 URL / failed 带 error)。参考素材走两步:
|
|
||||||
// POST /v1/uploads {kind,mime} → PUT 直传预签名 URL → 拿 key 放进 refs。
|
|
||||||
// 断流可携 jobId 重发重连。错误体为 {"error":{"code":"4011|4001|4002|4293|5001"}}。
|
|
||||||
|
|
||||||
// GenerateVividImage submits an image job to a Vivid-protocol upstream.
|
|
||||||
// quality is the tier string the upstream expects ("1K"/"2K"/"4K"), ratio the
|
|
||||||
// "W:H" aspect. refs are pre-uploaded via /v1/uploads into key strings.
|
|
||||||
func (c *Client) GenerateVividImage(ctx context.Context, baseURL, apiKey, model, prompt, ratio, quality string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
|
|
||||||
payload, err := c.vividPayload(ctx, baseURL, apiKey, model, prompt, ratio, quality, 0, refs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return c.vividGenerate(ctx, baseURL, apiKey, payload, downloadResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateVividVideo submits a video job (duration required by ranged models)
|
|
||||||
// to a Vivid-protocol upstream.
|
|
||||||
func (c *Client) GenerateVividVideo(ctx context.Context, baseURL, apiKey, model, prompt, ratio, quality string, durationSeconds int, refs [][]byte, downloadResult bool) ([]byte, string, error) {
|
|
||||||
payload, err := c.vividPayload(ctx, baseURL, apiKey, model, prompt, ratio, quality, durationSeconds, refs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return c.vividGenerate(ctx, baseURL, apiKey, payload, downloadResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) vividPayload(ctx context.Context, baseURL, apiKey, model, prompt, ratio, quality string, durationSeconds int, refs [][]byte) (map[string]any, error) {
|
|
||||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
||||||
if baseURL == "" || apiKey == "" {
|
|
||||||
return nil, ErrAuth
|
|
||||||
}
|
|
||||||
payload := map[string]any{"model": model, "prompt": prompt}
|
|
||||||
if quality != "" {
|
|
||||||
payload["quality"] = quality
|
|
||||||
}
|
|
||||||
if ratio != "" {
|
|
||||||
payload["ratio"] = ratio
|
|
||||||
}
|
|
||||||
if durationSeconds > 0 {
|
|
||||||
payload["duration"] = durationSeconds
|
|
||||||
}
|
|
||||||
if len(refs) > 0 {
|
|
||||||
keys := make([]string, 0, len(refs))
|
|
||||||
for _, ref := range refs {
|
|
||||||
key, err := c.vividUploadRef(ctx, baseURL, apiKey, ref)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
payload["refs"] = keys
|
|
||||||
}
|
|
||||||
return payload, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// vividUploadRef runs the two-step upload: POST /v1/uploads {kind:"image",mime}
|
|
||||||
// → PUT the bytes to the presigned url → returns the key for refs[].
|
|
||||||
func (c *Client) vividUploadRef(ctx context.Context, baseURL, apiKey string, data []byte) (string, error) {
|
|
||||||
mime := http.DetectContentType(data)
|
|
||||||
if mime == "" || mime == "application/octet-stream" {
|
|
||||||
mime = "image/png"
|
|
||||||
}
|
|
||||||
raw, _ := json.Marshal(map[string]any{"kind": "image", "mime": mime})
|
|
||||||
up, err := c.doJSON(ctx, http.MethodPost, baseURL+"/v1/uploads", apiKey, raw)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
putURL := strings.TrimSpace(stringValue(up["url"]))
|
|
||||||
key := strings.TrimSpace(stringValue(up["key"]))
|
|
||||||
if putURL == "" || key == "" {
|
|
||||||
return "", fmt.Errorf("%w: upload session missing url/key", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodPut, putURL, bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header.Set("Content-Type", mime) // must match the signed mime exactly
|
|
||||||
resp, err := httpClient().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, 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", ErrTemporaryUpstream, resp.StatusCode, clip(body, 120))
|
|
||||||
}
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// vividGenerate POSTs /v1/generate and reads the NDJSON stream to the terminal
|
|
||||||
// frame. If the stream drops before terminal, it reconnects once with the
|
|
||||||
// jobId (same-request resume — no new task, no double charge).
|
|
||||||
func (c *Client) vividGenerate(ctx context.Context, baseURL, apiKey string, payload map[string]any, downloadResult bool) ([]byte, string, error) {
|
|
||||||
var last map[string]any
|
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
if sleepCtx(ctx, 2*time.Second) != nil {
|
|
||||||
return nil, "", ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
term, err := c.vividStreamOnce(ctx, baseURL, apiKey, payload)
|
|
||||||
if err == nil && term != nil {
|
|
||||||
last = term
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil && !errors.Is(err, ErrTemporaryUpstream) {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
// Temporary error mid-stream: if we hold a jobId, reconnect mode retries
|
|
||||||
// without creating a new task.
|
|
||||||
if job := strings.TrimSpace(stringValue(payload["jobId"])); job != "" {
|
|
||||||
payload = map[string]any{"jobId": job}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if attempt == 2 {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if last == nil {
|
|
||||||
return nil, "", fmt.Errorf("%w: no terminal frame", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
status := strings.ToLower(strings.TrimSpace(stringValue(last["status"])))
|
|
||||||
switch status {
|
|
||||||
case "succeeded", "completed", "success":
|
|
||||||
resultURL := strings.TrimSpace(stringValue(last["result"]))
|
|
||||||
if resultURL == "" {
|
|
||||||
return nil, "", fmt.Errorf("%w: succeeded frame missing result", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, resultURL, nil
|
|
||||||
}
|
|
||||||
data, err := c.vividDownload(ctx, resultURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return data, resultURL, nil
|
|
||||||
default:
|
|
||||||
reason := strings.TrimSpace(stringValue(last["error"]))
|
|
||||||
if reason == "" {
|
|
||||||
reason = "upstream job " + status
|
|
||||||
}
|
|
||||||
if isCreditError(reason) || strings.Contains(reason, "积分不足") || strings.Contains(reason, "余额") {
|
|
||||||
return nil, "", fmt.Errorf("%w: %s", ErrQuotaExhausted, clip([]byte(reason), 160))
|
|
||||||
}
|
|
||||||
return nil, "", errors.New("custom: " + clip([]byte(reason), 160))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// vividStreamOnce performs one /v1/generate request and scans the NDJSON body
|
|
||||||
// to the last frame. Returns the terminal frame, or a temporary error on
|
|
||||||
// transport failure (caller's reconnect logic decides what to do with jobId).
|
|
||||||
func (c *Client) vividStreamOnce(ctx context.Context, baseURL, apiKey string, payload map[string]any) (map[string]any, error) {
|
|
||||||
raw, _ := json.Marshal(payload)
|
|
||||||
req, err := http.NewRequest(http.MethodPost, baseURL+"/v1/generate", bytes.NewReader(raw))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("X-Wait-Seconds", "7200")
|
|
||||||
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 {
|
|
||||||
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
|
|
||||||
// Remember the jobId for potential reconnect.
|
|
||||||
if job := strings.TrimSpace(stringValue(frame["jobId"])); job != "" {
|
|
||||||
payload["jobId"] = job
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := sc.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
|
||||||
}
|
|
||||||
return last, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// vividError maps the {"error":{"code":"NNNN"}} envelope to our error classes.
|
|
||||||
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", ErrQuotaExhausted, msg)
|
|
||||||
case "4293", "5001":
|
|
||||||
return fmt.Errorf("%w: %s", ErrTemporaryUpstream, msg)
|
|
||||||
case "4001":
|
|
||||||
return errors.New("custom: " + msg) // 参数/业务拒绝 → 永久错,不换号
|
|
||||||
}
|
|
||||||
return mapStatus(status, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// vividDownload fetches the presigned result URL — no auth header (it's signed).
|
|
||||||
func (c *Client) vividDownload(ctx context.Context, url string) ([]byte, error) {
|
|
||||||
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()
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return nil, fmt.Errorf("%w: result 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 result", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,179 +0,0 @@
|
|||||||
package custom
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// vividServer serves /v1/uploads, /v1/generate (ndjson) and a fake presigned
|
|
||||||
// result URL, all under one httptest server.
|
|
||||||
func vividServer(t *testing.T, frames []string, httpStatus int, errCode string) *httptest.Server {
|
|
||||||
t.Helper()
|
|
||||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/v1/uploads":
|
|
||||||
var req struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Mime string `json:"mime"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
if req.Kind != "image" || req.Mime == "" {
|
|
||||||
t.Errorf("bad upload request: %+v", req)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
fmt.Fprintf(w, `{"uuid":"u1","key":"u/refs/image/u1.png","url":"%s","expiresAt":9999999999}`,
|
|
||||||
// presigned URL points back at this server's /put endpoint
|
|
||||||
"http://"+r.Host+"/put")
|
|
||||||
case "/put":
|
|
||||||
if r.Method != http.MethodPut {
|
|
||||||
t.Errorf("expected PUT, got %s", r.Method)
|
|
||||||
}
|
|
||||||
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
|
|
||||||
t.Errorf("bad content-type %q", ct)
|
|
||||||
}
|
|
||||||
io.Copy(io.Discard, r.Body)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
case "/v1/generate":
|
|
||||||
if httpStatus != 0 {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(httpStatus)
|
|
||||||
fmt.Fprintf(w, `{"error":{"code":%q,"message":"boom"}}`, errCode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
|
||||||
for _, f := range frames {
|
|
||||||
fmt.Fprintln(w, f)
|
|
||||||
fmt.Fprintln(w) // heartbeat line
|
|
||||||
}
|
|
||||||
case "/result.png":
|
|
||||||
w.Write([]byte("PNGDATA"))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVividGenerateImageSuccess(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/v1/generate":
|
|
||||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
|
||||||
fmt.Fprintln(w, `{"jobId":"j1","status":"running"}`)
|
|
||||||
fmt.Fprintln(w) // heartbeat
|
|
||||||
fmt.Fprintf(w, `{"jobId":"j1","status":"succeeded","result":"http://%s/result.png","creditsCharged":10}`+"\n", r.Host)
|
|
||||||
case "/result.png":
|
|
||||||
w.Write([]byte("PNGDATA"))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
data, url, err := NewClient().GenerateVividImage(context.Background(), srv.URL, "vk_test", "m1", "hi", "1:1", "1K", nil, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate: %v", err)
|
|
||||||
}
|
|
||||||
if string(data) != "PNGDATA" {
|
|
||||||
t.Fatalf("data=%q", data)
|
|
||||||
}
|
|
||||||
if url == "" {
|
|
||||||
t.Fatal("empty url")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVividGenerateImageURLPassthrough(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
|
||||||
fmt.Fprintln(w, `{"jobId":"j1","status":"running"}`)
|
|
||||||
fmt.Fprintln(w, `{"jobId":"j1","status":"succeeded","result":"https://fs.example.com/x.png"}`)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
data, url, err := NewClient().GenerateVividImage(context.Background(), srv.URL, "vk_test", "m1", "hi", "1:1", "1K", nil, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate: %v", err)
|
|
||||||
}
|
|
||||||
if data != nil {
|
|
||||||
t.Fatalf("expected no bytes, got %d", len(data))
|
|
||||||
}
|
|
||||||
if url != "https://fs.example.com/x.png" {
|
|
||||||
t.Fatalf("url=%q", url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVividGenerateFailedFrame(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
|
||||||
fmt.Fprintln(w, `{"jobId":"j1","status":"running"}`)
|
|
||||||
fmt.Fprintln(w, `{"jobId":"j1","status":"failed","error":"积分不足","refunded":true}`)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
_, _, err := NewClient().GenerateVividImage(context.Background(), srv.URL, "vk_test", "m1", "hi", "1:1", "1K", nil, false)
|
|
||||||
if !errors.Is(err, ErrQuotaExhausted) {
|
|
||||||
t.Fatalf("want quota, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVividErrorCodes(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
status int
|
|
||||||
code string
|
|
||||||
want error
|
|
||||||
}{
|
|
||||||
{401, "4011", ErrAuth},
|
|
||||||
{402, "4002", ErrQuotaExhausted},
|
|
||||||
{429, "4293", ErrTemporaryUpstream},
|
|
||||||
{500, "5001", ErrTemporaryUpstream},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
srv := vividServer(t, nil, tc.status, tc.code)
|
|
||||||
_, _, err := NewClient().GenerateVividImage(context.Background(), srv.URL, "vk_test", "m1", "hi", "1:1", "1K", nil, false)
|
|
||||||
srv.Close()
|
|
||||||
if !errors.Is(err, tc.want) {
|
|
||||||
t.Fatalf("code %s: want %v, got %v", tc.code, tc.want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVividUploadRefFlow(t *testing.T) {
|
|
||||||
var putOK bool
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/v1/uploads":
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
fmt.Fprintf(w, `{"key":"k123","url":"http://%s/up"}`, r.Host)
|
|
||||||
case "/up":
|
|
||||||
if r.Method == http.MethodPut {
|
|
||||||
putOK = true
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
case "/v1/generate":
|
|
||||||
var req map[string]any
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
refs, _ := req["refs"].([]any)
|
|
||||||
if len(refs) != 1 || refs[0] != "k123" {
|
|
||||||
t.Errorf("refs=%v", req["refs"])
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
|
||||||
fmt.Fprintln(w, `{"jobId":"j1","status":"succeeded","result":"https://x/1.png"}`)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // fake png magic
|
|
||||||
_, url, err := NewClient().GenerateVividImage(context.Background(), srv.URL, "vk_test", "m1", "hi", "1:1", "1K", [][]byte{png}, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate: %v", err)
|
|
||||||
}
|
|
||||||
if !putOK {
|
|
||||||
t.Fatal("PUT never happened")
|
|
||||||
}
|
|
||||||
if url == "" {
|
|
||||||
t.Fatal("no url")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -618,9 +618,16 @@ func (r *EventRepository) MarkRefunded(ctx context.Context, eventID string) (boo
|
|||||||
return res.RowsAffected == 1, nil
|
return res.RowsAffected == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAccount stamps which provider account is fulfilling an in-flight event.
|
// SetUpstreamJob records the job id the upstream assigned (NDJSON jobId frame)
|
||||||
// Called when generation commits to a token, so the accounts view can count
|
// for cross-instance debugging. Best-effort.
|
||||||
// pending events per account and an abandoned-event purge can attribute back.
|
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 {
|
func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID, accountEmail string) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.EventLog{}).
|
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
|
models *repo.ModelRepository
|
||||||
events *repo.EventRepository
|
events *repo.EventRepository
|
||||||
settings *repo.SiteSettingRepository
|
settings *repo.SiteSettingRepository
|
||||||
tokens *repo.TokenRepository
|
channels *repo.ChannelRepository
|
||||||
cdks *repo.CDKRepository
|
cdks *repo.CDKRepository
|
||||||
store *storage.Client
|
store *storage.Client
|
||||||
showcase *repo.ShowcaseRepository
|
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{
|
return &AdminReadService{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
users: users,
|
users: users,
|
||||||
models: models,
|
models: models,
|
||||||
events: events,
|
events: events,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
tokens: tokens,
|
channels: channels,
|
||||||
cdks: cdks,
|
cdks: cdks,
|
||||||
store: store,
|
store: store,
|
||||||
showcase: showcase,
|
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),
|
"duration_prices_agent": map[string]any(item.DurationPricesAgent),
|
||||||
"durations": repo.JSONStrings(item.Durations),
|
"durations": repo.JSONStrings(item.Durations),
|
||||||
"max_reference_images": item.MaxReferenceImages,
|
"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,
|
"reference_mode": item.ReferenceMode,
|
||||||
"weight": item.Weight,
|
"weight": item.Weight,
|
||||||
"generation_count": item.GenerationCount,
|
"generation_count": item.GenerationCount,
|
||||||
@@ -194,24 +200,24 @@ func (s *AdminReadService) UserNameMap(ctx context.Context) (map[string]string,
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountNameMap builds a token-account id -> display label lookup (account
|
// AccountNameMap builds a channel id -> display name lookup used to annotate
|
||||||
// email, else display name, else id) used to annotate log rows with which
|
// log rows with which channel fulfilled each generation (event_logs.account_id
|
||||||
// provider account 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make(map[string]string, len(accounts))
|
out := make(map[string]string, len(channels))
|
||||||
for _, a := range accounts {
|
for _, ch := range channels {
|
||||||
label := strings.TrimSpace(a.AccountEmail)
|
label := strings.TrimSpace(ch.Name)
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = strings.TrimSpace(a.AccountDisplayName)
|
label = ch.ID
|
||||||
}
|
}
|
||||||
if label == "" {
|
out[ch.ID] = label
|
||||||
label = a.ID
|
|
||||||
}
|
|
||||||
out[a.ID] = label
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -420,66 +426,30 @@ func (s *AdminReadService) Invites(ctx context.Context) ([]repo.InviteRecord, *r
|
|||||||
return s.users.AllInvites(ctx, reward)
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tokens, err := s.tokens.List(ctx)
|
out := make([]map[string]any, 0, len(channels))
|
||||||
if err != nil {
|
for _, ch := range channels {
|
||||||
return nil, err
|
modelIDs, _ := s.channels.ModelIDsByChannel(ctx, ch.ID)
|
||||||
}
|
|
||||||
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 = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"name": item.Name,
|
"id": ch.ID,
|
||||||
"token_pool": item.Pool,
|
"name": ch.Name,
|
||||||
"type": item.Type,
|
"base_url": ch.BaseURL,
|
||||||
"model_count": modelCounts[item.Name],
|
"enabled": ch.Enabled,
|
||||||
"tokens_total": count.active + count.disabled + count.quota,
|
"concurrency": ch.Concurrency,
|
||||||
"tokens_active": count.active,
|
"weight": ch.Weight,
|
||||||
"tokens_disabled": count.disabled,
|
"model_count": len(modelIDs),
|
||||||
"tokens_quota": count.quota,
|
"success_total": ch.SuccessTotal,
|
||||||
|
"used_credits": ch.UsedCredits,
|
||||||
|
"type": "vivid",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -28,18 +28,18 @@ type AdminWriteService struct {
|
|||||||
models *repo.ModelRepository
|
models *repo.ModelRepository
|
||||||
events *repo.EventRepository
|
events *repo.EventRepository
|
||||||
apiKeys *repo.APIKeyRepository
|
apiKeys *repo.APIKeyRepository
|
||||||
tokens *repo.TokenRepository
|
channels *repo.ChannelRepository
|
||||||
orders *repo.OrderRepository
|
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{
|
return &AdminWriteService{
|
||||||
users: users,
|
users: users,
|
||||||
showcase: showcase,
|
showcase: showcase,
|
||||||
models: models,
|
models: models,
|
||||||
events: events,
|
events: events,
|
||||||
apiKeys: apiKeys,
|
apiKeys: apiKeys,
|
||||||
tokens: tokens,
|
channels: channels,
|
||||||
orders: orders,
|
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")
|
return nil, errors.New("type must be image or video")
|
||||||
}
|
}
|
||||||
if provider == "" {
|
if provider == "" {
|
||||||
return nil, errors.New("provider required")
|
provider = "channel"
|
||||||
}
|
}
|
||||||
if err := s.validateModelNameSpace(ctx, "", modelID, alias); err != nil {
|
if err := s.validateModelNameSpace(ctx, "", modelID, alias); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -401,6 +401,8 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
|||||||
DurationPricesAgent: jsonMap(body["duration_prices_agent"]),
|
DurationPricesAgent: jsonMap(body["duration_prices_agent"]),
|
||||||
Durations: jsonArray(body["durations"]),
|
Durations: jsonArray(body["durations"]),
|
||||||
MaxReferenceImages: intValue(body["max_reference_images"]),
|
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"),
|
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
|
||||||
Weight: intValue(body["weight"]),
|
Weight: intValue(body["weight"]),
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -434,11 +436,10 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
|||||||
}
|
}
|
||||||
if _, ok := body["provider"]; ok {
|
if _, ok := body["provider"]; ok {
|
||||||
provider := strings.TrimSpace(stringValue(body["provider"]))
|
provider := strings.TrimSpace(stringValue(body["provider"]))
|
||||||
if provider == "" {
|
if provider != "" {
|
||||||
return nil, errors.New("provider required")
|
|
||||||
}
|
|
||||||
patch["provider"] = provider
|
patch["provider"] = provider
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Only touch `enabled` when the caller explicitly sends a non-null value;
|
// Only touch `enabled` when the caller explicitly sends a non-null value;
|
||||||
// mirrors Python models_store.update ("enabled" in fields and is not None).
|
// mirrors Python models_store.update ("enabled" in fields and is not None).
|
||||||
// Without this guard a PATCH that omits the field would default it to false
|
// Without this guard a PATCH that omits the field would default it to false
|
||||||
@@ -477,9 +478,27 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
|||||||
if _, ok := body["max_reference_images"]; ok {
|
if _, ok := body["max_reference_images"]; ok {
|
||||||
patch["max_reference_images"] = intValue(body["max_reference_images"])
|
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 {
|
if _, ok := body["reference_mode"]; ok {
|
||||||
patch["reference_mode"] = defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none")
|
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 {
|
if _, ok := body["weight"]; ok {
|
||||||
patch["weight"] = intValue(body["weight"])
|
patch["weight"] = intValue(body["weight"])
|
||||||
}
|
}
|
||||||
@@ -524,52 +543,14 @@ func (s *AdminWriteService) DeleteModel(ctx context.Context, modelID string) err
|
|||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
// Clean up: strip this model id from every custom upstream account's
|
// Clean up: drop every channel binding pointing at the deleted model so no
|
||||||
// supported-models list so no orphan reference is left behind.
|
// orphan reference is left behind.
|
||||||
s.removeModelFromUpstreams(ctx, modelID)
|
if s.channels != nil {
|
||||||
|
_ = s.channels.DeleteBindingsByModel(ctx, modelID)
|
||||||
|
}
|
||||||
return nil
|
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) {
|
func (s *AdminWriteService) ClearLogs(ctx context.Context) (int64, error) {
|
||||||
return s.events.DeleteAll(ctx)
|
return s.events.DeleteAll(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -109,8 +110,15 @@ func (s *APIKeyService) DeleteOne(ctx context.Context, userID, keyID string) err
|
|||||||
return s.keys.DeleteByID(ctx, userID, keyID)
|
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) {
|
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 {
|
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"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"backend/internal/model"
|
|
||||||
"backend/internal/repo"
|
"backend/internal/repo"
|
||||||
"backend/internal/storage"
|
"backend/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaintenanceService runs the periodic self-healing sweep that the Python
|
// MaintenanceService runs the periodic self-healing sweep. Without it stale
|
||||||
// original did via a 60s daemon thread plus read-time lazy cleanup. Without it
|
// pending events permanently block a user's generation gate, and old media /
|
||||||
// the Go token pool only ever loses capacity: tokens never re-activate after a
|
// logs / temp objects accumulate unbounded. Token-pool and cookie-refresh
|
||||||
// quota reset, cookies never auto-renew, stale pending events permanently block
|
// upkeep is gone — channels are stateless upstream credentials.
|
||||||
// a user's generation gate, and old media/logs accumulate unbounded.
|
|
||||||
type MaintenanceService struct {
|
type MaintenanceService struct {
|
||||||
tokens *repo.TokenRepository
|
|
||||||
tokenSvc *TokenService
|
|
||||||
events *repo.EventRepository
|
events *repo.EventRepository
|
||||||
users *repo.UserRepository
|
users *repo.UserRepository
|
||||||
refresh *RefreshProfileService
|
|
||||||
settings *repo.SiteSettingRepository
|
settings *repo.SiteSettingRepository
|
||||||
store *storage.Client
|
store *storage.Client
|
||||||
inflight *InflightRegistry
|
inflight *InflightRegistry
|
||||||
@@ -36,13 +30,10 @@ type MaintenanceService struct {
|
|||||||
lastMediaPrune time.Time
|
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{
|
return &MaintenanceService{
|
||||||
tokens: tokens,
|
|
||||||
tokenSvc: tokenSvc,
|
|
||||||
events: events,
|
events: events,
|
||||||
users: users,
|
users: users,
|
||||||
refresh: refresh,
|
|
||||||
settings: settings,
|
settings: settings,
|
||||||
store: store,
|
store: store,
|
||||||
inflight: inflight,
|
inflight: inflight,
|
||||||
@@ -63,10 +54,6 @@ func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService,
|
|||||||
func (m *MaintenanceService) Run(ctx context.Context) {
|
func (m *MaintenanceService) Run(ctx context.Context) {
|
||||||
ticker := time.NewTicker(m.interval)
|
ticker := time.NewTicker(m.interval)
|
||||||
defer ticker.Stop()
|
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)
|
m.tick(ctx)
|
||||||
for {
|
for {
|
||||||
select {
|
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) {
|
func (m *MaintenanceService) tick(ctx context.Context) {
|
||||||
// 0. Auto-cancel unpaid recharge orders past their 30-min TTL.
|
// 0. Auto-cancel unpaid recharge orders past their 30-min TTL.
|
||||||
if m.orders != nil {
|
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
|
// 1. Fail long-pending events so they stop blocking the per-user gate, and
|
||||||
// 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
|
|
||||||
// refund the credits debited up-front for each abandoned generation (the
|
// refund the credits debited up-front for each abandoned generation (the
|
||||||
// normal failure-refund path never ran for a process-restart orphan).
|
// normal failure-refund path never ran for a process-restart orphan).
|
||||||
if purged, err := m.events.PurgeStale(ctx, m.stalePending, m.stalePendingVid); err != nil {
|
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) {
|
if m.inflight != nil && m.inflight.Cancel(e.ID) {
|
||||||
cancelled++
|
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 {
|
if e.UserID == "" || e.Cost <= 0 {
|
||||||
continue
|
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)
|
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)
|
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.
|
// mediaPruneEvery still gates it in case the interval is ever shortened.
|
||||||
if time.Since(m.lastMediaPrune) >= m.mediaPruneEvery {
|
if time.Since(m.lastMediaPrune) >= m.mediaPruneEvery {
|
||||||
m.pruneMedia(ctx)
|
m.pruneMedia(ctx)
|
||||||
@@ -274,10 +139,11 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
days := m.retentionDays(ctx, "media.retention_days")
|
days := m.retentionDays(ctx, "media.retention_days")
|
||||||
if days <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
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, "")
|
objs, err := m.store.List(ctx, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("maintenance: list media: %v", err)
|
log.Printf("maintenance: list media: %v", err)
|
||||||
@@ -303,10 +169,20 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
|
|||||||
removed, skipped := 0, 0
|
removed, skipped := 0, 0
|
||||||
var clearedKeys []string
|
var clearedKeys []string
|
||||||
for _, o := range objs {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := pinned[strings.TrimLeft(o.Key, "/")]; ok {
|
if _, ok := pinned[key]; ok {
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
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)
|
// DeAI applies 去AI特征 post-processing to the generated image (image only)
|
||||||
// and charges the per-tier surcharge on top of the model price.
|
// and charges the per-tier surcharge on top of the model price.
|
||||||
DeAI bool
|
DeAI bool
|
||||||
// AccountID pins an admin test to one specific provider account (账号生图测试).
|
// ChannelID pins an admin test to one specific channel (渠道生图测试).
|
||||||
AccountID string
|
ChannelID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserGenerationService) Generate(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) {
|
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"
|
// validateReferenceMode mirrors the /v1 checks: the override must be "frame",
|
||||||
// or "asset", the model must support references at all, and frame mode carries
|
// "asset" or "media", the model must support references at all, and frame mode
|
||||||
// at most 2 images (first+last frame).
|
// 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 {
|
func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int) error {
|
||||||
rm = strings.TrimSpace(rm)
|
rm = strings.TrimSpace(rm)
|
||||||
if rm == "" {
|
if rm == "" {
|
||||||
@@ -107,8 +108,8 @@ func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int
|
|||||||
if supported == "" || supported == "none" {
|
if supported == "" || supported == "none" {
|
||||||
return fmt.Errorf("%w: reference_mode not supported for this model", ErrUnsupportedParams)
|
return fmt.Errorf("%w: reference_mode not supported for this model", ErrUnsupportedParams)
|
||||||
}
|
}
|
||||||
if rm != "frame" && rm != "asset" {
|
if rm != "frame" && rm != "asset" && rm != "media" {
|
||||||
return fmt.Errorf("%w: reference_mode must be 'frame' or 'asset'", ErrUnsupportedParams)
|
return fmt.Errorf("%w: reference_mode must be 'frame', 'asset' or 'media'", ErrUnsupportedParams)
|
||||||
}
|
}
|
||||||
if rm == "frame" && refCount > 2 {
|
if rm == "frame" && refCount > 2 {
|
||||||
return fmt.Errorf("%w: frame mode supports at most 2 reference images (first+last frame), got %d", ErrUnsupportedParams, refCount)
|
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,
|
AspectRatio: in.Ratio,
|
||||||
Resolution: in.Resolution,
|
Resolution: in.Resolution,
|
||||||
ReferenceImages: in.ReferenceImages,
|
ReferenceImages: in.ReferenceImages,
|
||||||
AccountID: in.AccountID,
|
ChannelID: in.ChannelID,
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
return s.v1.prepareAdminTestImage(ctx, principal, V1ImageRequest{
|
return s.v1.prepareAdminTestImage(ctx, principal, V1ImageRequest{
|
||||||
@@ -146,7 +147,7 @@ func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User,
|
|||||||
AspectRatio: in.Ratio,
|
AspectRatio: in.Ratio,
|
||||||
Resolution: in.Resolution,
|
Resolution: in.Resolution,
|
||||||
ReferenceImages: in.ReferenceImages,
|
ReferenceImages: in.ReferenceImages,
|
||||||
AccountID: in.AccountID,
|
ChannelID: in.ChannelID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+906
-3158
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: '参考图模式' }
|
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 catalog = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const selectedId = ref(props.model?.id || '')
|
const selectedId = ref(props.model?.id || '')
|
||||||
@@ -37,15 +45,28 @@ const entry = computed(() => {
|
|||||||
return catalog.value.find((e) => e.id === selectedId.value) || null
|
return catalog.value.find((e) => e.id === selectedId.value) || null
|
||||||
})
|
})
|
||||||
const isVideo = computed(() => entry.value?.type === 'video')
|
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)
|
// Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K)
|
||||||
// regardless of how the catalog/stored record happens to list them.
|
// regardless of how the catalog/stored record happens to list them. For
|
||||||
const resolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
|
// 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 价,实付 = 分辨率价 + 每秒价 × 秒数)。目录用
|
// 按秒计价的视频模型(一个 /s 价,实付 = 分辨率价 + 每秒价 × 秒数)。目录用
|
||||||
// per_second 声明;已入库的模型则看它是否已存了 per_second 价。
|
// per_second 声明;已入库的模型则看它是否已存了 per_second 价。渠道导入的
|
||||||
|
// duration range 模型(duration_min/max,无固定档)也只能走 per_second。
|
||||||
const isPerSecond = computed(() => {
|
const isPerSecond = computed(() => {
|
||||||
const e = entry.value
|
const e = entry.value
|
||||||
if (!e) return false
|
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(() => {
|
const durationTiers = computed(() => {
|
||||||
@@ -53,7 +74,9 @@ const durationTiers = computed(() => {
|
|||||||
if (!e) return []
|
if (!e) return []
|
||||||
if (isPerSecond.value) return ['per_second']
|
if (isPerSecond.value) return ['per_second']
|
||||||
const ds = e.durations || []
|
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.
|
// Dropdown options: catalog rows not already in the managed store.
|
||||||
@@ -71,14 +94,9 @@ function resetPrices(e) {
|
|||||||
if (!e) return
|
if (!e) return
|
||||||
// Both image and video price per resolution; video ALSO prices per duration
|
// Both image and video price per resolution; video ALSO prices per duration
|
||||||
// (real video price = resolution price + duration price).
|
// (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 (e.type === 'video') {
|
||||||
if (isPerSecond.value) {
|
for (const d of durationTiers.value) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
|
||||||
videoPrices.value['per_second'] = ''
|
|
||||||
videoPricesAgent.value['per_second'] = ''
|
|
||||||
} else {
|
|
||||||
for (const d of (e.durations || [])) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,23 +112,19 @@ onMounted(async () => {
|
|||||||
const m = props.model
|
const m = props.model
|
||||||
// Resolution prices apply to both; video additionally has duration prices.
|
// Resolution prices apply to both; video additionally has duration prices.
|
||||||
// Agent prices are an optional overlay (blank = follows the normal price).
|
// 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] ?? ''
|
imagePrices.value[r] = m.prices?.[r] ?? ''
|
||||||
imagePricesAgent.value[r] = m.prices_agent?.[r] ?? ''
|
imagePricesAgent.value[r] = m.prices_agent?.[r] ?? ''
|
||||||
}
|
}
|
||||||
if (m.type === 'video') {
|
if (m.type === 'video') {
|
||||||
if (m.duration_prices?.per_second != null) {
|
for (const d of durationTiers.value) {
|
||||||
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] ?? ''
|
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
|
||||||
videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
|
videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -132,9 +146,9 @@ async function save() {
|
|||||||
|
|
||||||
let payload
|
let payload
|
||||||
if (e.type === 'video') {
|
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 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)
|
const duration_prices_agent = collect(videoPricesAgent.value, durationTiers.value)
|
||||||
if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return }
|
if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return }
|
||||||
if (!Object.keys(duration_prices).length) { error.value = '至少填写一个时长价格'; return }
|
if (!Object.keys(duration_prices).length) { error.value = '至少填写一个时长价格'; return }
|
||||||
@@ -142,9 +156,12 @@ async function save() {
|
|||||||
type: 'video',
|
type: 'video',
|
||||||
provider: e.provider,
|
provider: e.provider,
|
||||||
ratios: e.ratios || [],
|
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,
|
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,
|
duration_prices,
|
||||||
prices_agent,
|
prices_agent,
|
||||||
duration_prices_agent,
|
duration_prices_agent,
|
||||||
@@ -154,8 +171,8 @@ async function save() {
|
|||||||
weight: Number(weight.value) || 0,
|
weight: Number(weight.value) || 0,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const prices = collect(imagePrices.value, e.resolutions || [])
|
const prices = collect(imagePrices.value, resolutions.value)
|
||||||
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
|
const prices_agent = collect(imagePricesAgent.value, resolutions.value)
|
||||||
if (!Object.keys(prices).length) { error.value = '至少填写一个画质价格'; return }
|
if (!Object.keys(prices).length) { error.value = '至少填写一个画质价格'; return }
|
||||||
payload = {
|
payload = {
|
||||||
type: 'image',
|
type: 'image',
|
||||||
@@ -205,7 +222,7 @@ async function save() {
|
|||||||
placeholder="选择一个支持的模型" />
|
placeholder="选择一个支持的模型" />
|
||||||
<div v-else class="field font-mono !cursor-default opacity-90">{{ entry?.id }}</div>
|
<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 v-if="!isEdit && !addOptions.length" class="text-[11px] text-amber-300/80 mt-1.5">
|
||||||
所有支持的模型都已添加。
|
模型经「渠道管理 → 拉取上游模型」导入。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -224,7 +241,7 @@ async function save() {
|
|||||||
{{ isVideo ? '生视频' : '生图' }}
|
{{ isVideo ? '生视频' : '生图' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[11px] text-white/45 capitalize">{{ entry.provider }}</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>
|
||||||
|
|
||||||
<div class="grid grid-cols-[3.5rem_1fr] gap-x-3 gap-y-1.5 text-[11px]">
|
<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">
|
<template v-if="isVideo">
|
||||||
<span class="text-white/40">分辨率</span>
|
<span class="text-white/40">分辨率</span>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
|
<span v-for="r in storedResolutions" :key="r" class="ro-chip">{{ r }}</span>
|
||||||
<span v-if="!resolutions.length" class="text-white/30">—</span>
|
<span v-if="!storedResolutions.length" class="text-white/30">—</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-white/40">时长</span>
|
<span class="text-white/40">时长</span>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<span v-for="d in (entry.durations || [])" :key="d" class="ro-chip">{{ d }}</span>
|
<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>
|
</div>
|
||||||
<span class="text-white/40">参考图</span>
|
<span class="text-white/40">参考图</span>
|
||||||
<div class="text-white/70">
|
<div class="text-white/70">
|
||||||
@@ -256,8 +274,8 @@ async function save() {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="text-white/40">画质</span>
|
<span class="text-white/40">画质</span>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
|
<span v-for="r in storedResolutions" :key="r" class="ro-chip">{{ r }}</span>
|
||||||
<span v-if="!resolutions.length" class="text-white/30">—</span>
|
<span v-if="!storedResolutions.length" class="text-white/30">—</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-white/40">参考图</span>
|
<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>
|
<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
|
// (core/video_models.FAMILIES), not the user-edited managed-model record
|
||||||
// which may have stale max_reference_images / reference_mode values.
|
// which may have stale max_reference_images / reference_mode values.
|
||||||
const familyPreset = ref(null)
|
const familyPreset = ref(null)
|
||||||
|
const channels = ref([]) // 渠道 pin 候选(空 = 自动调度)
|
||||||
|
const channelId = ref('')
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!isVideo) return
|
if (isVideo) {
|
||||||
const r = await api('/video-presets')
|
const r = await api('/video-presets')
|
||||||
const list = r.data?.data || []
|
const list = r.data?.data || []
|
||||||
familyPreset.value = list.find((p) => p.key === props.model.id) || null
|
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)
|
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.
|
// Backend accepts raw base64 only — strip the "data:...;base64," prefix.
|
||||||
payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
|
payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
|
||||||
}
|
}
|
||||||
|
if (channelId.value) payload.channel_id = channelId.value
|
||||||
recoverSubmitTs = Date.now()
|
recoverSubmitTs = Date.now()
|
||||||
const r = await api('/test', jsonBody('POST', payload))
|
const r = await api('/test', jsonBody('POST', payload))
|
||||||
if (r.ok && r.data?.url) {
|
if (r.ok && r.data?.url) {
|
||||||
@@ -239,6 +245,15 @@ async function recover() {
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- reference images -->
|
||||||
<div v-if="maxRefs > 0">
|
<div v-if="maxRefs > 0">
|
||||||
<label class="lbl">
|
<label class="lbl">
|
||||||
|
|||||||
@@ -1,137 +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 protocol = ref(props.account?.protocol === 'vivid' ? 'vivid' : 'openai')
|
|
||||||
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(','),
|
|
||||||
protocol: protocol.value,
|
|
||||||
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 ? '编辑上游' : '添加上游' }}</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">协议</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button v-for="p in [{id:'openai',label:'OpenAI',desc:'/v1/images/* 标准格式'},{id:'vivid',label:'Vivid',desc:'/v1/generate NDJSON 流'}]" :key="p.id" type="button"
|
|
||||||
@click="protocol = p.id"
|
|
||||||
class="flex-1 rounded-lg ring-1 px-3 py-2 text-left transition-colors"
|
|
||||||
:class="protocol === p.id ? 'ring-sky-400 bg-sky-500/10' : 'ring-slate-200 bg-white hover:ring-slate-300'">
|
|
||||||
<div class="text-xs font-medium" :class="protocol === p.id ? 'text-sky-700' : 'text-slate-700'">{{ p.label }}</div>
|
|
||||||
<div class="text-[11px] text-slate-400 font-mono mt-0.5">{{ p.desc }}</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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 = [
|
const tabs = [
|
||||||
{ label: '概览', to: '/admin/overview', icon: 'overview' },
|
{ label: '概览', to: '/admin/overview', icon: 'overview' },
|
||||||
{ label: '模型管理', to: '/admin/models', icon: 'models' },
|
{ 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/users', icon: 'accounts' },
|
||||||
{ label: '并发分组', to: '/admin/concurrency', icon: 'shield' },
|
{ label: '并发分组', to: '/admin/concurrency', icon: 'shield' },
|
||||||
{ label: '违禁词管理', icon: 'ban', children: [
|
{ label: '违禁词管理', icon: 'ban', children: [
|
||||||
@@ -55,7 +55,7 @@ watch(() => route.path, () => {
|
|||||||
<Logo v-else :size="32" />
|
<Logo v-else :size="32" />
|
||||||
<div class="leading-tight min-w-0">
|
<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-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>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ watch(() => route.path, () => {
|
|||||||
</div>
|
</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">
|
<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>
|
<div class="text-[color:var(--fg-faint)] mr-3">/</div>
|
||||||
<h1 class="text-sm font-semibold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</h1>
|
<h1 class="text-sm font-semibold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</h1>
|
||||||
</header>
|
</header>
|
||||||
@@ -137,6 +137,16 @@ watch(() => route.path, () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.admin-link {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const DocsView = () => import('./views/DocsView.vue')
|
|||||||
const AboutView = () => import('./views/AboutView.vue')
|
const AboutView = () => import('./views/AboutView.vue')
|
||||||
const OverviewView = () => import('./views/OverviewView.vue')
|
const OverviewView = () => import('./views/OverviewView.vue')
|
||||||
const ModelsView = () => import('./views/ModelsView.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 UsersView = () => import('./views/UsersView.vue')
|
||||||
const ConcurrencyView = () => import('./views/ConcurrencyView.vue')
|
const ConcurrencyView = () => import('./views/ConcurrencyView.vue')
|
||||||
const BannedWordsView = () => import('./views/BannedWordsView.vue')
|
const BannedWordsView = () => import('./views/BannedWordsView.vue')
|
||||||
@@ -61,7 +61,7 @@ const routes = [
|
|||||||
{ path: '', redirect: '/admin/overview' },
|
{ path: '', redirect: '/admin/overview' },
|
||||||
{ path: 'overview', component: OverviewView, meta: { label: '概览' } },
|
{ path: 'overview', component: OverviewView, meta: { label: '概览' } },
|
||||||
{ path: 'models', component: ModelsView, 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: 'users', component: UsersView, meta: { label: '用户管理' } },
|
||||||
{ path: 'concurrency', component: ConcurrencyView, meta: { label: '并发分组' } },
|
{ path: 'concurrency', component: ConcurrencyView, meta: { label: '并发分组' } },
|
||||||
{ path: 'banned-words', component: BannedWordsView, meta: { label: '违禁词列表' } },
|
{ path: 'banned-words', component: BannedWordsView, meta: { label: '违禁词列表' } },
|
||||||
@@ -81,7 +81,7 @@ const routes = [
|
|||||||
{ path: '/overview', redirect: '/admin/overview' },
|
{ path: '/overview', redirect: '/admin/overview' },
|
||||||
{ path: '/models', redirect: '/admin/models' },
|
{ path: '/models', redirect: '/admin/models' },
|
||||||
{ path: '/video-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
|
// 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.
|
// old "/images → /admin/images" shortcut is gone. Use /files for that shortcut.
|
||||||
{ path: '/files', redirect: '/admin/images' },
|
{ 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. */
|
own utility so vertical clipping / rounded corners are preserved. */
|
||||||
.card:has(> table) { overflow-x: auto; }
|
.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 { 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 { 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; }
|
::-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);
|
background: rgb(255 255 255 / 0.04);
|
||||||
border-color: rgb(255 255 255 / 0.1);
|
border-color: rgb(255 255 255 / 0.1);
|
||||||
color: rgb(255 255 255 / 0.92);
|
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 {
|
.public-dark .field {
|
||||||
background: rgb(255 255 255 / 0.04);
|
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); }
|
.public-dark .hover\:bg-slate-100:hover { background: rgb(255 255 255 / 0.06); }
|
||||||
|
|
||||||
@layer components {
|
@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 {
|
.card {
|
||||||
@apply rounded-xl;
|
@apply rounded-2xl;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
-webkit-backdrop-filter: blur(14px);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(16px);
|
||||||
border: 1px solid var(--glass-border);
|
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) */
|
/* 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(服务端
|
// 单一真源是后端 /video-presets 的 max_videos / max_audios(渠道导入时从上游
|
||||||
// leonardoVideoSpecs 校验的就是这套数),预设没声明音视频上限的 seedance 系回落
|
// 模型规格带入);预设没声明的回落到模型行自己的 max_reference_videos/audios。
|
||||||
// 到 3 / 3,与画图台 (PlaygroundView) 的判定保持一致。
|
|
||||||
const SEEDANCE_FALLBACK = { videos: 3, audios: 3 }
|
|
||||||
|
|
||||||
function declared(v) {
|
function declared(v) {
|
||||||
return v === undefined || v === null ? null : Number(v)
|
return v === undefined || v === null ? null : Number(v)
|
||||||
}
|
}
|
||||||
@@ -12,13 +9,10 @@ function declared(v) {
|
|||||||
// 参考资产时返回 null。model 兼容 /managed-models(type) 与 /models(kind) 两种字段。
|
// 参考资产时返回 null。model 兼容 /managed-models(type) 与 /models(kind) 两种字段。
|
||||||
export function mediaCaps(model, preset) {
|
export function mediaCaps(model, preset) {
|
||||||
if ((model?.type || model?.kind) !== 'video') return null
|
if ((model?.type || model?.kind) !== 'video') return null
|
||||||
// creativefabrica 上游只收图片参考(VIDEO_FRAME_TYPE_REFERENCE),不收视频/音频;
|
const videos = declared(preset?.max_videos) ?? declared(model?.max_reference_videos) ?? 0
|
||||||
// 它虽然叫 seedance,但要排除,否则会错误地显示「视频 3 音频 3」。
|
const audios = declared(preset?.max_audios) ?? declared(model?.max_reference_audios) ?? 0
|
||||||
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)
|
|
||||||
if (!videos && !audios) return null
|
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 取预设。
|
// 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(() =>
|
const canvasOptions = computed(() =>
|
||||||
canvases.value.map((c) => ({ value: c.id, label: c.title || '未命名画布' })),
|
canvases.value.map((c) => ({ value: c.id, label: c.title || '未命名画布' })),
|
||||||
)
|
)
|
||||||
const maxRefs = computed(() => Math.min(8, Math.max(
|
// 参考文件分类上限:custom 模型直接读模型三个字段;老模型只有
|
||||||
Number(model.value?.max_reference_images || 0), model.value?.image_to_image ? 1 : 0,
|
// 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, () => {
|
watch(() => gen.kind, () => {
|
||||||
const first = kindModels.value[0]
|
const first = kindModels.value[0]
|
||||||
@@ -576,7 +591,7 @@ function onPaste(e) {
|
|||||||
// ---- 参考图上传(与画图台一致:直接传文件,不再用选中节点) ----
|
// ---- 参考图上传(与画图台一致:直接传文件,不再用选中节点) ----
|
||||||
const refImages = ref([]) // { name, dataUrl }
|
const refImages = ref([]) // { name, dataUrl }
|
||||||
const refInput = ref(null)
|
const refInput = ref(null)
|
||||||
const MAX_REF_BYTES = 15 * 1024 * 1024
|
const MAX_REF_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
function fileToDataUrl(f) {
|
function fileToDataUrl(f) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -587,23 +602,37 @@ function fileToDataUrl(f) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
async function addRefFile(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) {
|
if (refImages.value.length >= maxRefs.value) {
|
||||||
genMsg.value = `参考图最多 ${maxRefs.value} 张`
|
genMsg.value = `参考文件最多 ${maxRefs.value} 个`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (f.size > MAX_REF_BYTES) {
|
if (f.size > MAX_REF_BYTES) {
|
||||||
genMsg.value = '单张参考图不能超过 15MB'
|
genMsg.value = '单个参考文件不能超过 20MB'
|
||||||
return
|
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,生成时自动连边标明来源)
|
// 画布图片节点 → 参考图(记录 nodeId,生成时自动连边标明来源)
|
||||||
async function addNodeAsRef(n) {
|
async function addNodeAsRef(n) {
|
||||||
if (!n || n.type !== 'image' || !n.src) return
|
if (!n || n.type !== 'image' || !n.src) return
|
||||||
if (refImages.value.some((r) => r.nodeId === n.id)) return
|
if (refImages.value.some((r) => r.nodeId === n.id)) return
|
||||||
if (refImages.value.length >= maxRefs.value) {
|
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
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -952,21 +981,22 @@ const saveLabel = computed(() => ({
|
|||||||
<div class="cv-composer">
|
<div class="cv-composer">
|
||||||
<div v-if="maxRefs > 0" class="cv-composer__refs">
|
<div v-if="maxRefs > 0" class="cv-composer__refs">
|
||||||
<div v-for="(r, i) in refImages" :key="i" class="cv-refthumb">
|
<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>
|
<button type="button" class="cv-refthumb__x" title="移除" @click="removeRef(i)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="refImages.length < maxRefs"
|
v-if="refImages.length < maxRefs"
|
||||||
type="button"
|
type="button"
|
||||||
class="cv-refadd"
|
class="cv-refadd"
|
||||||
title="上传参考图"
|
:title="supportsMediaRefs ? '上传参考图/视频/音频' : '上传参考图'"
|
||||||
@click="refInput?.click()"
|
@click="refInput?.click()"
|
||||||
>
|
>
|
||||||
<span class="cv-refadd__icon">+</span>
|
<span class="cv-refadd__icon">+</span>
|
||||||
<span>参考图</span>
|
<span>参考图</span>
|
||||||
<span class="cv-refadd__n">{{ refImages.length }}/{{ maxRefs }}</span>
|
<span class="cv-refadd__n">{{ refImages.length }}/{{ maxRefs }}</span>
|
||||||
</button>
|
</button>
|
||||||
<input ref="refInput" type="file" accept="image/*" multiple hidden @change="onRefPicked">
|
<input ref="refInput" type="file" :accept="refFileAccept" multiple hidden @change="onRefPicked">
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="gen.prompt"
|
v-model="gen.prompt"
|
||||||
@@ -1340,6 +1370,13 @@ const saveLabel = computed(() => ({
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
.cv-refthumb__media {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
.cv-refthumb__x {
|
.cv-refthumb__x {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
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>
|
||||||
+106
-251
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// API 对接文档 — OpenAI-compatible. Lists live models and shows ready-to-run
|
// API 对接文档 — Vivid 协议(/v1/generate NDJSON 长连接)。列出在售模型
|
||||||
// curl / Python(openai SDK) examples for image + video, wired to this
|
// 与计费档位,示例代码直接可跑。
|
||||||
// deployment's base URL and the caller's model ids.
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { auth } from '../auth'
|
import { auth } from '../auth'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
@@ -10,7 +9,7 @@ import { points } from '../credits'
|
|||||||
import { mediaCaps, presetMap } from '../videoCaps'
|
import { mediaCaps, presetMap } from '../videoCaps'
|
||||||
|
|
||||||
const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy)
|
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 models = ref([])
|
||||||
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
|
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
|
||||||
@@ -31,8 +30,10 @@ const videoModels = computed(() => models.value.filter((m) => m.type === 'video'
|
|||||||
function pubName(m) {
|
function pubName(m) {
|
||||||
return m?.alias || m?.id || ''
|
return m?.alias || m?.id || ''
|
||||||
}
|
}
|
||||||
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'firefly-image-4')
|
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'gpt-image-2')
|
||||||
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'firefly-kling3')
|
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$/, ''))
|
const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] || '8s').replace(/s$/, ''))
|
||||||
|
|
||||||
function modeEmpty(m) {
|
function modeEmpty(m) {
|
||||||
@@ -61,183 +62,118 @@ function priceEmpty(m) {
|
|||||||
if (perSecondPrice(m) != null) return false
|
if (perSecondPrice(m) != null) return false
|
||||||
return !(m.durations || []).some((d) => durPrice(m, d) != null)
|
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 ----
|
// ---- request parameter tables ----
|
||||||
const imageParams = [
|
const generateParams = [
|
||||||
['model', 'string', '必填', '模型名(别名优先),见上表(图像)'],
|
['model', 'string', '必填', '模型名(别名优先),见上表 /v1/models 的 id'],
|
||||||
['prompt', 'string', '必填', '文字描述'],
|
['prompt', 'string', '必填', '文本描述'],
|
||||||
['size', 'string', '可选', '宽x高,如 "1024x1024"。同时决定「比例」+「分辨率档」(按长边)。具体怎么填见下方对照表;留空 = 1:1 · 2K'],
|
['quality', 'string', '可选', '清晰度档位,取该模型 qualities 之一(图像如 1K/2K/4K,视频如 720p/1080p);不传 = 该模型最低档'],
|
||||||
]
|
['ratio', 'string', '可选', '宽高比,如 "16:9";不传 = 类型默认'],
|
||||||
const editParams = [
|
['duration', 'int64', '可选', '视频时长(秒);range/options 模型必填,图片忽略'],
|
||||||
['image', 'file', '必填', '输入图;多张参考图重复 image[] 字段(multipart 文件上传)'],
|
['refs', 'array<string>', '可选', '参考素材 key 数组:先 POST /v1/uploads 拿预签名直传,PUT 上传后把返回的 key 放这里。30 分钟内有效,张数上限见模型表'],
|
||||||
['prompt', 'string', '必填', '编辑/参考描述'],
|
['jobId', 'string', '可选', '重连模式:流意外断开后只带 jobId 原样重发,不创建新任务、不重复扣费;已完成直接回终态帧'],
|
||||||
['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"=普通参考图。留空用模型默认;仅支持单一模式的模型不可传'],
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// ---- size → 比例 × 分辨率档 对照表(用 size 该传的值)----
|
const uploadParams = [
|
||||||
// size 的长边映射档位:<1800→1K · 1800–3499→2K · ≥3500→4K;宽高比映射比例。
|
['kind', 'string', '必填', '素材大类:image / video / audio(与文件实际类型一致)'],
|
||||||
const sizeTable = [
|
['mime', 'string', '必填', '文件 MIME(如 image/png),须与 kind 自洽;PUT 上传的 Content-Type 必须与它一字不差(已签进 URL)'],
|
||||||
{ 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' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// ---- examples (built in script so refs resolve correctly) ----
|
// ---- examples (built in script so refs resolve correctly) ----
|
||||||
const examples = computed(() => [
|
const examples = computed(() => [
|
||||||
{
|
{
|
||||||
title: '文生图 · curl',
|
title: '文生图 · curl (NDJSON 流)',
|
||||||
code:
|
code:
|
||||||
`curl ${base.value}/v1/images/generations \\
|
`curl -N ${base.value}/v1/generate \\
|
||||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '{
|
-d '{
|
||||||
"model": "${sampleImage.value}",
|
"model": "${sampleImage.value}",
|
||||||
"prompt": "a corgi running in a golden wheat field, cinematic",
|
"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:
|
code:
|
||||||
`import urllib.request
|
`import json, requests
|
||||||
from openai import OpenAI
|
|
||||||
|
|
||||||
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(
|
job_id, final = None, None
|
||||||
model="${sampleImage.value}",
|
for line in r.iter_lines():
|
||||||
prompt="a corgi running in a golden wheat field, cinematic",
|
if not line: # 空行 = 心跳,忽略
|
||||||
size="2048x2048", # 2K · 1:1,见下方对照表
|
continue
|
||||||
)
|
frame = json.loads(line)
|
||||||
# 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存)
|
job_id = frame.get("jobId") or job_id
|
||||||
urllib.request.urlretrieve(resp.data[0].url, "out.png")`,
|
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:
|
code:
|
||||||
`curl ${base.value}/v1/images/edits \\
|
`# 1) 申请上传会话 → 拿 {key, url}
|
||||||
|
resp=$(curl -s ${base.value}/v1/uploads \\
|
||||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||||
-F model="${sampleImage.value}" \\
|
-H "Content-Type: application/json" \\
|
||||||
-F prompt="把这张图改成赛博朋克风格" \\
|
-d '{"kind":"image","mime":"image/png"}')
|
||||||
-F size="2048x2048" \\
|
|
||||||
-F image=@input.png
|
# 2) PUT 直传(Content-Type 必须与申请的 mime 完全一致)
|
||||||
# 多张参考图:重复 -F image=@a.png -F image=@b.png`,
|
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:
|
code:
|
||||||
`import urllib.request
|
`curl -N ${base.value}/v1/generate \\
|
||||||
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 \\
|
|
||||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '{
|
-d '{
|
||||||
"model": "${sampleVideo.value}",
|
"model": "${sampleVideo.value}",
|
||||||
"prompt": "a paper boat sailing down a rainy street, cinematic",
|
"prompt": "a paper boat sailing down a rainy street, cinematic",
|
||||||
"seconds": "${sampleSeconds.value}",
|
"quality": "${sampleVQuality.value}",
|
||||||
"size": "1280x720"
|
"ratio": "16:9",
|
||||||
|
"duration": ${sampleSeconds.value}
|
||||||
}'
|
}'
|
||||||
|
# 与图像同一端点:quality 传分辨率档,duration 传秒数`,
|
||||||
# 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)`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '列出模型 · curl',
|
title: '列出模型 · curl',
|
||||||
code:
|
code:
|
||||||
`curl ${base.value}/v1/models \\
|
`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',
|
title: '查询余额 · curl',
|
||||||
code:
|
code:
|
||||||
`curl ${base.value}/v1/user/balance \\
|
`curl ${base.value}/v1/balance \\
|
||||||
-H "Authorization: Bearer ${keyHint.value}"
|
-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>
|
<header>
|
||||||
<span class="sticker">✦ 开发者</span>
|
<span class="sticker">✦ 开发者</span>
|
||||||
<h1 class="mt-3 text-4xl md:text-5xl font-bold tracking-tight">接口文档</h1>
|
<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>
|
</header>
|
||||||
|
|
||||||
<!-- quickstart -->
|
<!-- quickstart -->
|
||||||
@@ -264,10 +200,10 @@ async function copy(text) {
|
|||||||
<h2 class="text-sm font-semibold text-white/80">基础信息</h2>
|
<h2 class="text-sm font-semibold text-white/80">基础信息</h2>
|
||||||
<dl class="mt-4 space-y-3 text-sm">
|
<dl class="mt-4 space-y-3 text-sm">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<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>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<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>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<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>
|
<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">
|
<div class="card p-6">
|
||||||
<h2 class="text-sm font-semibold text-white/80">端点</h2>
|
<h2 class="text-sm font-semibold text-white/80">端点</h2>
|
||||||
<ul class="mt-4 space-y-2.5 text-sm font-mono">
|
<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-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-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/uploads</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-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-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-get">GET</span><span class="text-white/80">/v1/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/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>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -323,13 +256,11 @@ async function copy(text) {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex flex-wrap items-center gap-1 text-[11px]">
|
<div class="flex flex-wrap items-center gap-1 text-[11px]">
|
||||||
<template v-if="m.type === 'image'">
|
<template v-for="r in (m.resolutions || [])" :key="r">
|
||||||
<span v-for="r in (m.resolutions || [])" :key="r" class="cap cap-k">{{ r }}</span>
|
<span class="cap cap-k">{{ r }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="m.type === 'video'">
|
<template v-if="m.type === 'video'">
|
||||||
<span v-for="r in (m.resolutions || [])" :key="r" class="cap cap-k">{{ r }}</span>
|
<span v-if="durText(m)" class="cap cap-dur">{{ durText(m) }}</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>
|
|
||||||
</template>
|
</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 > 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>
|
<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 -->
|
<!-- parameters -->
|
||||||
<section class="grid lg:grid-cols-2 gap-6">
|
<section class="grid lg:grid-cols-2 gap-6">
|
||||||
<div>
|
<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">
|
<div class="card overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<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]">
|
<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>
|
<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>
|
</tr></thead>
|
||||||
<tbody>
|
<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 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/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/55">{{ p[2] }}</td>
|
||||||
@@ -372,14 +303,14 @@ async function copy(text) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<div class="card overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<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]">
|
<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>
|
<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>
|
</tr></thead>
|
||||||
<tbody>
|
<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 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/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/55">{{ p[2] }}</td>
|
||||||
@@ -388,85 +319,13 @@ async function copy(text) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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> 放进
|
||||||
<div class="lg:col-span-2">
|
<code class="text-white/70">/v1/generate</code> 的 <code class="text-white/70">refs</code>。
|
||||||
<h2 class="text-lg font-semibold mb-3">视频参数 <span class="text-xs font-normal text-white/40">/v1/videos · 异步</span></h2>
|
预签名 URL 仅用于 PUT(不可 GET 读回),30 分钟内有效。
|
||||||
<div class="card overflow-hidden">
|
支持:图 png/jpeg/webp,视频 mp4/webm/mov,音频 mp3/wav/m4a/ogg。
|
||||||
<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>
|
</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>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- examples -->
|
<!-- examples -->
|
||||||
@@ -488,23 +347,19 @@ async function copy(text) {
|
|||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
|
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
|
||||||
<div class="card p-6 space-y-3 text-sm text-white/70">
|
<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">NDJSON 流</strong>(<code class="text-white/85 font-mono">application/x-ndjson</code>):连接建立即回执首行
|
||||||
<p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p>
|
<code class="text-white/85 font-mono">{{ '{"jobId":"…","status":"running"}' }}</code>;
|
||||||
<ol class="list-decimal list-inside space-y-1 text-white/65 pl-1">
|
等待期间每 25 秒一个空行心跳(请忽略);末行为终态
|
||||||
<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>
|
<code class="text-white/85 font-mono">succeeded</code> / <code class="text-white/85 font-mono">failed</code> —— 读最后一行 JSON 即结果。</p>
|
||||||
<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>
|
<p><strong class="text-white/90">断流重连</strong>:流意外断开时,只带 <code class="text-white/85 font-mono">jobId</code> 重发同一请求即可重连续等 —— 不会重复创建任务、不重复扣费;已完成直接回终态帧。</p>
|
||||||
<li>完成后 <code class="text-white/85 font-mono">GET /v1/videos/{id}/content</code> 返回 <strong class="text-white/90">mp4 原始二进制</strong>(非 base64、非 URL)</li>
|
<p><strong class="text-white/90">产物 URL</strong>:<code class="text-white/85 font-mono">result</code> 是只读预签名 URL(30 分钟),过期即焚,请及时下载转存。任务类型即文件类型。</p>
|
||||||
</ol>
|
<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>
|
||||||
<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>
|
|
||||||
<div class="pt-2 grid sm:grid-cols-2 gap-2 text-xs">
|
<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">4001</span> 参数/业务拒绝:模型不可用、quality 不在白名单、refs 超限等</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">4002</span> 积分不足,请先充值</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">4011</span> API Key 无效或未授权</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">4293</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">5001</span> 服务异常,请稍后重试</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ const sourcePill = (s) => ({
|
|||||||
<th class="text-center px-4 py-3 font-medium">预览</th>
|
<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-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>
|
||||||
<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 { api } from '../api'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import ModelFormModal from '../components/ModelFormModal.vue'
|
import ModelFormModal from '../components/ModelFormModal.vue'
|
||||||
import CustomModelModal from '../components/CustomModelModal.vue'
|
|
||||||
import TestModal from '../components/TestModal.vue'
|
import TestModal from '../components/TestModal.vue'
|
||||||
import { points } from '../credits'
|
import { points } from '../credits'
|
||||||
import { mediaCaps, presetMap } from '../videoCaps'
|
import { mediaCaps, presetMap } from '../videoCaps'
|
||||||
@@ -11,7 +10,6 @@ import { mediaCaps, presetMap } from '../videoCaps'
|
|||||||
const models = ref([])
|
const models = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const showCustom = ref(false)
|
|
||||||
const editing = ref(null) // null = add, object = edit
|
const editing = ref(null) // null = add, object = edit
|
||||||
const testing = ref(null) // model being tested, or null
|
const testing = ref(null) // model being tested, or null
|
||||||
|
|
||||||
@@ -41,7 +39,6 @@ async function loadModels() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAdd() { editing.value = null; showForm.value = true }
|
|
||||||
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
|
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
|
||||||
function onSaved() { showForm.value = false; loadModels() }
|
function onSaved() { showForm.value = false; loadModels() }
|
||||||
|
|
||||||
@@ -79,7 +76,7 @@ const filtered = computed(() => {
|
|||||||
if (kindFilter.value && m.type !== kindFilter.value) return false
|
if (kindFilter.value && m.type !== kindFilter.value) return false
|
||||||
if (statusFilter.value === 'enabled' && m.enabled === false) return false
|
if (statusFilter.value === 'enabled' && m.enabled === false) return false
|
||||||
if (statusFilter.value === 'disabled' && 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
|
return true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -132,12 +129,9 @@ onMounted(loadModels)
|
|||||||
<button @click="loadModels" class="btn-soft">
|
<button @click="loadModels" class="btn-soft">
|
||||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||||
</button>
|
</button>
|
||||||
<button @click="openAdd" class="btn-primary">
|
<router-link to="/admin/channels" class="btn-primary inline-flex items-center gap-1">
|
||||||
<Icon name="plus" class="w-3.5 h-3.5" /> 新增模型
|
<Icon name="plug" class="w-3.5 h-3.5" /> 渠道导入模型
|
||||||
</button>
|
</router-link>
|
||||||
<button @click="showCustom = true" class="btn-soft">
|
|
||||||
<Icon name="plus" class="w-3.5 h-3.5" /> 自定义模型
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- 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-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">
|
<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="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>
|
</div>
|
||||||
|
|
||||||
<table v-else class="w-full text-sm table-fixed">
|
<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>
|
<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>
|
<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>
|
||||||
<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>
|
</td>
|
||||||
|
|
||||||
<!-- Type chip with the same shape as Logs/Provider 健康 -->
|
<!-- Type chip with the same shape as Logs/Provider 健康 -->
|
||||||
@@ -303,7 +297,6 @@ onMounted(loadModels)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModelFormModal v-if="showForm" :model="editing" @close="showForm = false" @saved="onSaved" />
|
<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" />
|
<TestModal v-if="testing" :model="testing" @close="testing = null" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -75,12 +75,12 @@ const cdk = computed(() => dash.value?.cdk || {})
|
|||||||
const invites = computed(() => dash.value?.invites || {})
|
const invites = computed(() => dash.value?.invites || {})
|
||||||
const checkin = computed(() => dash.value?.checkin || {})
|
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(() => {
|
const tokens = computed(() => {
|
||||||
let active = 0, total = 0
|
let active = 0, total = 0
|
||||||
for (const p of providers.value) {
|
for (const p of providers.value) {
|
||||||
active += p.tokens_active || 0
|
total += 1
|
||||||
total += (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
|
if (p.enabled) active += 1
|
||||||
}
|
}
|
||||||
return { active, total }
|
return { active, total }
|
||||||
})
|
})
|
||||||
@@ -93,20 +93,19 @@ const modelTypes = computed(() => {
|
|||||||
|
|
||||||
const providerHealth = computed(() =>
|
const providerHealth = computed(() =>
|
||||||
providers.value.map((p) => {
|
providers.value.map((p) => {
|
||||||
const total = (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
|
|
||||||
let status = 'down'
|
let status = 'down'
|
||||||
if ((p.tokens_active || 0) > 0) status = 'healthy'
|
if (p.enabled && (p.model_count || 0) > 0) status = 'healthy'
|
||||||
else if (total > 0) status = 'warning'
|
else if (p.enabled) status = 'warning'
|
||||||
return { ...p, status, total }
|
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 overallHealth = computed(() => {
|
||||||
const list = providerHealth.value
|
const list = providerHealth.value
|
||||||
if (!list.length) return { label: '未配置 Provider', tone: 'down' }
|
if (!list.length) return { label: '未配置渠道', tone: 'down' }
|
||||||
if (list.some((p) => p.status === 'down')) return { label: 'Provider 异常', tone: 'down' }
|
if (list.some((p) => p.status === 'down')) return { label: '渠道停用', tone: 'down' }
|
||||||
if (list.some((p) => p.status === 'warning')) return { label: 'Provider 告警', tone: 'warning' }
|
if (list.some((p) => p.status === 'warning')) return { label: '渠道未绑定模型', tone: 'warning' }
|
||||||
return { label: '系统健康', tone: 'healthy' }
|
return { label: '系统健康', tone: 'healthy' }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -272,13 +271,13 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4">
|
<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">
|
<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-emerald-300">{{ tokens.active }}</span>
|
||||||
<span class="text-white/30">/</span>
|
<span class="text-white/30">/</span>
|
||||||
<span>{{ tokens.total }}</span>
|
<span>{{ tokens.total }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[11px] text-white/45 mt-1">活跃 / 已配置</div>
|
<div class="text-[11px] text-white/45 mt-1">启用 / 已配置</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="text-xs text-white/55">模型</div>
|
<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="grid lg:grid-cols-2 gap-3">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
|
<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"
|
<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)">
|
:class="statusPill(overallHealth.tone)">
|
||||||
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
|
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
|
||||||
@@ -361,16 +360,15 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3">
|
<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-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">
|
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="flex-1 min-w-0">
|
||||||
<div class="text-sm font-medium capitalize truncate">{{ p.name }}</div>
|
<div class="text-sm font-medium truncate">{{ p.name }}</div>
|
||||||
<div class="text-[11px] text-white/45 mt-0.5">
|
<div class="text-[11px] text-white/45 mt-0.5 truncate">
|
||||||
{{ p.model_count }} 模型 · token {{ p.tokens_active }}/{{ p.total || 0 }} 活跃
|
{{ p.model_count }} 模型 · 成功 {{ p.success_total || 0 }}
|
||||||
<span v-if="p.tokens_quota"> · {{ p.tokens_quota }} 限额</span>
|
<span v-if="p.upstream_balance != null"> · 余额 {{ p.upstream_balance }}</span>
|
||||||
<span v-if="p.tokens_disabled"> · {{ p.tokens_disabled }} 停用</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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"
|
<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') {
|
if (mode.value === 'video') {
|
||||||
const a = Number(familyPreset.value?.max_reference_images || 0)
|
const a = Number(familyPreset.value?.max_reference_images || 0)
|
||||||
const b = Number(model.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(Math.max(a, b), 2)
|
||||||
if (refMode.value === 'frame') return Math.min(raw, 2)
|
// preset 模型的 max_reference_images 已是各类资产合计;custom 上游模型
|
||||||
return raw
|
// 按类分别配置(图/视/音),总上限要相加。
|
||||||
|
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,
|
// 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
|
// 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)
|
const refMode = ref(refModeDefault.value)
|
||||||
watch(refModeDefault, (v) => { refMode.value = v })
|
watch(refModeDefault, (v) => { refMode.value = v })
|
||||||
|
|
||||||
// 预设里声明的音/视频参考上限(未声明 = null,走 seedance 默认值)
|
// 预设里声明的音/视频参考上限;custom 上游模型直接读模型字段
|
||||||
|
// (max_reference_videos/audios);两者都没声明 = null → 按模型类型回落
|
||||||
|
// (seedance 默认 3,其它 0)。
|
||||||
const maxVideosRaw = computed(() => {
|
const maxVideosRaw = computed(() => {
|
||||||
const n = familyPreset.value?.max_videos
|
const p = familyPreset.value?.max_videos
|
||||||
return n === undefined || n === null ? null : Number(n)
|
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 maxAudiosRaw = computed(() => {
|
||||||
const n = familyPreset.value?.max_audios
|
const p = familyPreset.value?.max_audios
|
||||||
return n === undefined || n === null ? null : Number(n)
|
if (p !== undefined && p !== null) return Number(p)
|
||||||
|
const m = Number(model.value?.max_reference_audios || 0)
|
||||||
|
return m > 0 ? m : null
|
||||||
})
|
})
|
||||||
// creativefabrica 上游只收图片参考,没有首尾帧/视频/音频,虽叫 seedance 也要排除。
|
// seedance 系兜底 3/3 参考上限(模型字段显式声明的上限优先,见 maxVideosRaw)。
|
||||||
const isSeedanceModel = computed(() =>
|
const isSeedanceModel = computed(() => /^seedance/.test(model.value?.id || ''))
|
||||||
/^seedance/.test(model.value?.id || '') && (model.value?.provider || '') !== 'creativefabrica')
|
|
||||||
// 支持图片以外的参考资产(视频/音频)的模型:seedance 系 + 预设声明了音视频上限的
|
// 支持图片以外的参考资产(视频/音频)的模型:seedance 系 + 预设声明了音视频上限的
|
||||||
const supportsMediaRefs = computed(() =>
|
const supportsMediaRefs = computed(() =>
|
||||||
isSeedanceModel.value || maxVideosRaw.value > 0 || maxAudiosRaw.value > 0)
|
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.
|
// of charging + failing upstream after the upload.
|
||||||
const MAX_REF_BYTES = 20 * 1024 * 1024
|
const MAX_REF_BYTES = 20 * 1024 * 1024
|
||||||
// seedance 参考图模式各类上限(预设可覆盖:Leonardo 私有 seedance 只吃 1 段音频)
|
// seedance 参考图模式各类上限(预设可覆盖:Leonardo 私有 seedance 只吃 1 段音频)
|
||||||
const maxImages = computed(() => Number(familyPreset.value?.max_images) || maxRefs.value)
|
const maxImages = computed(() =>
|
||||||
const maxVideos = computed(() => (maxVideosRaw.value === null ? 3 : maxVideosRaw.value))
|
Number(familyPreset.value?.max_images) ||
|
||||||
const maxAudios = computed(() => (maxAudiosRaw.value === null ? 3 : maxAudiosRaw.value))
|
Number(model.value?.max_reference_images) ||
|
||||||
// Leonardo 私有 seedance:单个视频参考 3-10 秒,总时长须短于 15 秒。
|
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() {
|
function videoRefDurationError() {
|
||||||
if (mode.value !== 'video' || familyPreset.value?.provider !== 'leonardo') return ''
|
if (mode.value !== 'video') return ''
|
||||||
const vids = refImages.value.filter((r) => r.fileType === 'video')
|
const videoLimit = Number(familyPreset.value?.max_video_seconds) || 0
|
||||||
for (const v of vids) {
|
if (videoLimit > 0) {
|
||||||
if (v.duration && (v.duration < 3 || v.duration > 10))
|
const vTotal = refImages.value
|
||||||
return `单个视频参考需 3-10 秒:${v.name} 为 ${v.duration.toFixed(1)} 秒`
|
.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
|
const audioLimit = Number(familyPreset.value?.max_audio_seconds) || 0
|
||||||
if (audioLimit > 0) {
|
if (audioLimit > 0) {
|
||||||
const aTotal = refImages.value
|
const aTotal = refImages.value
|
||||||
@@ -908,7 +927,7 @@ onUnmounted(() => {
|
|||||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">
|
<label class="block text-xs font-medium text-slate-500 mb-1.5">
|
||||||
{{ refMode === 'frame' && mode === 'video' ? '首尾帧' : '参考图' }}
|
{{ refMode === 'frame' && mode === 'video' ? '首尾帧' : '参考图' }}
|
||||||
<span class="text-slate-400 font-normal">
|
<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>
|
||||||
<span v-if="refsRequired" class="text-rose-500">*</span>
|
<span v-if="refsRequired" class="text-rose-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ async function loadKey() {
|
|||||||
onMounted(loadKey)
|
onMounted(loadKey)
|
||||||
|
|
||||||
// Uniform mask for the hidden / persisted state — always "***" so the just-minted
|
// 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) ? '***' : '')
|
const maskedKey = computed(() => (apiKey.value || keyPreview.value) ? '***' : '')
|
||||||
|
|
||||||
async function generateKey() {
|
async function generateKey() {
|
||||||
|
|||||||
Reference in New Issue
Block a user