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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 04:01:06 +08:00
co-authored by Claude Opus 4.8
parent 8633addf64
commit 17cd289dfd
16 changed files with 1442 additions and 166 deletions
+160 -10
View File
@@ -18,6 +18,7 @@ import (
"backend/internal/provider/imagine"
"backend/internal/provider/krea"
"backend/internal/provider/leonardo"
"backend/internal/provider/grok"
"backend/internal/provider/runway"
"backend/internal/repo"
@@ -32,6 +33,7 @@ var validTokenPools = map[string]string{
"leonardo": "leonardo",
"krea": "krea",
"imagine": "imagine",
"grok": "grok",
}
type TokenService struct {
@@ -45,6 +47,7 @@ type TokenService struct {
leonardo *leonardo.Client
krea *krea.Client
imagine *imagine.Client
grok *grok.Client
// sem caps concurrent background pending-probe goroutines (mirrors Python's
// 10-worker _quota_check_pool) so a big paste doesn't fire hundreds of
// simultaneous upstream requests.
@@ -54,7 +57,7 @@ type TokenService struct {
kreaActivating atomic.Bool
}
func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client) *TokenService {
func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client) *TokenService {
return &TokenService{
tokens: tokens,
refresh: refresh,
@@ -66,6 +69,7 @@ func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileR
leonardo: leonardoClient,
krea: kreaClient,
imagine: imagineClient,
grok: grokClient,
sem: make(chan struct{}, 10),
}
}
@@ -91,6 +95,9 @@ func (s *TokenService) applyProxy(ctx context.Context) {
if s.imagine != nil {
s.imagine.SetProxy(proxy)
}
if s.grok != nil {
s.grok.SetProxy(proxy)
}
}
// RefreshExpiringTokens proactively renews krea/imagine sessions ~10min before
@@ -840,6 +847,106 @@ func (s *TokenService) checkPendingRunway(tokenID, accessToken string) {
s.finishPending(ctx, "runway", tokenID, "active", false, quotaMeta)
}
// ImportGrokToken lands a Grok website "sso" cookie (a JWT carrying only a
// session_id) as a pending account and probes its credit balance off-thread.
// Identity is the session id (grok sso has no email/exp claim). No refresh: a
// dead session just dies (失效就失效).
func (s *TokenService) ImportGrokToken(ctx context.Context, ssoToken, tokenID string) (*model.TokenAccount, error) {
ssoToken = strings.TrimSpace(strings.TrimPrefix(ssoToken, "Bearer "))
ssoToken = strings.TrimPrefix(ssoToken, "sso=")
if ssoToken == "" {
return nil, errors.New("sso token required")
}
if !grok.IsGrokToken(ssoToken) {
return nil, errors.New("not a grok sso token")
}
sid := grok.SessionIDFromToken(ssoToken)
// Resolve the real account email up front (GET /api/auth/session) for dedup +
// display — the sso session_id rotates per login, so email is the stable id.
email := ""
if s.grok != nil {
s.applyProxy(ctx)
if e, _, ferr := s.grok.FetchSession(ctx, ssoToken); ferr == nil {
email = e
}
}
idKey := email
if idKey == "" {
idKey = sid
}
// Identity is (pool, email): reuse the row for this account, else mint.
if existing, _ := s.tokens.GetByPoolEmail(ctx, "grok", idKey); existing != nil {
tokenID = existing.ID
} else if idKey != "" || tokenID == "" {
tokenID = newTokenID("grok")
}
meta := datatypes.JSONMap{"pending_check": true}
if sid != "" {
meta["session_id"] = sid
}
item, err := s.createToken(ctx, "grok", tokenID, ssoToken, "pending", meta)
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
if item, err = s.tokens.Update(ctx, "grok", tokenID, map[string]any{
"value": ssoToken, "status": "pending", "meta": meta,
}); err != nil {
return nil, err
}
} else {
return nil, err
}
}
if idKey != "" {
if updated, uerr := s.tokens.Update(ctx, "grok", tokenID, map[string]any{"account_email": idKey}); uerr == nil {
item = updated
}
}
go s.checkPendingGrok(tokenID, ssoToken)
return item, nil
}
func (s *TokenService) checkPendingGrok(tokenID, ssoToken string) {
defer func() {
if r := recover(); r != nil {
log.Printf("token import: grok pending check panicked for %s: %v", tokenID, r)
}
}()
s.sem <- struct{}{}
defer func() { <-s.sem }()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if s.grok == nil {
s.finishPending(ctx, "grok", tokenID, "active", false, nil)
return
}
s.applyProxy(ctx)
data, err := s.grok.FetchCreditsBalance(ctx, ssoToken)
if err != nil {
if errors.Is(err, grok.ErrAuth) {
s.finishPending(ctx, "grok", tokenID, "disabled", true, nil)
return
}
s.finishPending(ctx, "grok", tokenID, "active", false, nil)
return
}
quotaMeta := map[string]any{}
if rem, ok := data["remaining"].(int); ok {
quotaMeta["cached_quota_remaining"] = rem
quotaMeta["cached_quota_at"] = int(time.Now().Unix())
}
if used, ok := data["used"].(int); ok {
quotaMeta["cached_quota_used"] = used
}
if total, ok := data["total"].(int); ok {
quotaMeta["cached_quota_total"] = total
}
if reset := strings.TrimSpace(stringValue(data["reset_after"])); reset != "" {
_, _ = s.tokens.Update(ctx, "grok", tokenID, map[string]any{"cached_quota_reset_after": reset})
}
s.finishPending(ctx, "grok", tokenID, "active", false, quotaMeta)
}
// finishPending writes the terminal status/dead flag and clears the pending_check
// marker (merging any cached quota) for a background import probe.
func (s *TokenService) finishPending(ctx context.Context, pool, id, status string, dead bool, quotaMeta map[string]any) {
@@ -1224,13 +1331,10 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
meta := cloneJSONMap(item.Meta)
meta["cached_quota_at"] = int(time.Now().Unix())
if remaining, ok := data["remaining"].(int); ok {
// Refresh only updates the displayed balance number — it never flips
// status. Out-of-credits is judged at generation time (dead/401), so a
// refresh can't sink a runway account into a revivable "quota" state.
meta["cached_quota_remaining"] = remaining
// Refreshing in account management: an account below the credit floor
// is sunk into "限额" (quota) so it stops being scheduled. Mark-down
// only — recovery is a separate (unwritten) path.
if remaining < runwayMinCredits && item.Status == "active" {
patch["status"] = "quota"
}
}
if used, ok := data["used"].(int); ok {
meta["cached_quota_used"] = used
@@ -1256,12 +1360,58 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
"error": data["error"],
}, nil
}
if poolToType(item.Pool) == "grok" && s.grok != nil {
data, err := s.grok.FetchCreditsBalance(ctx, item.Value)
if err != nil {
if errors.Is(err, grok.ErrAuth) {
_, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{
"status": "disabled",
"dead": true,
"fails": gorm.Expr("fails + 1"),
})
}
return nil, err
}
patch := map[string]any{}
meta := cloneJSONMap(item.Meta)
meta["cached_quota_at"] = int(time.Now().Unix())
if remaining, ok := data["remaining"].(int); ok {
// Refresh only updates the displayed credit number; never flips status.
// Out-of-credits is judged at generation time (dead/401, no renewal).
meta["cached_quota_remaining"] = remaining
}
if used, ok := data["used"].(int); ok {
meta["cached_quota_used"] = used
}
if total, ok := data["total"].(int); ok {
meta["cached_quota_total"] = total
}
patch["meta"] = meta
if reset := strings.TrimSpace(stringValue(data["reset_after"])); reset != "" {
patch["cached_quota_reset_after"] = reset
item.CachedQuotaResetAfter = reset
}
if updated, updateErr := s.tokens.Update(ctx, item.Pool, item.ID, patch); updateErr == nil {
item = updated
}
return map[string]any{
"supported": true,
"remaining": data["remaining"],
"used": data["used"],
"total": data["total"],
"reset_after": emptyToNil(item.CachedQuotaResetAfter),
"quota_cached_at": meta["cached_quota_at"],
"unchanged": false,
"unknown": boolValueWithDefault(data["unknown"], false),
"error": data["error"],
}, nil
}
remaining, hasRemaining := jsonMapInt(item.Meta, "cached_quota_remaining")
quotaAt, _ := jsonMapInt(item.Meta, "cached_quota_at")
typeLabel := poolToType(item.Pool)
return map[string]any{
"supported": typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway",
"remaining": valueOrNil((typeLabel == "openai" || typeLabel == "runway") && hasRemaining, remaining),
"supported": typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway" || typeLabel == "grok",
"remaining": valueOrNil((typeLabel == "openai" || typeLabel == "runway" || typeLabel == "grok") && hasRemaining, remaining),
"total": nil,
"reset_after": emptyToNil(item.CachedQuotaResetAfter),
"quota_cached_at": valueOrNil(quotaAt != 0, quotaAt),
@@ -1372,7 +1522,7 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
if item.Meta != nil {
teamID = strings.TrimSpace(stringValue(item.Meta["team_id"]))
}
hasQuota := typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway" || typeLabel == "leonardo" || typeLabel == "krea" || typeLabel == "imagine"
hasQuota := typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway" || typeLabel == "leonardo" || typeLabel == "krea" || typeLabel == "imagine" || typeLabel == "grok"
return map[string]any{
"id": item.ID,
"pool": item.Pool,