feat: Adobe积分号不锁定额度 + Nano Banana双通道 + 前端显示额度

- Adobe不再区分视频/图片额度,移除ImageLimited/VideoLimited限制
- Adobe积分号quota耗尽不锁定(status不改为quota),只记录失败用于轮转
- Nano Banana 2/Pro 保留Runway通道,新增nano-banana-2/nano-banana-pro走Adobe
- Adobe模型重命名: runway-nano-banana-2, runway-nano-banana-pro
- 前端管理后台Adobe账号显示积分余额(之前固定显示—)
- README更新模型列表
This commit is contained in:
2026-08-02 20:09:03 +08:00
parent 359d95b8c1
commit 4987717d23
8 changed files with 79 additions and 54 deletions
+2 -2
View File
@@ -130,9 +130,9 @@ It's more than an API proxy: it ships with **credit billing, CDK top-ups, referr
| Provider | Models (examples) | Type |
|---|---|---|
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · firefly-video · firefly-ray · gemini-veo31 | Image / Video |
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | Image / Video |
| **OpenAI** | gpt-image-2 | Image |
| **Runway** | runway-gen4-turbo · nano-banana-2 (Nano Banana 2) | Video / Image |
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | Video / Image |
| **Grok (grok.com)** | grok-video (imagine text/image-to-video) | Video |
| **Leonardo.ai** | seedream-4.5 | Image |
| **Krea.ai** | flux-klein-2 | Image |
+2 -2
View File
@@ -137,9 +137,9 @@
| 供应商 | 模型(示例) | 类型 |
|---|---|---|
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · firefly-video · firefly-ray · gemini-veo31 | 图像 / 视频 |
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | 图像 / 视频 |
| **OpenAI** | gpt-image-2 | 图像 |
| **Runway** | runway-gen4-turbo · nano-banana-2(Nano Banana 2) | 视频 / 图像 |
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | 视频 / 图像 |
| **Grokgrok.com** | grok-videoimagine 文生 / 图生视频) | 视频 |
| **Leonardo.ai** | seedream-4.5 | 图像 |
| **Krea.ai** | flux-klein-2 | 图像 |
@@ -453,7 +453,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"description": "Adobe Flux Kontext Max",
},
{
"id": "nano-banana-2",
"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"},
@@ -464,7 +464,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"description": "Runway Nano Banana 2 (图/参考图)",
},
{
"id": "nano-banana-pro",
"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"},
@@ -474,6 +474,28 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"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-veo31",
"provider": "adobe",
@@ -625,7 +647,7 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"stub": false,
},
{
"id": "nano-banana-2",
"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"},
@@ -634,7 +656,7 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"stub": false,
},
{
"id": "nano-banana-pro",
"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"},
@@ -642,6 +664,24 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"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-veo31",
"provider": "adobe",
@@ -52,6 +52,10 @@ func ResolveModelSpec(modelID string) modelSpec {
return modelSpec{UpstreamModelID: "gpt-image", UpstreamModelVersion: "2"}
case "flux-kontext-max":
return modelSpec{UpstreamModelID: "flux", UpstreamModelVersion: "fluxKontextMax"}
case "nano-banana-2", "runway-nano-banana-2":
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-2"}
case "nano-banana-pro", "runway-nano-banana-pro":
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-pro"}
default:
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-3"}
}
+2 -2
View File
@@ -15,7 +15,7 @@ import (
// 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: "nano-banana-2"
// 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
@@ -147,7 +147,7 @@ func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClien
"exploreMode": false,
"creationSource": "tool-mode",
}
if modelID == "nano-banana-2" {
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"
+7 -4
View File
@@ -241,8 +241,8 @@ const leonardoDailyTokens = 150
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. Adobe resets both kinds at once, so the shared
// reset time gates recovery for all of them.
// 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).
@@ -269,8 +269,11 @@ func (r *TokenRepository) RecoverQuota(ctx context.Context) ([]model.TokenAccoun
patch := map[string]any{
"fails": 0,
"quota_recover_at": nil,
"image_limited": false,
"video_limited": false,
}
// 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.
+13 -37
View File
@@ -1155,14 +1155,7 @@ func (s *V1Service) hasActiveProviderToken(ctx context.Context, provider, kind s
if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
continue
}
if provider == "adobe" {
if kind == "video" && item.VideoLimited {
continue
}
if kind == "image" && item.ImageLimited {
continue
}
}
// Adobe accounts are credit-based (积分号) — no per-kind quota locks.
return true, nil
}
return false, nil
@@ -1617,11 +1610,9 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
}
var active []model.TokenAccount
for _, item := range items {
// Image quota is tracked separately from video — an account whose video
// quota is exhausted (VideoLimited) is still usable for image as long as
// its image quota remains. status=="quota" means BOTH kinds are limited
// (or a legacy/full quota mark), so it's excluded for either kind.
if item.Status == "active" && !item.Dead && !item.ImageLimited && strings.TrimSpace(item.Value) != "" {
// Adobe accounts are credit-based (积分号) — no per-kind quota locks.
// Only skip accounts that are dead or disabled.
if item.Status == "active" && !item.Dead && strings.TrimSpace(item.Value) != "" {
active = append(active, item)
}
}
@@ -1677,12 +1668,9 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
}
var active []model.TokenAccount
for _, item := range items {
// Video quota is tracked separately from image — skip accounts whose
// video quota is exhausted (VideoLimited), but an image-only limit
// (ImageLimited) leaves the account usable for video. status=="quota"
// means BOTH kinds are limited (or a legacy/full quota mark), so it's
// excluded for either kind.
if item.Status == "active" && !item.Dead && !item.VideoLimited && strings.TrimSpace(item.Value) != "" {
// Adobe accounts are credit-based (积分号) — no per-kind quota locks.
// Only skip accounts that are dead or disabled.
if item.Status == "active" && !item.Dead && strings.TrimSpace(item.Value) != "" {
active = append(active, item)
}
}
@@ -3211,28 +3199,16 @@ func (s *V1Service) markTokenFailure(ctx context.Context, pool string, token mod
}
switch {
case isQuota:
// Adobe quota is per-kind: a video-quota error must not block image
// requests (and vice-versa). Flag only the failing kind, and only sink
// the account into the shared "quota" waiting status once BOTH kinds are
// limited. Other pools (chatgpt) are single-kind, so they go straight to
// "quota" as before.
// Adobe accounts are credit-based (积分号) — quota exhaustion is
// non-locking: track the failure for rotation but don't limit or
// sink the account. Other pools go straight to "quota" as before.
if pool == "adobe" {
imageLimited := token.ImageLimited
videoLimited := token.VideoLimited
if kind == "video" {
videoLimited = true
patch["video_limited"] = true
} else {
imageLimited = true
patch["image_limited"] = true
}
if imageLimited && videoLimited {
patch["status"] = "quota"
}
// No-op: just track fails (already patched above), leave
// image_limited/video_limited/status untouched.
} else {
patch["status"] = "quota"
}
if strings.TrimSpace(token.CachedQuotaResetAfter) == "" {
if pool != "adobe" && strings.TrimSpace(token.CachedQuotaResetAfter) == "" {
recoverAt := time.Unix((time.Now().Unix()/86400+1)*86400, 0).UTC()
patch["quota_recover_at"] = &recoverAt
}
+5 -3
View File
@@ -189,8 +189,10 @@ async function reconcile() {
row._unknown = true
updates++
} else if (result && result.unchanged === false) {
if (row.type === 'adobe') row.reset_after = result.reset_after
else applyQuota(row, result)
if (row.type === 'adobe') {
row.reset_after = result.reset_after
applyQuota(row, result)
} else applyQuota(row, result)
updates++
}
bump()
@@ -468,7 +470,7 @@ onMounted(() => { loadAccounts(); loadModelList() })
<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 -->
<span v-if="(a.type === 'openai' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine' || a.type === 'grok') && a.remaining != null && a.remaining !== -1"
<span v-if="(a.type === 'openai' || a.type === 'adobe' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine' || a.type === 'grok') && a.remaining != null && a.remaining !== -1"
class="font-mono font-semibold"
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}{{ a.type === 'grok' ? '%' : '' }}</span>
<span v-else class="text-white/25" :title="a._quotaError || ''"></span>