Compare commits

..
13 Commits
Author SHA1 Message Date
chiyiandDevin b4efe6b192 feat!: 账号池改渠道网关,对外协议换成 vivid /v1
- 新增 channels/channel_models:上游 URL + AES-GCM 加密 key +
  并发/权重/本地统计,模型经渠道 /v1/models 导入绑定
- internal/upstream:vivid 协议客户端(NDJSON 流 + jobId 断流重连 +
  uploads 预签名 + models + balance + 错误分类)
- service/v1 重写:权重分组调度、Redis 并发槽 conc:c:、故障转移,
  站内/测试/对外统一走渠道执行器
- 对外端点换成 vivid 四件套:POST /v1/generate(NDJSON+jobId 重连)、
  POST /v1/uploads(SigV4 预签名 PUT)、GET /v1/models、GET /v1/balance,
  错误包 {"error":{"code","message"}}
- API key 新发改 vk_+48hex;删除全部内置 provider 与 token 池
- 管理端:渠道 CRUD/拉模型/导入/余额端点,前端账号页换渠道页,
  概览/文档/测试弹窗适配

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-16 21:14:18 +08:00
chiyiandDevin ecdbc0b281 feat(models): 视频分辨率档位增加 768p,编辑弹窗支持增删档位
- CustomModelModal:VID_RES/ALL_RES 加入 768p
- ModelFormModal:provider=custom 的模型编辑时渲染 候选档位 ∪ 已存档位
  完整价格网格,填价即开通、留空即下线;PATCH 的 resolutions/durations
  改发"有价键集合"(与新建语义一致),非 custom 模型行为不变
- 摘要面板仍只显示已开通档位,提示文案区分 catalog / custom

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 18:24:32 +08:00
chiyiandDevin b17cc3e9c8 fix(upstream): vivid 上传会话宽容解析 + 错误回显原始响应
vivid 协议各实现的 /v1/uploads 响应形状不一(顶层 / data 包裹、
url|upload_url|presigned_url、key|file_key|object_key)。之前只认顶层
url+key,遇到包装或别名就报 "upload session missing url/key" 且看不
到上游实际返回。现在逐层(含嵌套 data/result/upload/session)搜寻两
个字段,仍缺失时把原始 JSON 截断进错误信息便于定位。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 18:15:47 +08:00
chiyiandDevin effe76d83f style(admin): 管理台全局样式升级——卡片高光、表格统一润色
- .card 圆角 xl→2xl,顶部加内高光(亮色 sheen/暗色 rim-light)
- 卡片内表格:thead 统一紫罗兰渐变底色,tbody 行 hover 统一紫光
  洗色 + 首列内描边,不再依赖各页散落的 hover:bg-white 工具类
- AdminLayout:顶栏 Admin 标签改紫→青渐变胶囊,侧栏 logo 副标
  改渐变文字

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 17:24:47 +08:00
chiyiandDevin e7a4613bac fix(upstream): vivid 协议的 quality 原样透传分辨率档位
vivid 的 quality 就是模型自己的档位字符串——图像 1K/2K/4K、视频
720p/1080p。之前映射成 OpenAI 的 low/medium/high 导致视频上游收到
"1K" 报 4001 "该模型支持 720p"。现在原样透传,空值才默认 1K。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 16:59:13 +08:00
chiyiandDevin 1fee3e5461 feat(canvas): 生成输入框支持视频/音频参考文件
- 参考上限改为模型三类字段合计(图+视+音),不再被 min(8)
  截断;custom 模型的 9图+3视频+0音频 显示为 0/12
- 文件选择器按模型能力放开 accept;按类分别计数和限数
  (超图片/视频/音频各自上限时给出对应提示)
- 非图片参考在缩略位显示 🎬/🔊;单文件上限放宽到 20MB
  与后端一致

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 15:22:42 +08:00
chiyiandDevin 0732dfddef fix(playground): 图片参考上限回落用模型字段而非总数
maxImages 在没有 preset 时回落到 maxRefs(图+视+音合计),custom
模型显示成了总数。改成 preset → model.max_reference_images → maxRefs。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 15:16:43 +08:00
chiyiandDevin ab8ddc8354 fix(playground): custom 上游的 seedance* 模型不再吃 seedance 默认上限
isSeedanceModel 的正则把 custom provider 的自定义命名也算成了 leonardo
seedance,导致音频上限兜底成 3、总张数被重复相加。provider=custom
也排除出 seedance 判定(videoCaps 同步)。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 15:14:58 +08:00
chiyiandDevin e69455fbfc fix(playground): 参考文件徽标读取 custom 模型的分类上限
- max_reference_videos/audios 回落链改成 preset → 模型字段 → null;
  null 兜底只对 seedance 系默认 3/3,custom 模型未声明=0
- custom 模型的总参考上限 = 图+视+音相加(它的
  max_reference_images 只是图片类),不再错算成单类
- 徽标只显示上限 >0 的类别

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 15:12:10 +08:00
chiyiandDevin 1be1b00511 feat(upstream): custom 模型按媒体分类配置参考文件上限
- ModelConfig 新增 max_reference_videos / max_reference_audios
  (配合既有 max_reference_images);admin 读写接口透传
- custom 模型表单去掉「参考模式」下拉,改为参考图/参考视频/
  参考音频三个数量输入;合计张数走 totalRefLimit 校验
- vivid 上游 /v1/uploads 按文件魔数自动选 kind(image/video/
  audio)与 mime;openai 协议上游遇视频/音频参考自动跳过换号
- videoCaps 让 custom 模型的分类上限在画图台/文档徽标生效
- reference_mode 请求级 override 放行 "media"

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 14:58:34 +08:00
chiyiandDevin babfc71f13 feat(upstream): 自定义上游支持 Vivid 协议(/v1/generate NDJSON)
- custom 上游新增 protocol 选项(openai|vivid);vivid 走
  POST /v1/generate NDJSON 流:跳过心跳空行,取末行终态,
  断流携 jobId 重连不重复扣费;参考图先 POST /v1/uploads
  拿预签名再 PUT 直传;错误码 4011/4002/4293/5001/4001 分类
- 添加上游弹窗加协议二选一;账号 meta 存 protocol 并可回显
- 附 vivid 链路单测(成功流/URL 透传/失败帧/错误码/两步上传)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 12:18:26 +08:00
chiyiandDevin ae49b95e0f fix(adobe): 优先使用 cookie 里的 sherlockToken 作 arp,补齐会话一致性
- ExchangeCookie 提取 cookie 中 sherlockToken/forter/arkose/bfp/sid 重组
  arp 并按 token 缓存;无 ims_sid 的游客 token 直接拒绝,不再入池
- getARPSessionID 复用缓存 arp 并刷新 ftr 时间戳;sid/devHex/bfp 按
  token 保持稳定,不再每次重随机
- 新增 ARPFromCookie/refreshARPTimestamp 及形状单测

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 12:18:05 +08:00
chiyi 33b39006c0 chore(gitignore): 忽略本地调试/探针敏感文件(_tmp_*,_arp_*,live probe) 2026-10-14 12:17:50 +08:00
77 changed files with 3808 additions and 22995 deletions
+5
View File
@@ -64,3 +64,8 @@ Thumbs.db
# fast-deploy build artifact # fast-deploy build artifact
/frontend/dist.tar.gz /frontend/dist.tar.gz
# ad-hoc probe/test scratch files (cookies, tokens, live probes)
_tmp_*
_arp_*.txt
zz_live_probe_test.go
-45
View File
@@ -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)
}
+38 -86
View File
@@ -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)
@@ -150,30 +129,30 @@ func NewApp(ctx context.Context) (*App, error) {
canvasSvc := service.NewCanvasService(canvasRepo) canvasSvc := service.NewCanvasService(canvasRepo)
engine := router.New(cfg, authSvc, router.Handlers{ engine := router.New(cfg, authSvc, router.Handlers{
Health: handler.NewHealthHandler(), Health: handler.NewHealthHandler(),
Images: handler.NewImageHandler(cfg, imageAccessSvc, rustfsClient), Images: handler.NewImageHandler(cfg, imageAccessSvc, rustfsClient),
V1: handler.NewV1Handler(v1Svc), V1: handler.NewV1Handler(v1Svc),
Site: handler.NewSiteHandler(siteSvc), Site: handler.NewSiteHandler(siteSvc),
Showcase: handler.NewShowcaseHandler(showcaseSvc), Showcase: handler.NewShowcaseHandler(showcaseSvc),
Auth: handler.NewAuthHandler(cfg, authSvc, rateLimitSvc), Auth: handler.NewAuthHandler(cfg, authSvc, rateLimitSvc),
SiteSettings: handler.NewSiteSettingsHandler(siteSvc), SiteSettings: handler.NewSiteSettingsHandler(siteSvc),
AppSettings: handler.NewAppSettingsHandler(appSettingsSvc), AppSettings: handler.NewAppSettingsHandler(appSettingsSvc),
AdminRead: handler.NewAdminReadHandler(adminReadSvc), AdminRead: handler.NewAdminReadHandler(adminReadSvc),
AdminWrite: handler.NewAdminWriteHandler(adminWriteSvc), AdminWrite: handler.NewAdminWriteHandler(adminWriteSvc),
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),
BannedWords: handler.NewBannedWordsHandler(bannedWordRepo), BannedWords: handler.NewBannedWordsHandler(bannedWordRepo),
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()
+4
View File
@@ -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,534 +0,0 @@
package handler
import (
"errors"
"net/http"
"strings"
"backend/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ProviderAdminHandler struct {
tokens *service.TokenService
refresh *service.RefreshProfileService
}
func NewProviderAdminHandler(tokens *service.TokenService, refresh *service.RefreshProfileService) *ProviderAdminHandler {
return &ProviderAdminHandler{
tokens: tokens,
refresh: refresh,
}
}
func (h *ProviderAdminHandler) TokensList(c *gin.Context) {
data, err := h.tokens.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load tokens"})
return
}
c.JSON(http.StatusOK, gin.H{"data": data})
}
func (h *ProviderAdminHandler) TokensCreate(c *gin.Context) {
var body struct {
Pool string `json:"pool"`
Value string `json:"value"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
item, err := h.tokens.Add(c.Request.Context(), body.Pool, body.Value, body.ID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID})
}
func (h *ProviderAdminHandler) ImportChatGPTToken(c *gin.Context) {
var body struct {
AccessToken string `json:"access_token"`
Value string `json:"value"`
Name string `json:"name"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
token := body.AccessToken
if token == "" {
token = body.Value
}
name := body.Name
if name == "" {
name = body.ID
}
item, err := h.tokens.ImportChatGPTToken(c.Request.Context(), token, name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
}
func (h *ProviderAdminHandler) ImportRunwayToken(c *gin.Context) {
var body struct {
AccessToken string `json:"access_token"`
Value string `json:"value"`
Name string `json:"name"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
token := body.AccessToken
if token == "" {
token = body.Value
}
name := body.Name
if name == "" {
name = body.ID
}
item, err := h.tokens.ImportRunwayToken(c.Request.Context(), token, name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
}
func (h *ProviderAdminHandler) ImportGrokToken(c *gin.Context) {
var body struct {
AccessToken string `json:"access_token"`
Value string `json:"value"`
SSO string `json:"sso"`
Name string `json:"name"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
token := body.AccessToken
for _, v := range []string{body.Value, body.SSO} {
if token == "" {
token = v
}
}
name := body.Name
if name == "" {
name = body.ID
}
item, err := h.tokens.ImportGrokToken(c.Request.Context(), token, name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
}
func (h *ProviderAdminHandler) ImportCustomAccount(c *gin.Context) {
var body struct {
BaseURL string `json:"base_url"`
URL string `json:"url"`
Key string `json:"key"`
APIKey string `json:"api_key"`
Models string `json:"models"`
Name string `json:"name"`
Weight int `json:"weight"`
Concurrency int `json:"concurrency"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
baseURL := body.BaseURL
if baseURL == "" {
baseURL = body.URL
}
key := body.Key
if key == "" {
key = body.APIKey
}
item, err := h.tokens.ImportCustomAccount(c.Request.Context(), baseURL, key, body.Models, body.Name, body.Weight, body.Concurrency, body.ID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status})
}
func (h *ProviderAdminHandler) ImportKreaCookie(c *gin.Context) {
var body struct {
Cookie string `json:"cookie"`
Value string `json:"value"`
Name string `json:"name"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
cookie := body.Cookie
if cookie == "" {
cookie = body.Value
}
name := body.Name
if name == "" {
name = body.ID
}
item, err := h.tokens.ImportKreaCookie(c.Request.Context(), cookie, name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
}
func (h *ProviderAdminHandler) ImportImagineToken(c *gin.Context) {
var body struct {
Cookie string `json:"cookie"`
Value string `json:"value"`
Name string `json:"name"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
cred := body.Cookie
if cred == "" {
cred = body.Value
}
name := body.Name
if name == "" {
name = body.ID
}
item, err := h.tokens.ImportImagineToken(c.Request.Context(), cred, name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
}
func (h *ProviderAdminHandler) ImportLeonardoCookie(c *gin.Context) {
var body struct {
Cookie string `json:"cookie"`
Value string `json:"value"`
Name string `json:"name"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
cookie := body.Cookie
if cookie == "" {
cookie = body.Value
}
name := body.Name
if name == "" {
name = body.ID
}
item, err := h.tokens.ImportLeonardoCookie(c.Request.Context(), cookie, name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
}
func (h *ProviderAdminHandler) ImportAdobeCookie(c *gin.Context) {
var body struct {
Cookie string `json:"cookie"`
Value string `json:"value"`
Name string `json:"name"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
cookie := body.Cookie
if cookie == "" {
cookie = body.Value
}
name := body.Name
if name == "" {
name = body.ID
}
item, profile, err := h.tokens.ImportAdobeCookie(c.Request.Context(), cookie, name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"profile_id": profile.ID,
"id": item.ID,
"status": item.Status,
"pending": item.Status == "pending",
})
}
func (h *ProviderAdminHandler) ImportCreativeFabricaCookie(c *gin.Context) {
var body struct {
Cookie string `json:"cookie"`
Value string `json:"value"`
Name string `json:"name"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
cookie := body.Cookie
if cookie == "" {
cookie = body.Value
}
name := body.Name
if name == "" {
name = body.ID
}
item, err := h.tokens.ImportCreativeFabricaCookie(c.Request.Context(), cookie, name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"id": item.ID,
"status": item.Status,
"pending": item.Status == "pending",
})
}
func (h *ProviderAdminHandler) TokenUpdate(c *gin.Context) {
var body map[string]any
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
item, err := h.tokens.Update(c.Request.Context(), c.Param("pool"), c.Param("id"), body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
}
func (h *ProviderAdminHandler) TokenDelete(c *gin.Context) {
if err := h.tokens.Delete(c.Request.Context(), c.Param("pool"), c.Param("id")); err != nil {
if errors.Is(err, service.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"detail": "token not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// TokenDeleteBulk removes multiple accounts in one call (account multi-select).
func (h *ProviderAdminHandler) TokenDeleteBulk(c *gin.Context) {
var body struct {
IDs []string `json:"ids"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
if len(body.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "未选择任何账号"})
return
}
n, err := h.tokens.DeleteBulk(c.Request.Context(), body.IDs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": n})
}
func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
data, err := h.tokens.Accounts(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load accounts"})
return
}
// KPI stats are computed over the FULL set (每个类型的 成功/失败/限额), independent
// of the current filter/page — mirrors the old client-side `stats` computed.
stats := accountsStats(data)
// Server-side filtering (类型 / 状态 / 搜索 邮箱·ID·类型 / dead) so pagination is
// correct across pages. ?dead=1 returns only 异常(已失效) accounts — used by
// 「删除异常账号」to collect every dead id regardless of the current page.
typeFilter := strings.TrimSpace(c.Query("type"))
statusFilter := strings.TrimSpace(c.Query("status"))
planFilter := strings.TrimSpace(c.Query("plan"))
deadOnly := c.Query("dead") == "1"
q := strings.ToLower(strings.TrimSpace(c.Query("q")))
filtered := make([]map[string]any, 0, len(data))
for _, row := range data {
if deadOnly && !rowBool(row, "dead") {
continue
}
if typeFilter != "" && rowStr(row, "type") != typeFilter {
continue
}
if statusFilter != "" && rowStr(row, "status") != statusFilter {
continue
}
// plan filter: vip=非free, free=free, sub=子号, master=vip且非子号
if planFilter != "" {
plan := strings.ToLower(rowStr(row, "plan"))
isSub := rowBool(row, "sub_account")
isFree := plan == "" || plan == "free"
switch planFilter {
case "vip":
if isFree { continue }
case "free":
if !isFree { continue }
case "sub":
if !isSub { continue }
case "master":
if isFree || isSub { continue }
}
}
if q != "" {
email := strings.ToLower(rowStr(row, "email"))
id := strings.ToLower(rowStr(row, "id"))
typ := strings.ToLower(rowStr(row, "type"))
if !strings.Contains(email, q) && !strings.Contains(id, q) && !strings.Contains(typ, q) {
continue
}
}
filtered = append(filtered, row)
}
total := len(filtered)
limit, offset := pageParams(c, 20)
page := pageSlice(filtered, limit, offset)
c.JSON(http.StatusOK, gin.H{"data": page, "total": total, "limit": limit, "offset": offset, "stats": stats})
}
// accountsStats reproduces the 账号 KPI strip: per-type 正常/失效/限额 counts plus a
// grand total and total dead count (drives 「删除异常账号 (N)」).
func accountsStats(rows []map[string]any) gin.H {
types := []string{"openai", "adobe", "runway", "leonardo", "krea", "imagine", "grok", "creativefabrica"}
by := map[string]*struct{ N, Ok, Dead, Quota int }{}
for _, t := range types {
by[t] = &struct{ N, Ok, Dead, Quota int }{}
}
deadTotal := 0
for _, row := range rows {
dead := rowBool(row, "dead")
if dead {
deadTotal++
}
g, ok := by[rowStr(row, "type")]
if !ok {
continue
}
g.N++
status := rowStr(row, "status")
switch {
case status == "active":
g.Ok++
case dead || status == "disabled":
g.Dead++
case status == "quota":
g.Quota++
}
}
out := gin.H{"total": len(rows), "dead_total": deadTotal}
for _, t := range types {
g := by[t]
out[t] = gin.H{"n": g.N, "ok": g.Ok, "dead": g.Dead, "quota": g.Quota}
}
return out
}
func rowStr(m map[string]any, key string) string {
s, _ := m[key].(string)
return s
}
func rowBool(m map[string]any, key string) bool {
b, _ := m[key].(bool)
return b
}
func (h *ProviderAdminHandler) AccountQuota(c *gin.Context) {
data, err := h.tokens.Quota(c.Request.Context(), c.Param("pool"), c.Param("id"))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, data)
}
func (h *ProviderAdminHandler) AccountEmail(c *gin.Context) {
data, err := h.tokens.Email(c.Request.Context(), c.Param("pool"), c.Param("id"))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, data)
}
func (h *ProviderAdminHandler) RefreshProfiles(c *gin.Context) {
items, err := h.refresh.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load refresh profiles"})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *ProviderAdminHandler) RefreshNow(c *gin.Context) {
if err := h.refresh.RefreshNow(c.Request.Context(), c.Param("profile_id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *ProviderAdminHandler) RefreshUpdate(c *gin.Context) {
var body map[string]any
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
item, err := h.refresh.Update(c.Request.Context(), c.Param("profile_id"), body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
}
func (h *ProviderAdminHandler) RefreshDelete(c *gin.Context) {
if err := h.refresh.Delete(c.Request.Context(), c.Param("profile_id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+91 -682
View File
@@ -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})
} }
// modelPreset projects one model_configs row into the preset shape the studio
// (画图台) / model pickers consume. Database-driven now: providers are gone and
// every model arrives via a channel import.
func modelPreset(m model.ModelConfig) gin.H {
durations := repo.JSONStrings(m.Durations)
if len(durations) == 0 && m.DurationMin > 0 && m.DurationMax >= m.DurationMin {
// Range spec → expand integer seconds (capped so absurd ranges can't
// flood a dropdown).
for d := m.DurationMin; d <= m.DurationMax && len(durations) < 61; d++ {
durations = append(durations, fmt.Sprintf("%ds", d))
}
}
_, perSecond := m.DurationPrices["per_second"]
return gin.H{
"key": m.EffectiveName(),
"id": m.ID,
"label": m.EffectiveName(),
"type": m.Type,
"provider": m.Provider,
"ratios": repo.JSONStrings(m.Ratios),
"resolutions": repo.JSONStrings(m.Resolutions),
"durations": durations,
"duration_min": m.DurationMin,
"duration_max": m.DurationMax,
"per_second": perSecond,
"max_reference_images": m.MaxReferenceImages,
"max_images": m.MaxReferenceImages,
"max_videos": m.MaxReferenceVideos,
"max_audios": m.MaxReferenceAudios,
"max_reference_videos": m.MaxReferenceVideos,
"max_reference_audios": m.MaxReferenceAudios,
"max_video_seconds": m.MaxRefVideoSeconds,
"max_audio_seconds": m.MaxRefAudioSeconds,
"reference_mode": m.ReferenceMode,
"image_to_image": m.ImageToImage,
"description": m.Name,
"added": true,
}
}
// catalogEntries lists the managed models (previously a static picklist of
// offerable provider models). With channel imports the DB IS the catalog —
// rows are already "added", so the admin picklist degrades gracefully.
func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) { func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) {
items := []gin.H{ models, err := h.admin.Models(c.Request.Context())
{ if err != nil {
"id": "gpt-image-2", return nil, err
"provider": "chatgpt",
"type": "image",
// ChatGPT web backend only reliably produces 1K and honors a limited
// ratio set; size params are advisory prompt hints. Mirrors the Python
// reference (providers/chatgpt/provider.py) — do not offer 2K/4K.
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
"resolutions": []string{"1K"},
"image_to_image": true,
"max_reference_images": 6,
"description": "ChatGPT image generation",
},
{
"id": "firefly-gpt-image-2",
"provider": "adobe",
"type": "image",
"ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"},
"resolutions": []string{"1K", "2K", "4K"},
"image_to_image": true,
"max_reference_images": 6,
"description": "Adobe Firefly GPT Image",
},
{
"id": "firefly-image-5",
"provider": "adobe",
"type": "image",
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
"resolutions": []string{"1K", "2K"},
"image_to_image": true,
"description": "Adobe Firefly Image 5",
},
{
"id": "runway-nano-banana-2",
"provider": "runway",
"type": "image",
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
"resolutions": []string{"1K", "2K", "4K"},
"image_to_image": true,
"max_reference_images": 6,
"reference_mode": "asset",
"description": "Runway Nano Banana 2 (图/参考图)",
},
{
"id": "runway-nano-banana-pro",
"provider": "runway",
"type": "image",
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
"resolutions": []string{"1K", "2K", "4K"},
"image_to_image": true,
"max_reference_images": 6,
"reference_mode": "asset",
"description": "Runway Nano Banana Pro (图/参考图)",
},
{
"id": "nano-banana-2",
"provider": "adobe",
"type": "image",
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
"resolutions": []string{"1K", "2K", "4K"},
"image_to_image": true,
"max_reference_images": 6,
"reference_mode": "asset",
"description": "Nano Banana 2 (图/参考图)",
},
{
"id": "nano-banana-pro",
"provider": "adobe",
"type": "image",
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
"resolutions": []string{"1K", "2K", "4K"},
"image_to_image": true,
"max_reference_images": 6,
"reference_mode": "asset",
"description": "Nano Banana Pro (图/参考图)",
},
{
"id": "gemini-veo3.1-fast",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"4s", "6s", "8s"},
"max_reference_images": 3,
"reference_mode": "frame",
"description": "Veo 3.1 Fast",
},
{
"id": "gemini-veo3.1",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"4s", "6s", "8s"},
"max_reference_images": 3,
"reference_mode": "style",
"description": "Veo 3.1",
},
{
"id": "gemini-veo3.1",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"5s", "10s"},
"max_reference_images": 2,
"reference_mode": "frame",
"description": "Luma Ray video",
},
{
"id": "firefly-video",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "1:1", "9:16"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"5s"},
"max_reference_images": 2,
"reference_mode": "frame",
"description": "Adobe Firefly Video",
},
{
"id": "adobe-seedance-2.0-fast",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"per_second": true,
"max_reference_images": 9,
"reference_mode": "style",
"description": "Seedance 2.0 Fast",
},
{
"id": "adobe-seedance-2.0",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"per_second": true,
"max_reference_images": 9,
"reference_mode": "style",
"description": "Seedance 2.0",
},
{
"id": "seedance-2.0-fast",
"provider": "creativefabrica",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
// 一次性账号:积分刚好够一次生成,时长固定 14 秒。
"durations": []string{"14s"},
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
"max_reference_images": 9,
"reference_mode": "asset",
"description": "Seedance 2.0 Fast (Creative Fabrica)",
},
{
"id": "seedance-2.0",
"provider": "creativefabrica",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
// 一次性账号:积分刚好够一次生成,时长固定 10 秒。
"durations": []string{"10s"},
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
"max_reference_images": 9,
"reference_mode": "asset",
"description": "Seedance 2.0 (Creative Fabrica)",
},
{
"id": "runway-gen4-turbo",
"provider": "runway",
"type": "video",
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
"resolutions": []string{"720p"},
"durations": []string{"5s", "10s"},
"max_reference_images": 1,
"reference_mode": "frame",
"description": "Runway Gen-4 Turbo video (图生视频)",
},
{
"id": "grok-video",
"provider": "grok",
"type": "video",
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
"resolutions": []string{"720p"},
// Console 视频吃 1–15 秒的整数时长,所以按秒计价(同 seedance);
// 参考图只有 1 张首帧(上游只有 image 字段,没有尾帧)。
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"per_second": true,
"max_reference_images": 1,
"reference_mode": "frame",
"description": "Grok Imagine video (文/图生视频)",
},
{
"id": "grok-image",
"provider": "grok",
"type": "image",
"ratios": []string{"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"},
"resolutions": []string{"1K", "2K"},
"image_to_image": true,
"max_reference_images": 3,
"reference_mode": "asset",
"description": "Grok Imagine image (文生图 / 图生图)",
},
{
"id": "seedream-4.5",
"provider": "leonardo",
"type": "image",
"ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"},
"resolutions": []string{"2K", "4K"},
"image_to_image": true,
"max_reference_images": 6,
"description": "Leonardo Seedream 4.5 (生图 / 图生图)",
},
{
"id": "seedance-2.0-不卡人脸",
"provider": "leonardo",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"per_second": true,
// 参考资产总上限 8 = 4 图 + 1 音频 + 3 视频(分类上限在服务端校验)。
"max_reference_images": 8,
"reference_mode": "style",
"description": "Leonardo Seedance 2.0 (私有生成 / 图音视频参考)",
},
{
"id": "seedance-2.0-fast-不卡人脸",
"provider": "leonardo",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"per_second": true,
"max_reference_images": 8,
"reference_mode": "style",
"description": "Leonardo Seedance 2.0 Fast (私有生成 / 图音视频参考)",
},
{
"id": "minimax-h3",
"provider": "leonardo",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"1440p"},
// 上游吃 5–15 秒整数时长,所以按秒计价(后台只填一个 /s 价)。
"durations": []string{"5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"per_second": true,
// 参考资产总上限 8 = 5 图 + 3 音频(分类上限在服务端校验)。
"max_reference_images": 8,
"reference_mode": "style",
"description": "Leonardo MiniMax H3 / hailuo-03 (1440p / 图音参考)",
},
{
"id": "flux-klein-2",
"provider": "krea",
"type": "image",
"ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"},
"resolutions": []string{"1K", "2K"},
"image_to_image": true,
"max_reference_images": 4,
"description": "Krea Flux Klein (生图 / 图生图)",
},
{
"id": "imagine-1.5",
"provider": "imagine",
"type": "image",
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
"resolutions": []string{"2K"},
"max_reference_images": 0,
"description": "Imagine 1.5 (文生图)",
},
{
"id": "imagine-1.5pro",
"provider": "imagine",
"type": "image",
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
"resolutions": []string{"4K"},
"max_reference_images": 0,
"description": "Imagine 1.5 Pro (文生图)",
},
} }
existing := map[string]bool{} items := make([]gin.H, 0, len(models))
if h.admin != nil { for _, m := range models {
models, err := h.admin.Models(c.Request.Context()) row := modelPreset(m)
if err != nil { row["id"] = m.EffectiveName()
return nil, err items = append(items, row)
}
for _, item := range models {
existing[item.ID] = true
}
}
for i := range items {
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
} }
+174 -332
View File
@@ -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
} }
_ = principal return principal, true
}
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"` if err != nil {
Seconds json.RawMessage `json:"seconds"` v1Fail(c, err)
Size string `json:"size"` return
ReferenceMode string `json:"reference_mode"` }
InputReference []string `json:"input_reference"`
ReferenceImages []string `json:"reference_images"` wait := 300 * time.Second
if h := strings.TrimSpace(c.GetHeader("X-Wait-Seconds")); h != "" {
if sec, serr := strconv.Atoi(h); serr == nil && sec > 0 && time.Duration(sec)*time.Second <= v1MaxWait {
wait = time.Duration(sec) * time.Second
} }
if err := c.ShouldBindJSON(&body); err != nil { }
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return c.Writer.Header().Set("Content-Type", "application/x-ndjson")
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Writer.WriteHeader(http.StatusOK)
flusher, _ := c.Writer.(http.Flusher)
writeFrame := func(v *model.EventLog) bool {
frame, _ := json.Marshal(v1JobBody(h.v1, v))
if _, werr := c.Writer.Write(append(frame, '\n')); werr != nil {
return false
} }
modelID, prompt, size = body.Model, body.Prompt, body.Size if flusher != nil {
seconds = rawToString(body.Seconds) flusher.Flush()
referenceMode = strings.TrimSpace(body.ReferenceMode) }
refs = append(body.InputReference, body.ReferenceImages...) return true
} }
duration := strings.TrimSpace(seconds)
if duration != "" && !strings.HasSuffix(duration, "s") {
duration += "s"
}
aspect, resolution := videoSizeToInternal(size)
resp, err := h.v1.StartVideoJob(c.Request.Context(), principal, service.V1VideoRequest{
Model: modelID,
Prompt: prompt,
Duration: duration,
AspectRatio: aspect,
Resolution: resolution,
ReferenceImages: refs,
ReferenceMode: referenceMode,
BaseURL: requestBaseURL(c),
})
if err != nil {
h.writeV1Error(c, err, nil)
return
}
c.JSON(http.StatusOK, resp)
}
// GetVideo — OpenAI GET /v1/videos/{id}. Returns the job status object. // 已终态:直接回终态帧关流。
func (h *V1Handler) GetVideo(c *gin.Context) { if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization")) writeFrame(ev)
if err != nil {
h.writeAuthError(c, err)
return return
} }
resp, err := h.v1.VideoJob(c.Request.Context(), principal, c.Param("id")) // 连上即回执 jobId(断流后携它重发的凭据)。
if err != nil { if !writeFrame(ev) {
h.writeV1Error(c, err, nil)
return return
} }
c.JSON(http.StatusOK, resp)
}
// GetVideoContent — OpenAI GET /v1/videos/{id}/content. Streams the rendered mp4 deadline := time.Now().Add(wait)
// by proxying the stored upstream URL (downloaded on demand, never persisted). poll := time.NewTicker(v1PollInterval)
func (h *V1Handler) GetVideoContent(c *gin.Context) { heartbeat := time.NewTicker(25 * time.Second)
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization")) defer poll.Stop()
if err != nil { defer heartbeat.Stop()
h.writeAuthError(c, err) for {
return select {
} case <-c.Request.Context().Done():
body, contentType, err := h.v1.OpenVideoContent(c.Request.Context(), principal, c.Param("id")) return // client gone — the job keeps running; caller may reconnect with jobId
if err != nil { case <-poll.C:
h.writeV1Error(c, err, nil) latest, lerr := h.v1.JobForUser(c.Request.Context(), principal, ev.ID)
return if lerr != nil || latest == nil {
}
defer body.Close()
c.Header("Content-Type", contentType)
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, body)
}
// GetImageContent — GET /v1/images/{id}/content. Streams a no-store image by
// proxying its stored (possibly auth-gated) upstream URL. Never persisted.
func (h *V1Handler) GetImageContent(c *gin.Context) {
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
if err != nil {
h.writeAuthError(c, err)
return
}
body, contentType, err := h.v1.OpenImageContent(c.Request.Context(), principal, c.Param("id"))
if err != nil {
h.writeV1Error(c, err, nil)
return
}
defer body.Close()
c.Header("Content-Type", contentType)
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, body)
}
// readMultipartImages reads the given file fields and returns each as base64.
func readMultipartImages(c *gin.Context, keys ...string) []string {
var out []string
form := c.Request.MultipartForm
if form == nil {
return out
}
for _, key := range keys {
for _, fh := range form.File[key] {
f, e := fh.Open()
if e != nil {
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()
}
case <-time.After(time.Until(deadline)):
writeFrame(ev) // snapshot; client reconnects with jobId
return
} }
} }
return out
} }
// rawToString accepts OpenAI's `seconds` whether sent as a JSON string or number. // v1JobBody assembles one NDJSON frame (running receipt and terminal frame
func rawToString(raw json.RawMessage) string { // share the shape; succeeded adds result+creditsCharged, failed adds error).
if len(raw) == 0 { func v1JobBody(v1 *service.V1Service, ev *model.EventLog) map[string]any {
return "" body := map[string]any{"jobId": ev.ID, "status": service.JobStatus(ev)}
switch service.JobStatus(ev) {
case "succeeded":
body["result"] = v1.JobResultURL(ev)
body["creditsCharged"] = ev.Cost
case "failed":
body["error"] = ev.Error
body["refunded"] = true
} }
var s string return body
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 // ---- POST /v1/uploads ----
// 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 func (h *V1Handler) Uploads(c *gin.Context) {
// rejects models that only offer 1440p. principal, ok := h.auth(c)
func videoSizeToInternal(size string) (ratio, resolution string) { if !ok {
var w, h int return
if s := strings.TrimSpace(strings.ToLower(size)); s != "" {
_, _ = fmt.Sscanf(s, "%dx%d", &w, &h)
} }
if w == 0 || h == 0 { var body struct {
return "16:9", "" Kind string `json:"kind"`
Mime string `json:"mime"`
} }
// The "p" resolution is the SHORT edge (720p = 1280×720, 1080p = 1920×1080), if err := c.ShouldBindJSON(&body); err != nil {
// so a standard 1280×720 must read as 720p — not 1080p off the long edge. v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
resolution = "720p" return
switch {
case min(w, h) >= 1440:
resolution = "1440p"
case min(w, h) >= 1080:
resolution = "1080p"
} }
return guessRatioWH(w, h), resolution view, err := h.v1.PresignRefUpload(c.Request.Context(), principal, body.Kind, body.Mime)
if err != nil {
v1Fail(c, err)
return
}
c.JSON(http.StatusOK, view)
} }
func guessRatioWH(w, h int) string { // ---- GET /v1/models ----
if w == h {
return "1:1" func (h *V1Handler) Models(c *gin.Context) {
if _, ok := h.auth(c); !ok {
return
} }
r := float64(w) / float64(h) items, err := h.v1.SellableModels(c.Request.Context())
cands := []struct { if err != nil {
name string v1Err(c, http.StatusInternalServerError, "5001", "模型加载失败")
v float64 return
}{{"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 c.JSON(http.StatusOK, items)
} }
// openaiImageResponse strips our rich internal map down to OpenAI's image shape. // ---- GET /v1/balance ----
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) { func (h *V1Handler) UserBalance(c *gin.Context) {
switch { principal, ok := h.auth(c)
case errors.Is(err, service.ErrMissingAPIKey): if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()}) return
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"})
} }
} bal, err := h.v1.UserBalance(c.Request.Context(), principal)
if err != nil {
func (h *V1Handler) writeV1Error(c *gin.Context, err error, payload map[string]any) { v1Err(c, http.StatusInternalServerError, "5001", "余额查询失败")
switch { return
case errors.Is(err, service.ErrUnknownModel):
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt):
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrInsufficientFunds):
c.JSON(http.StatusPaymentRequired, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrReferenceTooLarge):
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrNoProviderAccount):
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderAuth):
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderQuota):
// Match the Python contract: provider quota exhaustion maps to 401
// (QuotaExhaustedError is handled alongside AuthError in routes.py).
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderTemporary):
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrVideoJobNotFound):
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrVideoNotReady):
c.JSON(http.StatusConflict, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderUnsupported):
c.JSON(http.StatusNotImplemented, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderExecution):
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrGenerationPending):
c.JSON(http.StatusNotImplemented, payload)
default:
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
} }
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 == "" {
+29 -50
View File
@@ -11,25 +11,25 @@ import (
) )
type Handlers struct { type Handlers struct {
Health *handler.HealthHandler Health *handler.HealthHandler
Images *handler.ImageHandler Images *handler.ImageHandler
V1 *handler.V1Handler V1 *handler.V1Handler
Site *handler.SiteHandler Site *handler.SiteHandler
Showcase *handler.ShowcaseHandler Showcase *handler.ShowcaseHandler
Auth *handler.AuthHandler Auth *handler.AuthHandler
SiteSettings *handler.SiteSettingsHandler SiteSettings *handler.SiteSettingsHandler
AppSettings *handler.AppSettingsHandler AppSettings *handler.AppSettingsHandler
AdminRead *handler.AdminReadHandler AdminRead *handler.AdminReadHandler
AdminWrite *handler.AdminWriteHandler AdminWrite *handler.AdminWriteHandler
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
BannedWords *handler.BannedWordsHandler BannedWords *handler.BannedWordsHandler
Canvas *handler.CanvasHandler Canvas *handler.CanvasHandler
} }
func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine { func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine {
@@ -59,16 +59,12 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
engine.GET("/health", handlers.Health.Handle) engine.GET("/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)
+68 -77
View File
@@ -95,19 +95,20 @@ type EventLog struct {
Duration string `gorm:"size:32"` Duration string `gorm:"size:32"`
Refs int `gorm:"not null;default:0"` Refs int `gorm:"not null;default:0"`
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"`
UserID string `gorm:"size:32;index"` // UpstreamJob is the job id assigned by the upstream vivid instance (from
Cost float64 `gorm:"not null;default:0"` // the NDJSON stream's jobId field) — kept for cross-instance debugging.
UpstreamJob string `gorm:"size:64"`
UserID string `gorm:"size:32;index"`
Cost float64 `gorm:"not null;default:0"`
// Refunded marks that this event's up-front charge has already been credited // Refunded marks that this event's up-front charge has already been credited
// back, so the normal failure path and the abandoned-purge sweep can never // back, so the normal failure path and the abandoned-purge sweep can never
// double-refund the same generation. // double-refund the same generation.
@@ -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"`
MaxReferenceImages int `gorm:"not null;default:0"` // DurationMin/DurationMax bound a free-form duration (seconds) for models
ReferenceMode string `gorm:"size:32;not null;default:'none'"` // whose duration spec is a range rather than discrete options. 0 = unset.
// Custom-upstream models (provider="custom"): UpstreamModel is the model name DurationMin int `gorm:"not null;default:0"`
// sent to the upstream OpenAI-compatible API; the base_url + key live on the DurationMax int `gorm:"not null;default:0"`
// matching custom account (pool="custom", meta.base_url). Empty for built-ins. MaxReferenceImages int `gorm:"not null;default:0"`
UpstreamModel string `gorm:"size:255;not null;default:''"` // Per-kind reference caps for upstreams that accept video/audio refs.
// 0 = that kind is not accepted.
MaxReferenceVideos int `gorm:"not null;default:0"`
MaxReferenceAudios int `gorm:"not null;default:0"`
// MaxRefVideoSeconds/MaxRefAudioSeconds cap the combined duration of
// reference videos/audios. 0 = unlimited.
MaxRefVideoSeconds int `gorm:"not null;default:0"`
MaxRefAudioSeconds int `gorm:"not null;default:0"`
ReferenceMode string `gorm:"size:32;not null;default:'none'"`
// Weight controls display order in the model dropdown / admin list: higher // Weight 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,55 +185,37 @@ 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"`
CreatedAt time.Time // Weight biases weighted-random channel order: weight 2 is picked first at
UpdatedAt time.Time // roughly 2x the rate of weight 1.
Weight int `gorm:"not null;default:1"`
SuccessTotal int64 `gorm:"not null;default:0"` // locally-tracked successful generations
UsedCredits float64 `gorm:"not null;default:0"` // upstream credits spent (reported by the upstream)
LastUsedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
} }
type RefreshProfile struct { // 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"` CreatedAt time.Time
ImportedAt *time.Time UpdatedAt time.Time
LastAttemptAt *time.Time
LastSuccessAt *time.Time
LastError string `gorm:"type:text"`
NextRetryAt *time.Time
ConsecutiveFailures int `gorm:"not null;default:0"`
CreatedAt time.Time
UpdatedAt time.Time
} }
type SiteSetting struct { type SiteSetting struct {
@@ -244,8 +235,8 @@ func AutoMigrateModels() []any {
&EventLog{}, &EventLog{},
&ModelConfig{}, &ModelConfig{},
&CDKCode{}, &CDKCode{},
&TokenAccount{}, &Channel{},
&RefreshProfile{}, &ChannelModel{},
&SiteSetting{}, &SiteSetting{},
&StatCounter{}, &StatCounter{},
&ConcurrencyGroup{}, &ConcurrencyGroup{},
@@ -263,15 +254,15 @@ func AutoMigrateModels() []any {
type Order struct { type Order struct {
ID string `gorm:"primaryKey;size:40"` ID string `gorm:"primaryKey;size:40"`
UserID string `gorm:"size:32;index;not null"` UserID string `gorm:"size:32;index;not null"`
Amount float64 `gorm:"not null"` // 充值金额(元) Amount float64 `gorm:"not null"` // 充值金额(元)
Points int `gorm:"not null"` // 到账积分 Points int `gorm:"not null"` // 到账积分
PayType string `gorm:"size:16"` // wxpay | alipay | admin | cdk PayType string `gorm:"size:16"` // wxpay | alipay | admin | cdk
Status string `gorm:"size:16;index;not null"` // pending | paid | cancelled Status string `gorm:"size:16;index;not null"` // pending | paid | cancelled
Source string `gorm:"size:16;index;not null;default:'epay'"` // epay | admin | cdk Source string `gorm:"size:16;index;not null;default:'epay'"` // epay | admin | cdk
Remark string `gorm:"type:text"` // e.g. 兑换码 code / 管理员操作说明 Remark string `gorm:"type:text"` // e.g. 兑换码 code / 管理员操作说明
TradeNo string `gorm:"size:64;index"` // 易支付平台订单号 TradeNo string `gorm:"size:64;index"` // 易支付平台订单号
PayInfo string `gorm:"type:text"` // 二维码 url / 跳转 url PayInfo string `gorm:"type:text"` // 二维码 url / 跳转 url
PayInfoType string `gorm:"size:16"` // qrcode | jump | html | ... PayInfoType string `gorm:"size:16"` // qrcode | jump | html | ...
ExpiresAt time.Time `gorm:"index"` ExpiresAt time.Time `gorm:"index"`
PaidAt *time.Time PaidAt *time.Time
CreatedAt time.Time CreatedAt time.Time
@@ -294,10 +285,10 @@ type ConcurrencyGroup struct {
// node/edge graph as a versioned JSON blob; saves are whole-document // node/edge graph as a versioned JSON blob; saves are whole-document
// replacements (last write wins), which matches the single-editor frontend. // replacements (last write wins), which matches the single-editor frontend.
type Canvas struct { type Canvas struct {
ID string `gorm:"primaryKey;size:40"` ID string `gorm:"primaryKey;size:40"`
UserID string `gorm:"size:32;index;not null"` UserID string `gorm:"size:32;index;not null"`
Title string `gorm:"size:120;not null;default:'未命名画布'"` Title string `gorm:"size:120;not null;default:'未命名画布'"`
Data string `gorm:"type:text"` Data string `gorm:"type:text"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time `gorm:"index"` UpdatedAt time.Time `gorm:"index"`
} }
@@ -1,59 +0,0 @@
package adobe
import (
"encoding/base64"
"encoding/json"
"regexp"
"testing"
)
// captured from live firefly.adobe.com traffic:
//
// ark: 91818c89a54748463.1048135404|r=ap-southeast-1|…|rid=84|ag=101|…
// ftr: dbd9d77a491b4437bc5c4d649a04a794_1785846934401_6890_UDF43-m4_31ck_YRQXWT0P1AE=-7389-v2_tt
//
// The Arkose slot is deliberately emitted empty rather than synthesized — see
// buildARPSessionID — so the expected ftr ends in "_31ck__tt".
var (
arkPat = regexp.MustCompile(`^[0-9a-f]{17}\.[1-9][0-9]{9}\|r=ap-southeast-1\|.*\|rid=[0-9]{1,2}\|ag=101\|`)
ftrPat = regexp.MustCompile(`^[0-9a-f]{32}_[0-9]{13}_[0-9]{4,5}_UDF43-m4_31ck__tt$`)
)
func TestARPSessionIDShape(t *testing.T) {
raw, err := base64.StdEncoding.DecodeString(buildARPSessionID("tok-a"))
if err != nil {
t.Fatalf("not base64: %v", err)
}
var got struct{ Sid, Ark, Ftr string }
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("not json: %v", err)
}
if !arkPat.MatchString(got.Ark) {
t.Errorf("ark shape mismatch:\n%s", got.Ark)
}
if !ftrPat.MatchString(got.Ftr) {
t.Errorf("ftr shape mismatch:\n%s", got.Ftr)
}
if len(got.Sid) != 36 {
t.Errorf("sid not a uuid: %q", got.Sid)
}
}
// ark must differ per call — a frozen blob is a cross-account correlation key.
func TestARKVariesAcrossCalls(t *testing.T) {
if buildARKBlob() == buildARKBlob() {
t.Error("ark is constant across calls")
}
}
// pid is stable per token but distinct across tokens.
func TestPIDStablePerToken(t *testing.T) {
defer ReleasePID("tok-x")
defer ReleasePID("tok-y")
if allocPID("tok-x") != allocPID("tok-x") {
t.Error("pid changed for the same token")
}
if allocPID("tok-x") == allocPID("tok-y") {
t.Error("two tokens share a pid")
}
}
-44
View File
@@ -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
-517
View File
@@ -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
}
-262
View File
@@ -1,262 +0,0 @@
package adobe
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"math/big"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
// adobeUserIDPat matches Adobe IMS user IDs embedded in cookies (e.g.
// "4BDA81F069FC6DA40A495FAB@AdobeID").
var adobeUserIDPat = regexp.MustCompile(`[A-Fa-f0-9]{20,}@AdobeID`)
// PID pool: one PID per token, reused across requests for the same account.
var (
arpPIDMu sync.Mutex
arpTokenPID = map[string]int{} // token → pid
arpPIDToken = map[int]string{} // pid → token
)
func extractUserIDFromCookie(cookie string) string {
decoded, err := url.QueryUnescape(cookie)
if err != nil {
decoded = cookie
}
return adobeUserIDPat.FindString(decoded)
}
func stringValue(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
return strings.TrimSpace(strings.ReplaceAll(toJSONScalar(x), "\n", " "))
}
}
func toJSONScalar(v any) string {
b, err := json.Marshal(v)
if err != nil {
return ""
}
return string(b)
}
func intValue(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
case float32:
return int(x)
case json.Number:
n, _ := x.Int64()
return int(n)
case string:
n, _ := strconv.Atoi(strings.TrimSpace(x))
return n
default:
return 0
}
}
func defaultString(v, fallback string) string {
v = strings.TrimSpace(v)
if v == "" {
return fallback
}
return v
}
func itoa(v int) string {
return strconv.Itoa(v)
}
func decodeJWTPayload(token string) map[string]any {
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) < 2 {
return map[string]any{}
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return map[string]any{}
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return map[string]any{}
}
return out
}
// arkHead and arkTail are the site-constant halves of the ark blob, as captured
// from firefly.adobe.com. The leading "<17hex>.<10digits>" pair and the rid
// field sit between them and vary per session, so buildARKBlob randomizes those
// instead of shipping one frozen blob for every account.
const (
arkHead = "|r=ap-southeast-1|meta=3|metabgclr=transparent|metaiconclr=%23757575|guitextcolor=%23000000|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40|sup=1"
arkTail = "|ag=101|cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|surl=https%3A%2F%2Farks-client.adobe.com|smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager"
)
func buildARKBlob() string {
return randomHex(9)[:17] + "." + randomDigits(10) +
arkHead + "|rid=" + strconv.Itoa(randomInt(1, 99)) + arkTail
}
// buildARPSessionID returns the x-arp-session-id value: base64 of
// {"sid","ark","ftr"}, shaped to match live firefly.adobe.com traffic:
//
// ark: <17hex>.<10digits>|…|rid=<n>|…
// ftr: <32hex>_<unix_ms>_<pid>_UDF43-m4_31ck__tt
//
// The pid is allocated per token (allocPID) so it stays stable across an
// account's requests, the way a real browser session would.
//
// The Arkose segment is left empty on purpose. In a real browser that slot
// carries a "<b64>=-<4digits>-v2" payload emitted by Adobe's Arkose bot-manager
// script; a synthesized one has the right shape but fails Adobe's server-side
// validation, and 3p-images/generate-async rejects it with a misleading
// {"error_code":"timeout_error","message":"system under load"}. Sending the
// slot empty reads as "no Arkose payload yet" and is accepted.
func buildARPSessionID(token string) string {
ftr := strings.Join([]string{
randomHex(16), // 32 hex chars, matching the capture
strconv.FormatInt(time.Now().UnixMilli(), 10),
strconv.Itoa(allocPID(token)),
"UDF43-m4",
"31ck",
"",
"tt",
}, "_")
b, _ := json.Marshal(map[string]any{
"sid": uuid.NewString(),
"ark": buildARKBlob(),
"ftr": ftr,
})
return base64.StdEncoding.EncodeToString(b)
}
// randomDigits returns n decimal digits with a non-zero leading digit, so the
// rendered value keeps a constant width.
func randomDigits(n int) string {
if n <= 0 {
return ""
}
var b strings.Builder
b.Grow(n)
b.WriteByte(byte('1' + randomInt(0, 8)))
for i := 1; i < n; i++ {
b.WriteByte(byte('0' + randomInt(0, 9)))
}
return b.String()
}
func randomBase64(n int) string {
buf := make([]byte, n)
rand.Read(buf)
return base64.RawURLEncoding.EncodeToString(buf)
}
func allocPID(token string) int {
arpPIDMu.Lock()
defer arpPIDMu.Unlock()
if pid, ok := arpTokenPID[token]; ok {
return pid
}
for {
pid := randomInt(1000, 99999)
if _, used := arpPIDToken[pid]; !used {
arpPIDToken[pid] = token
arpTokenPID[token] = pid
return pid
}
}
}
// ReleasePID releases the PID bound to token so it can be reused by another
// account.
func ReleasePID(token string) {
arpPIDMu.Lock()
defer arpPIDMu.Unlock()
if pid, ok := arpTokenPID[token]; ok {
delete(arpPIDToken, pid)
delete(arpTokenPID, token)
}
}
func randomHex(n int) string {
if n <= 0 {
return ""
}
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
now := time.Now().UnixNano()
for i := range buf {
buf[i] = byte(now >> ((i % 8) * 8))
}
}
return hex.EncodeToString(buf)
}
func randomInt(min, max int) int {
if max <= min {
return min
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
if err != nil {
return min
}
return min + int(n.Int64())
}
func intOrNil(v any) any {
switch x := v.(type) {
case nil:
return nil
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
case float32:
return int(x)
case json.Number:
n, err := x.Int64()
if err != nil {
return nil
}
return int(n)
case string:
n, err := strconv.Atoi(strings.TrimSpace(x))
if err != nil {
return nil
}
return n
default:
return nil
}
}
func emptyStringNil(v string) any {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return v
}
File diff suppressed because it is too large Load Diff
-151
View File
@@ -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)
}
-325
View File
@@ -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)
}
-381
View File
@@ -1,381 +0,0 @@
// Package custom implements a generic OpenAI-compatible upstream client. A
// "custom" model forwards generation to any OpenAI-compatible API: the upstream
// base_url + api_key live on a custom account (pool="custom"), the upstream model
// name on the model config (UpstreamModel). Calls go DIRECT (no tls-client, no
// proxy) — the upstream is a normal API with no anti-bot.
package custom
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
var (
ErrAuth = errors.New("custom upstream auth failed")
ErrQuotaExhausted = errors.New("custom upstream quota exhausted")
ErrTemporaryUpstream = errors.New("custom upstream temporary error")
)
type Client struct{}
func NewClient() *Client { return &Client{} }
// sanitizeErr strips the upstream URL/host from a network error so a user's
// private upstream URL never leaks into the event log / API response.
func sanitizeErr(err error) string {
if err == nil {
return ""
}
s := err.Error()
switch {
case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"):
return "request timeout"
case strings.Contains(s, "connection refused"):
return "connection refused"
case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "):
return "cannot reach upstream"
case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"):
return "TLS error"
case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"):
return "connection reset"
}
var ue *url.Error
if errors.As(err, &ue) {
return strings.ToLower(ue.Op) + " upstream failed"
}
return "upstream request failed"
}
func httpClient() *http.Client { return &http.Client{Timeout: 30 * time.Minute} }
// GenerateImage calls the upstream OpenAI image API. With reference images it
// uses /v1/images/edits (multipart); otherwise /v1/images/generations. Returns
// the raw image bytes (decoded from b64_json, or downloaded from url).
func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prompt, size, quality string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" || apiKey == "" {
return nil, "", ErrAuth
}
var req *http.Request
var err error
if len(refs) > 0 {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
_ = w.WriteField("model", model)
_ = w.WriteField("prompt", prompt)
// Ask the upstream for a URL (not base64) so we can pass it through directly.
_ = w.WriteField("response_format", "url")
if size != "" {
_ = w.WriteField("size", size)
}
for i, r := range refs {
fw, e := w.CreateFormFile("image[]", fmt.Sprintf("ref_%d.png", i+1))
if e != nil {
return nil, "", e
}
_, _ = fw.Write(r)
}
_ = w.Close()
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/edits", body)
if err != nil {
return nil, "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
} else {
payload := map[string]any{"model": model, "prompt": prompt, "n": 1, "response_format": "url"}
if size != "" {
payload["size"] = size
}
if quality != "" {
payload["quality"] = quality
}
raw, _ := json.Marshal(payload)
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/generations", bytes.NewReader(raw))
if err != nil {
return nil, "", err
}
req.Header.Set("Content-Type", "application/json")
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient().Do(req)
if err != nil {
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if e := mapStatus(resp.StatusCode, body); e != nil {
return nil, "", e
}
return imageFromResponse(ctx, body, downloadResult)
}
// GenerateVideo drives the upstream Sora-style async video API:
// POST /v1/videos → poll GET /v1/videos/{id} → GET /v1/videos/{id}/content.
// Reference frames (image-to-video / first-last frames) are sent as multipart
// input_reference[] files, matching the OpenAI videos API. When downloadResult
// is false it returns the upstream content URL instead.
func (c *Client) GenerateVideo(ctx context.Context, baseURL, apiKey, model, prompt, size string, seconds int, frames [][]byte, downloadResult bool) ([]byte, string, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" || apiKey == "" {
return nil, "", ErrAuth
}
var created map[string]any
var err error
if len(frames) > 0 {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
_ = w.WriteField("model", model)
_ = w.WriteField("prompt", prompt)
if size != "" {
_ = w.WriteField("size", size)
}
if seconds > 0 {
_ = w.WriteField("seconds", fmt.Sprintf("%d", seconds))
}
for i, f := range frames {
fw, e := w.CreateFormFile("input_reference[]", fmt.Sprintf("frame_%d.png", i+1))
if e != nil {
return nil, "", e
}
_, _ = fw.Write(f)
}
_ = w.Close()
created, err = c.doMultipart(ctx, baseURL+"/v1/videos", apiKey, body, w.FormDataContentType())
} else {
payload := map[string]any{"model": model, "prompt": prompt}
if size != "" {
payload["size"] = size
}
if seconds > 0 {
payload["seconds"] = fmt.Sprintf("%d", seconds)
}
raw, _ := json.Marshal(payload)
created, err = c.doJSON(ctx, http.MethodPost, baseURL+"/v1/videos", apiKey, raw)
}
if err != nil {
return nil, "", err
}
jobID := strings.TrimSpace(stringValue(created["id"]))
if jobID == "" {
return nil, "", fmt.Errorf("%w: video create missing id", ErrTemporaryUpstream)
}
// Poll until terminal.
for {
if err := ctx.Err(); err != nil {
return nil, "", err
}
job, err := c.doJSON(ctx, http.MethodGet, baseURL+"/v1/videos/"+jobID, apiKey, nil)
if err != nil {
if errors.Is(err, ErrTemporaryUpstream) {
if sleepCtx(ctx, 5*time.Second) != nil {
return nil, "", ctx.Err()
}
continue
}
return nil, "", err
}
switch strings.ToLower(strings.TrimSpace(stringValue(job["status"]))) {
case "completed", "succeeded", "success":
contentURL := baseURL + "/v1/videos/" + jobID + "/content"
if !downloadResult {
return nil, contentURL, nil
}
data, err := c.download(ctx, contentURL, apiKey)
if err != nil {
return nil, "", err
}
return data, contentURL, nil
case "failed", "error", "canceled", "cancelled":
reason := stringValue(job["error"])
if isCreditError(reason) {
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip([]byte(reason), 160))
}
return nil, "", fmt.Errorf("custom: video %s", clip([]byte(reason), 160))
}
if sleepCtx(ctx, 5*time.Second) != nil {
return nil, "", ctx.Err()
}
}
}
func (c *Client) doJSON(ctx context.Context, method, url, apiKey string, body []byte) (map[string]any, error) {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if e := mapStatus(resp.StatusCode, raw); e != nil {
return nil, e
}
var out map[string]any
if len(raw) == 0 {
return map[string]any{}, nil
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%w: non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
}
return out, nil
}
func (c *Client) doMultipart(ctx context.Context, url, apiKey string, body io.Reader, contentType string) (map[string]any, error) {
req, err := http.NewRequest(http.MethodPost, url, body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", contentType)
resp, err := httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if e := mapStatus(resp.StatusCode, raw); e != nil {
return nil, e
}
var out map[string]any
if len(raw) == 0 {
return map[string]any{}, nil
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%w: non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
}
return out, nil
}
func (c *Client) download(ctx context.Context, url, apiKey string) ([]byte, error) {
req, _ := http.NewRequest(http.MethodGet, url, nil)
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, fmt.Errorf("%w: empty download", ErrTemporaryUpstream)
}
return data, nil
}
// imageBytesFromResponse extracts image bytes from an OpenAI images response:
// data[0].b64_json (preferred) or data[0].url (downloaded).
// imageFromResponse parses an OpenAI image response and returns the upstream URL.
// We always request response_format=url, so the response must carry a URL — a
// base64-only response is treated as an error (no base64 pass-through). With
// downloadResult=false the URL is returned directly (no download).
func imageFromResponse(ctx context.Context, body []byte, downloadResult bool) ([]byte, string, error) {
var out struct {
Data []struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &out); err != nil || len(out.Data) == 0 {
return nil, "", fmt.Errorf("%w: bad image response: %s", ErrTemporaryUpstream, clip(body, 160))
}
url := strings.TrimSpace(out.Data[0].URL)
if url == "" {
return nil, "", fmt.Errorf("%w: image response had no url (upstream ignored response_format=url)", ErrTemporaryUpstream)
}
if !downloadResult {
return nil, url, nil
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := httpClient().Do(req)
if err != nil {
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
return raw, url, err
}
func mapStatus(status int, body []byte) error {
switch {
case status >= 200 && status < 300:
return nil
case status == 401 || status == 403:
return fmt.Errorf("%w: %d %s", ErrAuth, status, clip(body, 160))
case status == 429:
// Custom upstreams have NO "quota exhausted" lock — a 429 is just rate
// limiting, treated as a temporary error (fail over, account stays active).
return fmt.Errorf("%w: 429 %s", ErrTemporaryUpstream, clip(body, 160))
case status >= 500:
return fmt.Errorf("%w: %d %s", ErrTemporaryUpstream, status, clip(body, 160))
default:
if isCreditError(string(body)) {
return fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip(body, 160))
}
return fmt.Errorf("custom: %d %s", status, clip(body, 160))
}
}
func isCreditError(s string) bool {
s = strings.ToLower(s)
return strings.Contains(s, "insufficient") || strings.Contains(s, "quota") ||
strings.Contains(s, "credit") || strings.Contains(s, "balance")
}
func stringValue(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
b, _ := json.Marshal(x)
return strings.TrimSpace(string(b))
}
}
func clip(b []byte, n int) string {
s := strings.TrimSpace(string(b))
if len(s) > n {
return s[:n]
}
return s
}
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
-935
View File
@@ -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
}
-609
View File
@@ -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); }
};
})();
-528
View File
@@ -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)
}
-452
View File
@@ -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
}
-251
View File
@@ -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
}
-499
View File
@@ -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
}
-324
View File
@@ -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
}
-401
View File
@@ -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))
}
}
-412
View File
@@ -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
}
-373
View File
@@ -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
}
-181
View File
@@ -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
}
-516
View File
@@ -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
}
}
+190
View File
@@ -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
}
+12 -5
View File
@@ -24,8 +24,8 @@ type EventListFilter struct {
UserID string UserID string
UserIDs []string // when set, keep ONLY rows whose user_id is in this list (admin 用户搜索) UserIDs []string // when set, keep ONLY rows whose user_id is in this list (admin 用户搜索)
Query string // free-text search over prompt / model / error (server-side, 跨页) Query string // free-text search over prompt / model / error (server-side, 跨页)
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page) ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型) Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media) HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media)
ExcludeFiles []string // when set, omit rows whose file is in this list (e.g. hide homepage showcase media from user galleries) ExcludeFiles []string // when set, omit rows whose file is in this list (e.g. hide homepage showcase media from user galleries)
MediaOnly bool // when true, keep only rows that are pending or have a stored file — the 画图台 grid, so deleted works don't eat a slot MediaOnly bool // when true, keep only rows that are pending or have a stored file — the 画图台 grid, so deleted works don't eat a slot
@@ -618,9 +618,16 @@ func (r *EventRepository) MarkRefunded(ctx context.Context, eventID string) (boo
return res.RowsAffected == 1, nil 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
}
-529
View File
@@ -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
}
+40 -70
View File
@@ -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
+31 -50
View File
@@ -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,10 +436,9 @@ 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).
@@ -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)
} }
+9 -1
View File
@@ -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 {
+450
View File
@@ -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)
}
+34 -158
View File
@@ -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,20 +30,17 @@ 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, events: events,
tokenSvc: tokenSvc, users: users,
events: events, settings: settings,
users: users, store: store,
refresh: refresh, inflight: inflight,
settings: settings, showcase: showcase,
store: store, orders: orders,
inflight: inflight, interval: 60 * time.Second,
showcase: showcase, stalePending: 600 * time.Second,
orders: orders,
interval: 60 * time.Second,
stalePending: 600 * time.Second,
// 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的 // 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的
// 10 分钟去扫会在它出片前判死并退款。 // 10 分钟去扫会在它出片前判死并退款。
stalePendingVid: 30 * time.Minute, stalePendingVid: 30 * time.Minute,
@@ -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
+10 -9
View File
@@ -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,
}) })
} }
} }
File diff suppressed because it is too large Load Diff
+83
View File
@@ -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
}
+631
View File
@@ -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
}
}
+138
View File
@@ -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>
-166
View File
@@ -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>
+53 -35
View File
@@ -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,20 +112,16 @@ 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 videoPrices.value[d] = m.duration_prices?.[d] ?? ''
videoPricesAgent.value['per_second'] = m.duration_prices_agent?.per_second ?? '' videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
} else {
const durs = (m.durations && m.durations.length) ? m.durations : Object.keys(m.duration_prices || {})
for (const d of durs) {
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
}
} }
} }
} }
@@ -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>
+19 -4
View File
@@ -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">
-123
View File
@@ -1,123 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue'
import { api, jsonBody } from '../api'
import Icon from './Icon.vue'
const props = defineProps({ account: { type: Object, default: null } }) // edit mode when set
const emit = defineEmits(['close', 'imported'])
const isEdit = !!props.account
const name = ref(props.account?.email || '')
const baseUrl = ref(props.account?.base_url || '')
const key = ref('') // edit: blank = keep existing key
const allModels = ref([]) // existing models to pick from
const selected = ref(props.account?.models ? String(props.account.models).split(',').map((x) => x.trim()).filter(Boolean) : [])
const weight = ref(Number(props.account?.weight) || 0)
const concurrency = ref(Number(props.account?.concurrency) || 1)
const status = ref('')
const isError = ref(false)
const submitting = ref(false)
onMounted(async () => {
try {
const r = await api('/managed-models')
allModels.value = (r.data?.data || []).map((m) => ({ id: m.id, type: m.type, alias: m.alias }))
} catch (_) {}
})
function toggle(id) {
const i = selected.value.indexOf(id)
if (i >= 0) selected.value.splice(i, 1)
else selected.value.push(id)
}
async function submit() {
if (!baseUrl.value.trim() || (!isEdit && !key.value.trim())) {
status.value = isEdit ? '请填写 URL' : '请填写 URL 和 Key'; isError.value = true; return
}
submitting.value = true; status.value = ''; isError.value = false
try {
const r = await api('/tokens/import-custom-account', jsonBody('POST', {
id: isEdit ? props.account.id : undefined,
name: name.value.trim(),
base_url: baseUrl.value.trim(),
key: key.value.trim(), // blank in edit = keep existing
models: selected.value.join(','),
weight: Number(weight.value) || 0,
concurrency: Number(concurrency.value) || 1,
}))
if (r.ok) {
status.value = isEdit ? '✓ 已保存' : '✓ 已添加上游'; emit('imported')
setTimeout(() => emit('close'), 700)
} else {
status.value = r.data?.detail || '保存失败'; isError.value = true
}
} catch (e) {
status.value = String(e); isError.value = true
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
@click.self="emit('close')">
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-lg">
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 class="text-sm font-semibold">{{ isEdit ? '编辑上游' : '添加上游(自定义 OpenAI 兼容)' }}</h2>
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
<Icon name="close" class="w-5 h-5" />
</button>
</div>
<div class="p-5 space-y-3">
<p class="text-xs text-slate-500 leading-relaxed">
上游就是一个账号:填 v1 URL + Key。模型按 <strong class="text-slate-700">id 相同</strong>自动路由 ——
在「模型管理」加一个 provider=custom、id 与上游一致的模型即可从这个上游调用。调用<strong class="text-slate-700">直连不走代理</strong>。
</p>
<div>
<label class="text-xs text-slate-500">备注名</label>
<input v-model="name" class="field" placeholder="例如:我的中转 / xx-api" />
</div>
<div>
<label class="text-xs text-slate-500">v1 URL <span class="text-rose-500">*</span></label>
<input v-model="baseUrl" class="field font-mono text-xs" placeholder="https://api.example.com(无需 /v1 结尾)" />
</div>
<div>
<label class="text-xs text-slate-500">Key <span v-if="!isEdit" class="text-rose-500">*</span><span v-else class="text-white/40">(留空=不改)</span></label>
<input v-model="key" class="field font-mono text-xs" :placeholder="isEdit ? '留空保持原 key' : 'sk-...'" />
</div>
<div>
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs text-slate-500">支持的模型(多选,不选 = 全部)</label>
<span class="text-[11px] text-slate-400">{{ selected.length ? `已选 ${selected.length}` : '全部' }}</span>
</div>
<div v-if="!allModels.length" class="text-xs text-slate-400 rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-3">
暂无模型 —— 可先去模型管理加自定义模型
</div>
<div v-else class="flex flex-wrap gap-1.5 max-h-44 overflow-y-auto rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-2">
<button v-for="m in allModels" :key="m.id" type="button" @click="toggle(m.id)"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs ring-1 transition-colors"
:class="selected.includes(m.id) ? 'bg-sky-500/15 text-sky-700 ring-sky-300 font-medium' : 'bg-white text-slate-600 ring-slate-200 hover:ring-slate-300'">
<span class="w-1.5 h-1.5 rounded-full" :class="m.type === 'video' ? 'bg-orange-400' : 'bg-emerald-400'"></span>{{ m.alias || m.id }}
</button>
</div>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="text-xs text-slate-500">权重(高的优先)</label>
<input v-model.number="weight" type="number" class="field" placeholder="0" />
</div>
<div class="flex-1">
<label class="text-xs text-slate-500">并发数(单账号)</label>
<input v-model.number="concurrency" type="number" min="1" class="field" placeholder="1" />
</div>
</div>
<button @click="submit" :disabled="submitting" class="btn-primary w-full mt-1">
{{ submitting ? '保存中…' : (isEdit ? '保存' : '添加上游') }}
</button>
<p v-if="status" class="text-xs" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
</div>
</div>
</div>
</template>
+13 -3
View File
@@ -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;
+3 -3
View File
@@ -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
View File
@@ -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) */
-137
View File
@@ -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 }
})
}
+5 -11
View File
@@ -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 取预设。
-759
View File
@@ -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>
+49 -12
View File
@@ -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;
+362
View File
@@ -0,0 +1,362 @@
<script setup>
import { ref, onMounted } from 'vue'
import { api, jsonBody } from '../api'
import { fmtTs, fmtDate } from '../utils/format'
import Icon from '../components/Icon.vue'
const KIND_LABEL = { image: '图像', video: '视频', text: '文本' }
const channels = ref([])
const loading = ref(false)
const err = ref('')
// create/edit form
const form = ref(null)
const saving = ref(false)
const formErr = ref('')
// fetch-models → pick → import
const fetchTarget = ref(null) // channel snapshot being imported into
const fetching = ref(false)
const fetchErr = ref('')
const specs = ref([])
const picked = ref(new Set())
const importing = ref(false)
const managedIds = ref(new Set())
async function load() {
loading.value = true
err.value = ''
const r = await api('/channels')
if (r.ok) channels.value = r.data?.data || []
else err.value = r.data?.detail || '渠道加载失败'
loading.value = false
}
async function loadManaged() {
const r = await api('/managed-models')
managedIds.value = new Set((r.data?.data || []).map((m) => m.id))
}
function modelSummary(c) {
const names = c.models || []
if (!names.length) return ''
return names.length <= 2 ? names.join('、') : `${names[0]}、${names[1]} 等`
}
function openAdd() {
formErr.value = ''
form.value = { id: '', name: '', base_url: '', api_key: '', concurrency: 5, weight: 1, enabled: true }
}
function openEdit(c) {
formErr.value = ''
form.value = { id: c.id, name: c.name, base_url: c.base_url, api_key: '', concurrency: c.concurrency || 0, weight: c.weight || 1, enabled: !!c.enabled }
}
async function save() {
const f = form.value
if (!f) return
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
if (!f.id && !f.api_key) { formErr.value = '请填写 API Key'; return }
if (saving.value) return
saving.value = true
formErr.value = ''
const r = await api('/channels', jsonBody('POST', {
id: f.id, name: f.name, base_url: f.base_url, api_key: f.api_key,
enabled: !!f.enabled, concurrency: f.concurrency || 0, weight: f.weight || 1,
}))
saving.value = false
if (!r.ok) { formErr.value = r.data?.detail || '保存失败'; return }
form.value = null
load()
}
async function toggle(c) {
const prev = !!c.enabled
c.enabled = !prev
const r = await api('/channels', jsonBody('POST', {
id: c.id, name: c.name, base_url: c.base_url, api_key: '',
enabled: c.enabled, concurrency: c.concurrency || 0, weight: c.weight || 1,
}))
if (!r.ok) { c.enabled = prev; err.value = r.data?.detail || '状态更新失败' }
}
async function del(c) {
if (!confirm(`确认删除渠道「${c.name || c.base_url}」?其绑定的模型若没有其他渠道接入,将一并删除。`)) return
const r = await api('/channels/delete', jsonBody('POST', { id: c.id }))
if (!r.ok) { err.value = r.data?.detail || '删除失败'; return }
channels.value = channels.value.filter((x) => x.id !== c.id)
}
// 表单内拉模型:编辑态走库内渠道(密钥不用重填);新渠道用表单里的地址+key 预览。
function fetchFromForm() {
const f = form.value
if (!f) return
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
if (!f.id && !f.api_key) { formErr.value = '请填写 API Key'; return }
openFetch({ id: f.id || '', name: f.name || f.base_url, base_url: f.base_url, api_key: f.api_key, concurrency: f.concurrency, weight: f.weight, enabled: !!f.enabled })
}
async function openFetch(c) {
fetchTarget.value = c
specs.value = []
picked.value = new Set()
fetchErr.value = ''
fetching.value = true
const r = await api('/channels/fetch-models', jsonBody('POST', {
id: c.id || '', base_url: c.base_url || '', api_key: c.api_key || '',
}))
fetching.value = false
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', imported: managedIds.value.has(s.id) }))
picked.value = new Set(specs.value.filter((s) => !s.imported).map((s) => s.id))
if (!picked.value.size) picked.value = new Set(specs.value.map((s) => s.id))
}
function togglePick(id) {
const next = new Set(picked.value)
next.has(id) ? next.delete(id) : next.add(id)
picked.value = next
}
function specText(s) {
const parts = []
if (s.qualities?.length) parts.push(s.qualities.join('/'))
if (s.duration) {
if (s.duration.mode === 'fixed') parts.push(`固定 ${s.duration.seconds}s`)
else if (s.duration.mode === 'range') parts.push(`${s.duration.min}-${s.duration.max}s`)
else if (s.duration.mode === 'options') parts.push((s.duration.options || []).map((d) => `${d}s`).join('/'))
}
const refs = []
if (s.refImages > 0) refs.push(`图片${s.refImages}`)
if (s.refVideos > 0) refs.push(`视频${s.refVideos}${s.refVideoSeconds > 0 ? `≤${s.refVideoSeconds}s` : ''}`)
if (s.refAudios > 0) refs.push(`音频${s.refAudios}${s.refAudioSeconds > 0 ? `≤${s.refAudioSeconds}s` : ''}`)
if (refs.length) parts.push(`参考 ${refs.join('+')}`)
return parts.join(' · ') || '-'
}
async function doImport() {
if (importing.value || !picked.value.size) return
const models = specs.value
.filter((s) => picked.value.has(s.id))
.map((s) => {
const { imported, ...rest } = s
return rest
})
const target = fetchTarget.value
importing.value = true
try {
if (!target.id) {
// 表单预览路径:渠道尚未入库,先按快照保存再绑定;失败中止不建孤儿。
const sr = await api('/channels', jsonBody('POST', {
id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key,
enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1,
}))
if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return }
target.id = sr.data?.id
form.value = null
}
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models }))
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
fetchTarget.value = null
load()
loadManaged()
} finally {
importing.value = false
}
}
onMounted(() => { load(); loadManaged() })
</script>
<template>
<section class="space-y-4">
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
<div>
<div class="text-sm font-medium">渠道</div>
<p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 模型经「拉取上游模型」导入 · 余额实时探测</p>
</div>
<button @click="openAdd" class="btn-primary">
<Icon name="plus" class="w-4 h-4" /> 添加渠道
</button>
</div>
<p v-if="err" class="text-xs text-rose-300">{{ err }}</p>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="tbl">
<thead>
<tr>
<th>渠道</th>
<th>上游地址</th>
<th>模型</th>
<th>并发</th>
<th>配比</th>
<th class="text-right">成功调用</th>
<th class="text-right">累计消耗</th>
<th class="text-right">上游余额</th>
<th>状态</th>
<th>最近使用</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="c in channels" :key="c.id">
<td>
<div class="font-medium text-white/85">{{ c.name }}</div>
<div class="text-[11px] text-white/35 font-mono">{{ c.id }}</div>
</td>
<td>
<div class="max-w-[240px] truncate font-mono text-xs text-white/55" :title="c.base_url">{{ c.base_url }}</div>
</td>
<td>
<div class="tabular-nums text-white/80">{{ (c.models || []).length }}</div>
<div class="text-[11px] text-white/35 max-w-[180px] truncate" :title="(c.models || []).join('、')">{{ modelSummary(c) }}</div>
</td>
<td class="tabular-nums">{{ c.concurrency || '默认' }}</td>
<td class="tabular-nums">{{ c.weight || 1 }}</td>
<td class="text-right tabular-nums">{{ (c.success_total || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums">{{ (c.used_credits || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums">{{ c.upstream_balance == null ? '-' : c.upstream_balance.toLocaleString() }}</td>
<td>
<button @click="toggle(c)" class="inline-flex items-center gap-1.5 text-xs"
:class="c.enabled ? 'text-emerald-300' : 'text-white/40'">
<span class="w-1.5 h-1.5 rounded-full" :class="c.enabled ? 'bg-emerald-400' : 'bg-white/25'"></span>
{{ c.enabled ? '启用' : '停用' }}
</button>
</td>
<td class="text-xs text-white/45 whitespace-nowrap">{{ c.last_used_at ? fmtTs(c.last_used_at) : '-' }}</td>
<td class="text-right whitespace-nowrap">
<button @click="openFetch(c)" class="op" title="拉取上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button>
<button @click="openEdit(c)" class="op" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button>
<button @click="del(c)" class="op danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
</td>
</tr>
<tr v-if="loading"><td colspan="11" class="py-10 text-center text-white/40 text-sm">正在加载渠道…</td></tr>
<tr v-else-if="!channels.length"><td colspan="11" class="py-10 text-center text-white/40 text-sm">没有渠道,点右上角「添加渠道」接入上游实例</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 添加 / 编辑渠道 -->
<div v-if="form" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="form = null">
<div class="card !shadow-2xl my-12 w-full max-w-lg">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">{{ form.id ? '编辑渠道' : '添加渠道' }}</h2>
<button @click="form = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button>
</div>
<div class="p-5 space-y-4">
<div>
<label class="lbl">渠道名称</label>
<input v-model.trim="form.name" type="text" maxlength="64" class="field" placeholder="例如:主站" />
</div>
<div>
<label class="lbl">上游地址 <span class="text-rose-300">*</span></label>
<input v-model.trim="form.base_url" type="text" maxlength="300" class="field" placeholder="https://example.com" />
<p class="mt-1 text-[11px] text-white/35">同构 /v1 协议实例的站点地址,不带路径</p>
</div>
<div>
<label class="lbl">API Key <span v-if="!form.id" class="text-rose-300">*</span></label>
<input v-model.trim="form.api_key" type="text" class="field" :placeholder="form.id ? '留空保持不变' : 'vk_…'" autocomplete="off" />
<p class="mt-1 text-[11px] text-white/35">上游站点的用户 API Key(vk_ 前缀),密文存储,不回显</p>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="lbl">并发数</label>
<input v-model.number="form.concurrency" type="number" min="0" max="100" class="field" />
<p class="mt-1 text-[11px] text-white/35">0 = 默认 5</p>
</div>
<div>
<label class="lbl">配比</label>
<input v-model.number="form.weight" type="number" min="1" max="100" class="field" />
<p class="mt-1 text-[11px] text-white/35">同模型多渠道按配比分单</p>
</div>
</div>
<div class="flex items-center gap-3">
<label class="lbl !mb-0">状态</label>
<button type="button" @click="form.enabled = !form.enabled" class="inline-flex items-center gap-1.5 text-xs"
:class="form.enabled ? 'text-emerald-300' : 'text-white/40'">
<span class="w-1.5 h-1.5 rounded-full" :class="form.enabled ? 'bg-emerald-400' : 'bg-white/25'"></span>
{{ form.enabled ? '已启用' : '已停用' }}
</button>
</div>
<div class="flex items-center gap-3">
<button type="button" @click="fetchFromForm" :disabled="fetching" class="btn-ghost text-xs">
{{ fetching ? '拉取中…' : '获取模型列表' }}
</button>
<span class="text-[11px] text-white/35">{{ form.id ? '用已保存的地址与密钥拉取' : '用上方的地址与 API Key 拉取;导入时会先自动保存渠道' }}</span>
</div>
<p v-if="formErr" class="text-xs text-rose-300">{{ formErr }}</p>
</div>
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
<button @click="form = null" class="btn-ghost">取消</button>
<button @click="save" :disabled="saving" class="btn-primary">{{ saving ? '保存中…' : '保存' }}</button>
</div>
</div>
</div>
<!-- 拉取上游模型 → 勾选导入 -->
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
<div class="card !shadow-2xl my-12 w-full max-w-2xl">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">上游模型 · {{ fetchTarget.name || fetchTarget.base_url }}</h2>
<button @click="fetchTarget = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button>
</div>
<div class="p-5">
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
<div v-else-if="fetchErr" class="py-10 text-center text-rose-300 text-sm">{{ fetchErr }}</div>
<template v-else>
<p class="text-xs text-white/45 mb-3">勾选要导入的模型:能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名);已导入的模型自动标记(重复导入会刷新参数)。</p>
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
<div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto">
<div v-for="s in specs" :key="s.id" @click="togglePick(s.id)"
class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors"
:class="!picked.has(s.id) && 'opacity-50'">
<input type="checkbox" :checked="picked.has(s.id)" @click.stop @change="togglePick(s.id)" class="mt-1 accent-sky-400" />
<div class="flex-1 min-w-0 space-y-1.5">
<div class="flex items-center gap-2 flex-wrap">
<code class="font-mono text-xs text-white/85">{{ s.id }}</code>
<span class="px-1.5 py-0.5 rounded text-[10px] font-semibold"
:class="s.kind === 'video' ? 'bg-rose-400/15 text-rose-300' : 'bg-sky-400/15 text-sky-300'">
{{ KIND_LABEL[s.kind] || s.kind }}
</span>
<span class="text-[11px] text-white/40">{{ specText(s) }}</span>
<span v-if="s.imported" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已导入</span>
</div>
<div class="flex items-center gap-2" @click.stop>
<span class="text-[11px] text-white/35 shrink-0">别名</span>
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="!picked.has(s.id)"
class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40"
placeholder="留空 = 与上游同名" />
</div>
</div>
</div>
</div>
</template>
</div>
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
<button @click="fetchTarget = null" class="btn-ghost">关闭</button>
<button @click="doImport" :disabled="importing || !picked.size" class="btn-primary">
{{ importing ? '导入中…' : (fetchTarget?.id ? `导入选中(${picked.size})` : `保存渠道并导入(${picked.size})`) }}
</button>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.tbl { width: 100%; border-collapse: collapse; min-width: 1000px; }
.tbl thead th { padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 600; color: rgb(255 255 255 / 0.45); border-bottom: 1px solid rgb(255 255 255 / 0.06); white-space: nowrap; }
.tbl tbody td { padding: 10px 14px; border-bottom: 1px solid rgb(255 255 255 / 0.04); font-size: 12.5px; color: rgb(255 255 255 / 0.7); vertical-align: middle; }
.tbl tbody tr:last-child td { border-bottom: none; }
.tbl tbody tr:hover { background: rgb(255 255 255 / 0.02); }
.lbl { display: block; font-size: 0.72rem; font-weight: 500; color: rgb(255 255 255 / 0.55); margin-bottom: 0.4rem; }
.op { display: inline-grid; place-items: center; min-width: 28px; height: 26px; padding: 0 7px; margin-left: 4px; border-radius: 7px; background: rgb(255 255 255 / 0.05); color: rgb(255 255 255 / 0.6); }
.op:hover { background: rgb(255 255 255 / 0.1); color: white; }
.op.danger:hover { background: rgb(244 63 94 / 0.15); color: rgb(251 113 133); }
</style>
+107 -252
View File
@@ -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 &lt;key&gt;</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>
<p class="text-xs text-white/40 mt-3">
用法:POST 拿 <code class="text-white/70">url</code> → PUT 直传 → 返回的 <code class="text-white/70">key</code> 放进
<code class="text-white/70">/v1/generate</code> 的 <code class="text-white/70">refs</code>。
预签名 URL 仅用于 PUT(不可 GET 读回),30 分钟内有效。
支持:图 png/jpeg/webp,视频 mp4/webm/mov,音频 mp3/wav/m4a/ogg。
</p>
</div> </div>
<div class="lg:col-span-2">
<h2 class="text-lg font-semibold mb-3">视频参数 <span class="text-xs font-normal text-white/40">/v1/videos · 异步</span></h2>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
</tr></thead>
<tbody>
<tr v-for="p in videoParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- size 对照表(课时表)—— 解决"传错分辨率" -->
<section>
<h2 class="text-lg font-semibold mb-1">图像分辨率对照表 · <code class="text-white/70 text-sm">size</code> 该传什么</h2>
<p class="text-xs text-white/45 mb-3">
左边选比例,上面选分辨率档,交叉格里就是 <code class="text-white/70">size</code> 要传的值(直接复制)。
没有 <code class="text-white/70">quality</code> 参数,图像分辨率只看 <code class="text-white/70">size</code> 的<strong class="text-white/70">长边</strong>。
档位必须是该模型支持的(见上方「可用模型」的分辨率列),不支持会自动回退到该模型最低档。
</p>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-2.5 font-medium">比例</th>
<th class="px-4 py-2.5 font-medium">1K</th>
<th class="px-4 py-2.5 font-medium">2K</th>
<th class="px-4 py-2.5 font-medium">4K</th>
</tr></thead>
<tbody>
<tr v-for="row in sizeTable" :key="row.ratio" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-2.5 text-white/75">{{ row.ratio }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k1 }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k2 }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k4 }}</td>
</tr>
</tbody>
</table>
</div>
<p class="text-xs text-white/40 mt-2">
例:想要 <strong class="text-white/70">2K 的 16:9 横图</strong> → <code class="text-white/70">"size": "2048x1152"</code>。
留空 size = 默认 <strong class="text-white/70">1:1 · 2K</strong>。
</p>
<!-- 视频分辨率(720p / 1080p,按短边判) -->
<h2 class="text-lg font-semibold mb-1 mt-8">视频分辨率对照表 · <code class="text-white/70 text-sm">size</code> 该传什么</h2>
<p class="text-xs text-white/45 mb-3">
视频用 <code class="text-white/70">720p</code> / <code class="text-white/70">1080p</code> 两档,只看 <code class="text-white/70">size</code> 的<strong class="text-white/70">短边</strong>(短边 ≥1080 = 1080p,否则 720p)。
档位必须是该视频模型支持的(如 grok-video 仅 720p),不支持会被拒。
</p>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-2.5 font-medium">比例</th>
<th class="px-4 py-2.5 font-medium">720p</th>
<th class="px-4 py-2.5 font-medium">1080p</th>
</tr></thead>
<tbody>
<tr v-for="row in videoSizeTable" :key="row.ratio" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-2.5 text-white/75">{{ row.ratio }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.p720 }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.p1080 }}</td>
</tr>
</tbody>
</table>
</div>
<p class="text-xs text-white/40 mt-2">
例:想要 <strong class="text-white/70">720p 的 16:9 横版视频</strong> → <code class="text-white/70">"size": "1280x720"</code>;
竖版 9:16 → <code class="text-white/70">"720x1280"</code>。
</p>
</section> </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>(长边:&lt;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>
+1 -1
View File
@@ -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>
+6 -13
View File
@@ -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>
+19 -21
View File
@@ -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"
+43 -24
View File
@@ -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>
+1 -1
View File
@@ -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() {