diff --git a/README.md b/README.md
index 254ad3b..88420b4 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@
## ✨ 简介
-**image2api** 把 Adobe Firefly、OpenAI、Runway、Grok、Leonardo、Krea、Imagine 等平台的图像 / 视频能力,统一封装成**一套 OpenAI 兼容的 API**;背后用多账号池自动调度 —— 额度耗尽自动换号、认证失效自动刷新或判死、临时错误自动重试、token 到期前主动续期 —— 对外提供稳定服务。
+**image2api** 把 Adobe Firefly、OpenAI、Runway、Grok、Leonardo、Krea、Imagine 等平台,以及**任意 OpenAI 兼容上游**的图像 / 视频能力,统一封装成**一套 OpenAI 兼容的 API**;背后用多账号池自动调度 —— 权重优先 + 并发感知、额度耗尽自动换号、认证失效自动刷新或判死、临时错误自动重试、token 到期前主动续期 —— 对外提供稳定服务。
它不只是 API 代理:自带**积分计费、CDK 充值、邀请奖励、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)**(品牌)即基于本项目搭建。
@@ -74,10 +74,17 @@
- 图片结果 **base64 直返**,服务端不留存文件,隐私友好
#### 🔁 多账号池 + 智能故障转移
-- 账号池轮询调度,单账号出错不影响整体
+- 账号池调度,单账号出错不影响整体
+- **权重优先 + 并发感知**:按账号权重从高到低调度,某账号并发满了才轮到下一个;同权重组内 round-robin 均摊。每账号并发数可配(上游账号),其余系统固定
- **额度耗尽→换号** · **认证失效→刷新重试 / 判死** · **临时错误→同号重试 ×3** · **参数错→直接报错**
- **预扣额度**:生成前原子扣减,失败自动退回,杜绝并发超额
+#### 🔗 自定义上游聚合(OpenAI 兼容)
+- 把任意 **OpenAI 兼容的 v1 端点**当成一个账号接入(填 `base_url` + `key`),无需写代码
+- **按 model id 自动路由**:上游声明支持哪些 id,生成该 id 时即走对应上游(可覆盖内置 provider);id 留空 = 全部
+- 模型管理里自由新建自定义模型(id / 类型 / 比例 / 分辨率·价 / 时长·价 / 参考图),按本地价计费
+- 调用**直连不走代理**;上游可配权重与并发,与内置池统一调度
+
#### 🔐 Token 自动保活
- 一次性轮换 token(Krea / Imagine)**到期前 10 分钟主动续期**,新 token 自动落库
- Adobe cookie 定时换 token;纯 JWT 到期自动判死
@@ -110,8 +117,9 @@
| **Leonardo.ai** | seedream-4.5 | 图像 |
| **Krea.ai** | flux-klein-2 | 图像 |
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | 图像 |
+| **自定义上游** | 任意 OpenAI 兼容 v1 端点(按 id 路由) | 图像 / 视频 |
-> 模型由管理后台动态启用并定价,可随时增删。
+> 模型由管理后台动态启用并定价,可随时增删。自定义上游支持把任何 OpenAI 兼容服务接成账号,按 model id 路由调用。
## 🔌 OpenAI 兼容 API
@@ -238,7 +246,8 @@ backend/ 后端源码(Go)
│ │ ├── grok/ Grok(grok.com,statsig 伪造,视频)
│ │ ├── leonardo/ Leonardo
│ │ ├── krea/ Krea
-│ │ └── imagine/ Imagine.art
+│ │ ├── imagine/ Imagine.art
+│ │ └── custom/ 自定义上游(OpenAI 兼容 v1,按 id 路由,直连不走代理)
│ ├── repo/ 数据访问层(用户 / 模型 / 账号 / 日志 / CDK…)
│ ├── service/ 业务逻辑(生成调度、计费、账号池、保活、维护)
│ └── storage/ RustFS / S3 媒体存储
diff --git a/backend/internal/bootstrap/app.go b/backend/internal/bootstrap/app.go
index c9b4d15..b4c191b 100644
--- a/backend/internal/bootstrap/app.go
+++ b/backend/internal/bootstrap/app.go
@@ -12,6 +12,7 @@ import (
"backend/internal/model"
"backend/internal/provider/adobe"
"backend/internal/provider/chatgpt"
+ "backend/internal/provider/custom"
"backend/internal/provider/grok"
"backend/internal/provider/imagine"
"backend/internal/provider/krea"
@@ -110,7 +111,8 @@ func NewApp(ctx context.Context) (*App, error) {
kreaClient := krea.NewClient("")
imagineClient := imagine.NewClient("")
grokClient := grok.NewClient("")
- v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, rustfsClient)
+ customClient := custom.NewClient()
+ v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, rustfsClient)
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
showcaseSvc := service.NewShowcaseService(showcaseRepo)
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient)
diff --git a/backend/internal/http/handler/provider_admin.go b/backend/internal/http/handler/provider_admin.go
index e7000ff..2311465 100644
--- a/backend/internal/http/handler/provider_admin.go
+++ b/backend/internal/http/handler/provider_admin.go
@@ -132,6 +132,38 @@ func (h *ProviderAdminHandler) ImportGrokToken(c *gin.Context) {
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"`
diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go
index 210dc1e..33b2379 100644
--- a/backend/internal/http/handler/user_generation.go
+++ b/backend/internal/http/handler/user_generation.go
@@ -370,13 +370,15 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"description": "Adobe Flux Kontext Max",
},
{
- "id": "nano-banana-2",
- "provider": "adobe",
- "type": "image",
- "ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
- "resolutions": []string{"1K"},
- "image_to_image": true,
- "description": "Adobe Gemini Flash Nano Banana",
+ "id": "nano-banana-2",
+ "provider": "runway",
+ "type": "image",
+ "ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "3:2", "5:4", "4:5", "2:3"},
+ "resolutions": []string{"1K", "2K", "4K"},
+ "image_to_image": true,
+ "max_reference_images": 6,
+ "reference_mode": "asset",
+ "description": "Runway Nano Banana 2 (图/参考图)",
},
{
"id": "gemini-veo31",
@@ -405,7 +407,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "1:1", "9:16"},
- "resolutions": []string{"540p", "720p", "1080p"},
+ "resolutions": []string{"720p", "1080p"},
"durations": []string{"5s"},
"max_reference_images": 2,
"reference_mode": "frame",
@@ -422,6 +424,17 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"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"},
+ "durations": []string{"6s", "10s"},
+ "max_reference_images": 6,
+ "reference_mode": "asset",
+ "description": "Grok Imagine video (文/图生视频)",
+ },
{
"id": "seedream-4.5",
"provider": "leonardo",
@@ -519,11 +532,11 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
},
{
"id": "nano-banana-2",
- "provider": "adobe",
+ "provider": "runway",
"kind": "image",
- "ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
- "resolutions": []string{"1K"},
- "description": "Adobe Gemini Flash Nano Banana",
+ "ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "3:2", "5:4", "4:5", "2:3"},
+ "resolutions": []string{"1K", "2K", "4K"},
+ "description": "Runway Nano Banana 2",
"stub": false,
},
{
@@ -549,7 +562,7 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"provider": "adobe",
"kind": "video",
"ratios": []string{"16:9", "1:1", "9:16"},
- "resolutions": []string{"540p", "720p", "1080p"},
+ "resolutions": []string{"720p", "1080p"},
"description": "Adobe Firefly Video",
"stub": false,
},
@@ -562,6 +575,15 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"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": "seedream-4.5",
"provider": "leonardo",
diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go
index 8df433d..997cb6f 100644
--- a/backend/internal/http/router/router.go
+++ b/backend/internal/http/router/router.go
@@ -109,6 +109,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
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)
diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go
index 597d0e2..4e5fa0b 100644
--- a/backend/internal/model/models.go
+++ b/backend/internal/model/models.go
@@ -103,6 +103,10 @@ type ModelConfig struct {
Durations datatypes.JSON `gorm:"type:jsonb"`
MaxReferenceImages int `gorm:"not null;default:0"`
ReferenceMode string `gorm:"size:32;not null;default:'none'"`
+ // Custom-upstream models (provider="custom"): UpstreamModel is the model name
+ // sent to the upstream OpenAI-compatible API; the base_url + key live on the
+ // matching custom account (pool="custom", meta.base_url). Empty for built-ins.
+ UpstreamModel string `gorm:"size:255;not null;default:''"`
// Weight controls display order in the model dropdown / admin list: higher
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
// back to created_at desc. Default 0.
@@ -146,6 +150,13 @@ type TokenAccount struct {
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"`
CreatedAt time.Time
UpdatedAt time.Time
}
diff --git a/backend/internal/provider/custom/client.go b/backend/internal/provider/custom/client.go
new file mode 100644
index 0000000..146f673
--- /dev/null
+++ b/backend/internal/provider/custom/client.go
@@ -0,0 +1,298 @@
+// 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/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "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{} }
+
+func httpClient() *http.Client { return &http.Client{Timeout: 10 * 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) ([]byte, 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)
+ 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}
+ 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: %v", ErrTemporaryUpstream, err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if e := mapStatus(resp.StatusCode, body); e != nil {
+ return nil, e
+ }
+ return imageBytesFromResponse(ctx, body)
+}
+
+// GenerateVideo drives the upstream Sora-style async video API:
+// POST /v1/videos → poll GET /v1/videos/{id} → GET /v1/videos/{id}/content.
+// 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, downloadResult bool) ([]byte, string, error) {
+ baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
+ if baseURL == "" || apiKey == "" {
+ return nil, "", ErrAuth
+ }
+ 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", ErrQuotaExhausted, 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: %v", ErrTemporaryUpstream, 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: %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, 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).
+func imageBytesFromResponse(ctx context.Context, body []byte) ([]byte, error) {
+ var out struct {
+ Data []struct {
+ B64JSON string `json:"b64_json"`
+ 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))
+ }
+ d := out.Data[0]
+ if d.B64JSON != "" {
+ raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(d.B64JSON))
+ if err != nil {
+ return nil, fmt.Errorf("%w: bad b64: %v", ErrTemporaryUpstream, err)
+ }
+ return raw, nil
+ }
+ if d.URL != "" {
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, d.URL, nil)
+ resp, err := httpClient().Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
+ }
+ defer resp.Body.Close()
+ return io.ReadAll(resp.Body)
+ }
+ return nil, fmt.Errorf("%w: image response had no b64/url", ErrTemporaryUpstream)
+}
+
+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:
+ return fmt.Errorf("%w: 429 %s", ErrQuotaExhausted, 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", ErrQuotaExhausted, 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
+ }
+}
diff --git a/backend/internal/provider/grok/client.go b/backend/internal/provider/grok/client.go
index 9df3526..6aa95e3 100644
--- a/backend/internal/provider/grok/client.go
+++ b/backend/internal/provider/grok/client.go
@@ -290,29 +290,29 @@ func parseCreditsConfig(buf []byte) (remaining int, resetUnix string, ok bool) {
return 0, "", false
}
-func scanConfigMessage(msg []byte) (remaining int, resetUnix string, ok bool) {
- var remF float32
- haveRem := 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 remaining credits
- remF = float32FromLE(val)
- haveRem = true
+ 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)
}
}
}
- if haveRem {
- return int(remF), resetUnix, true
- }
- return 0, resetUnix, false
+ // 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).
diff --git a/backend/internal/service/tokens.go b/backend/internal/service/tokens.go
index 681e595..51cb4c7 100644
--- a/backend/internal/service/tokens.go
+++ b/backend/internal/service/tokens.go
@@ -34,6 +34,7 @@ var validTokenPools = map[string]string{
"krea": "krea",
"imagine": "imagine",
"grok": "grok",
+ "custom": "custom",
}
type TokenService struct {
@@ -947,6 +948,68 @@ func (s *TokenService) checkPendingGrok(tokenID, ssoToken string) {
s.finishPending(ctx, "grok", tokenID, "active", false, quotaMeta)
}
+// ImportCustomAccount adds an upstream as a custom account: base_url + key, the
+// csv list of model ids it serves (empty = all), plus optional weight and
+// per-account concurrency. No probe — the account goes active immediately and is
+// matched to custom models by id at generation time. Calls go direct (no proxy).
+func (s *TokenService) ImportCustomAccount(ctx context.Context, baseURL, apiKey, models, name string, weight, concurrency int, tokenID string) (*model.TokenAccount, error) {
+ baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
+ apiKey = strings.TrimSpace(apiKey)
+ // Edit mode: tokenID points at an existing custom account. base_url required;
+ // a blank key keeps the stored one.
+ if strings.TrimSpace(tokenID) != "" {
+ existing, gerr := s.tokens.Get(ctx, "custom", tokenID)
+ if gerr != nil {
+ return nil, gerr
+ }
+ if baseURL == "" {
+ return nil, errors.New("base_url required")
+ }
+ meta := datatypes.JSONMap{"base_url": baseURL}
+ if m := strings.TrimSpace(models); m != "" {
+ meta["models"] = m
+ }
+ patch := map[string]any{"meta": meta, "weight": weight, "concurrency": concurrency, "account_email": strings.TrimSpace(name)}
+ if apiKey != "" {
+ patch["value"] = apiKey
+ }
+ item, uerr := s.tokens.Update(ctx, "custom", tokenID, patch)
+ if uerr != nil {
+ return nil, uerr
+ }
+ _ = existing
+ return item, nil
+ }
+ if baseURL == "" || apiKey == "" {
+ return nil, errors.New("base_url and key required")
+ }
+ meta := datatypes.JSONMap{"base_url": baseURL}
+ if m := strings.TrimSpace(models); m != "" {
+ meta["models"] = m
+ }
+ tokenID = newTokenID("custom")
+ item, err := s.createToken(ctx, "custom", tokenID, apiKey, "active", meta)
+ if err != nil {
+ return nil, err
+ }
+ patch := map[string]any{}
+ if strings.TrimSpace(name) != "" {
+ patch["account_email"] = strings.TrimSpace(name)
+ }
+ if weight != 0 {
+ patch["weight"] = weight
+ }
+ if concurrency > 0 {
+ patch["concurrency"] = concurrency
+ }
+ if len(patch) > 0 {
+ if updated, uerr := s.tokens.Update(ctx, "custom", tokenID, patch); uerr == nil {
+ item = updated
+ }
+ }
+ return item, nil
+}
+
// 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) {
@@ -1546,6 +1609,10 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
"pending": pending,
"quota_supported": hasQuota,
"needs_reset_fetch": typeLabel == "adobe" && item.Status == "active" && strings.TrimSpace(item.CachedQuotaResetAfter) == "",
+ "weight": item.Weight,
+ "concurrency": item.Concurrency,
+ "base_url": emptyToNil(strings.TrimSpace(stringValue(item.Meta["base_url"]))),
+ "models": strings.TrimSpace(stringValue(item.Meta["models"])),
}
}
diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go
index 05c9f34..dc0d873 100644
--- a/backend/internal/service/v1.go
+++ b/backend/internal/service/v1.go
@@ -15,10 +15,13 @@ import (
"sync/atomic"
"time"
+ "strconv"
+
"backend/internal/config"
"backend/internal/model"
"backend/internal/provider/adobe"
"backend/internal/provider/chatgpt"
+ "backend/internal/provider/custom"
"backend/internal/provider/grok"
"backend/internal/provider/imagine"
"backend/internal/provider/krea"
@@ -70,6 +73,7 @@ type V1Service struct {
krea *krea.Client
imagine *imagine.Client
grok *grok.Client
+ custom *custom.Client
store *storage.Client
// refresh re-mints an Adobe access token from its cookie when a request hits a
// 401 mid-flight (set via SetRefresh — wired after construction to avoid an
@@ -195,7 +199,7 @@ type V1VideoRequest struct {
BaseURL string
}
-func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client, store *storage.Client) *V1Service {
+func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client, customClient *custom.Client, store *storage.Client) *V1Service {
return &V1Service{
cfg: cfg,
models: models,
@@ -210,6 +214,7 @@ func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.
krea: kreaClient,
imagine: imagineClient,
grok: grokClient,
+ custom: customClient,
store: store,
inflight: &InflightRegistry{},
}
@@ -352,7 +357,7 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
startedAt := time.Now()
var imageBytes []byte
- switch modelItem.Provider {
+ switch s.effectiveProvider(genCtx, modelItem) {
case "adobe":
b, execErr := s.generateAdobeImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
if execErr != nil {
@@ -455,6 +460,23 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
}
}
imageBytes = b
+ case "custom":
+ b, execErr := s.generateCustomImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
+ if execErr != nil {
+ _ = s.refundIfNeeded(ctx, principal, eventID, price)
+ _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
+ switch {
+ case errors.Is(execErr, custom.ErrAuth):
+ return nil, ErrProviderAuth
+ case errors.Is(execErr, custom.ErrQuotaExhausted):
+ return nil, ErrProviderQuota
+ case errors.Is(execErr, custom.ErrTemporaryUpstream):
+ return nil, ErrProviderTemporary
+ default:
+ return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr)
+ }
+ }
+ imageBytes = b
default:
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
@@ -552,13 +574,15 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
var videoBytes []byte
var execErr error
- switch modelItem.Provider {
+ switch s.effectiveProvider(genCtx, modelItem) {
case "adobe":
videoBytes, _, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true)
case "runway":
videoBytes, _, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), true)
case "grok":
videoBytes, _, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true)
+ case "custom":
+ videoBytes, _, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true)
default:
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
@@ -570,11 +594,11 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
switch {
case errors.Is(execErr, ErrNoProviderAccount):
return nil, ErrNoProviderAccount
- case errors.Is(execErr, adobe.ErrAuth), errors.Is(execErr, runway.ErrAuth), errors.Is(execErr, grok.ErrAuth):
+ case errors.Is(execErr, adobe.ErrAuth), errors.Is(execErr, runway.ErrAuth), errors.Is(execErr, grok.ErrAuth), errors.Is(execErr, custom.ErrAuth):
return nil, ErrProviderAuth
- case errors.Is(execErr, adobe.ErrQuotaExhausted), errors.Is(execErr, runway.ErrQuotaExhausted), errors.Is(execErr, grok.ErrQuotaExhausted):
+ case errors.Is(execErr, adobe.ErrQuotaExhausted), errors.Is(execErr, runway.ErrQuotaExhausted), errors.Is(execErr, grok.ErrQuotaExhausted), errors.Is(execErr, custom.ErrQuotaExhausted):
return nil, ErrProviderQuota
- case errors.Is(execErr, adobe.ErrTemporaryUpstream), errors.Is(execErr, runway.ErrTemporaryUpstream), errors.Is(execErr, grok.ErrTemporaryUpstream):
+ case errors.Is(execErr, adobe.ErrTemporaryUpstream), errors.Is(execErr, runway.ErrTemporaryUpstream), errors.Is(execErr, grok.ErrTemporaryUpstream), errors.Is(execErr, custom.ErrTemporaryUpstream):
return nil, ErrProviderTemporary
default:
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr)
@@ -658,13 +682,15 @@ func (s *V1Service) runVideoJob(ctx context.Context, principal *APIPrincipal, in
var videoURL string
var execErr error
- switch modelItem.Provider {
+ switch s.effectiveProvider(genCtx, modelItem) {
case "adobe":
_, videoURL, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
case "runway":
_, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), false)
case "grok":
_, videoURL, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
+ case "custom":
+ _, videoURL, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
default:
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
@@ -859,11 +885,16 @@ func (s *V1Service) prepareImage(ctx context.Context, principal *APIPrincipal, i
if !modelItem.Enabled || modelItem.Type != "image" {
return nil, "", "", 0, ErrUnknownModel
}
- // Fail fast before charging if the provider has no usable account.
- if ok, err := s.hasActiveProviderToken(ctx, modelItem.Provider, "image"); err != nil {
- return nil, "", "", 0, err
- } else if !ok {
- return nil, "", "", 0, ErrNoProviderAccount
+ // Fail fast before charging if the provider has no usable account. Use the
+ // effective provider: a custom upstream serving this model id routes to
+ // "custom" (effectiveProvider only returns it when such an account exists, so
+ // the precheck is satisfied); otherwise check the native provider pool.
+ if eff := s.effectiveProvider(ctx, modelItem); eff != "custom" {
+ if ok, err := s.hasActiveProviderToken(ctx, eff, "image"); err != nil {
+ return nil, "", "", 0, err
+ } else if !ok {
+ return nil, "", "", 0, ErrNoProviderAccount
+ }
}
refLimit := 0
if modelItem.ImageToImage {
@@ -921,8 +952,10 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
if !modelItem.Enabled || modelItem.Type != "video" {
return nil, "", "", "", 0, ErrUnknownModel
}
- // Fail fast before charging if the provider has no usable account.
- if ok, err := s.hasActiveProviderToken(ctx, modelItem.Provider, "video"); err != nil {
+ // Fail fast before charging — effective provider (custom upstream by id, else native).
+ if eff := s.effectiveProvider(ctx, modelItem); eff == "custom" {
+ // custom serves this id (effectiveProvider guaranteed it) — precheck ok
+ } else if ok, err := s.hasActiveProviderToken(ctx, eff, "video"); err != nil {
return nil, "", "", "", 0, err
} else if !ok {
return nil, "", "", "", 0, ErrNoProviderAccount
@@ -1534,6 +1567,246 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
return nil, "", lastErr
}
+// customAccountServes reports whether a custom (upstream) account is usable for a
+// given model id: active, not dead, has a base_url, and its meta.models list (csv
+// of model ids it serves) contains the id. An empty models list serves ALL ids.
+func customAccountServes(item model.TokenAccount, modelID string) bool {
+ if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
+ return false
+ }
+ if item.Meta == nil || strings.TrimSpace(stringValue(item.Meta["base_url"])) == "" {
+ return false
+ }
+ list := strings.TrimSpace(stringValue(item.Meta["models"]))
+ if list == "" {
+ return true
+ }
+ for _, m := range strings.Split(list, ",") {
+ if strings.EqualFold(strings.TrimSpace(m), modelID) {
+ return true
+ }
+ }
+ return false
+}
+
+// customActive returns the custom accounts that serve modelID, ordered by weight
+// (higher first; ties by id) so heavier upstreams are preferred.
+func (s *V1Service) customActive(ctx context.Context, modelID string) ([]model.TokenAccount, error) {
+ items, err := s.tokens.ListByPool(ctx, "custom")
+ if err != nil {
+ return nil, err
+ }
+ var active []model.TokenAccount
+ for _, item := range items {
+ if customAccountServes(item, modelID) {
+ active = append(active, item)
+ }
+ }
+ s.rotateRoundRobin("custom", active) // weight priority + round-robin within ties
+ return active, nil
+}
+
+// accountConcurrency is the per-account simultaneous-job cap. Custom accounts use
+// their configured Concurrency (default 1); built-in pools use the system value.
+func accountConcurrency(item model.TokenAccount) int {
+ if item.Concurrency > 0 {
+ return item.Concurrency
+ }
+ return 1
+}
+
+// effectiveProvider routes a model to the "custom" upstream whenever a custom
+// account declares it serves that model id (id-based override of the model's
+// native provider) — so an upstream can take over any model by matching its id.
+// Otherwise the model's own provider is used.
+func (s *V1Service) effectiveProvider(ctx context.Context, modelItem *model.ModelConfig) string {
+ if s.custom != nil {
+ if active, err := s.customActive(ctx, modelItem.ID); err == nil && len(active) > 0 {
+ return "custom"
+ }
+ }
+ return modelItem.Provider
+}
+
+// generateCustomImage forwards an image generation to an OpenAI-compatible
+// upstream. The upstream (custom account) is matched by model id; calls go direct
+// (no proxy). Billing uses the local model price.
+func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) {
+ if s.custom == nil {
+ return nil, errors.New("custom client not configured")
+ }
+ refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
+ if err != nil {
+ return nil, err
+ }
+ active, err := s.customActive(ctx, modelItem.ID)
+ if err != nil {
+ return nil, err
+ }
+ if len(active) == 0 {
+ return nil, ErrNoProviderAccount
+ }
+ size := upstreamSize(aspectRatio, resolution)
+ quality := upstreamQuality(resolution)
+ var lastErr error
+ busy := 0
+ for _, token := range active {
+ if !s.gate.tryAcquireN(token.ID, accountConcurrency(token)) {
+ busy++
+ continue
+ }
+ var data []byte
+ done, failover := func() (bool, bool) {
+ defer s.gate.release(token.ID)
+ _ = s.events.SetAccount(ctx, eventID, token.ID)
+ _ = s.tokens.TouchLastUsed(ctx, token.ID)
+ baseURL := stringValue(token.Meta["base_url"])
+ d, genErr := s.custom.GenerateImage(ctx, baseURL, token.Value, modelItem.ID, in.Prompt, size, quality, refs)
+ if genErr == nil {
+ _, _ = s.tokens.Update(ctx, "custom", token.ID, map[string]any{
+ "last_used_at": time.Now(), "success_total": gorm.Expr("success_total + 1"), "fails": 0,
+ })
+ data = d
+ return true, false
+ }
+ lastErr = genErr
+ switch {
+ case errors.Is(genErr, custom.ErrAuth):
+ s.markTokenFailure(ctx, "custom", token, "image", true, false)
+ return false, true
+ case errors.Is(genErr, custom.ErrQuotaExhausted):
+ s.markTokenFailure(ctx, "custom", token, "image", false, true)
+ return false, true
+ case errors.Is(genErr, custom.ErrTemporaryUpstream):
+ return false, true
+ default:
+ return false, false
+ }
+ }()
+ if done {
+ return data, nil
+ }
+ if failover {
+ continue
+ }
+ return nil, lastErr
+ }
+ if lastErr == nil {
+ if busy > 0 {
+ return nil, ErrConcurrencyFull
+ }
+ lastErr = ErrProviderExecution
+ }
+ return nil, lastErr
+}
+
+// generateCustomVideo forwards a video generation to an OpenAI-compatible
+// (Sora-style) upstream, matched by model id. No proxy; local-price billing.
+func (s *V1Service) generateCustomVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio, resolution string, durationSeconds int, downloadResult bool) ([]byte, string, error) {
+ if s.custom == nil {
+ return nil, "", errors.New("custom client not configured")
+ }
+ active, err := s.customActive(ctx, modelItem.ID)
+ if err != nil {
+ return nil, "", err
+ }
+ if len(active) == 0 {
+ return nil, "", ErrNoProviderAccount
+ }
+ size := upstreamSize(aspectRatio, resolution)
+ var lastErr error
+ var videoURL string
+ busy := 0
+ for _, token := range active {
+ if !s.gate.tryAcquireN(token.ID, accountConcurrency(token)) {
+ busy++
+ continue
+ }
+ var data []byte
+ done, failover := func() (bool, bool) {
+ defer s.gate.release(token.ID)
+ _ = s.events.SetAccount(ctx, eventID, token.ID)
+ _ = s.tokens.TouchLastUsed(ctx, token.ID)
+ baseURL := stringValue(token.Meta["base_url"])
+ d, url, genErr := s.custom.GenerateVideo(ctx, baseURL, token.Value, modelItem.ID, in.Prompt, size, durationSeconds, downloadResult)
+ if genErr == nil {
+ _, _ = s.tokens.Update(ctx, "custom", token.ID, map[string]any{
+ "last_used_at": time.Now(), "success_total": gorm.Expr("success_total + 1"), "fails": 0,
+ })
+ data = d
+ videoURL = url
+ return true, false
+ }
+ lastErr = genErr
+ switch {
+ case errors.Is(genErr, custom.ErrAuth):
+ s.markTokenFailure(ctx, "custom", token, "video", true, false)
+ return false, true
+ case errors.Is(genErr, custom.ErrQuotaExhausted):
+ s.markTokenFailure(ctx, "custom", token, "video", false, true)
+ return false, true
+ case errors.Is(genErr, custom.ErrTemporaryUpstream):
+ return false, true
+ default:
+ return false, false
+ }
+ }()
+ if done {
+ return data, videoURL, nil
+ }
+ if failover {
+ continue
+ }
+ return nil, "", lastErr
+ }
+ if lastErr == nil {
+ if busy > 0 {
+ return nil, "", ErrConcurrencyFull
+ }
+ lastErr = ErrProviderExecution
+ }
+ return nil, "", lastErr
+}
+
+// upstreamSize maps our (ratio, resolution) to an OpenAI-style "WxH" size string
+// for the upstream. The pixel base scales with the tier (1K/2K/4K); the ratio
+// sets the shape. Upstreams that key off ratio (our own /v1) read it fine.
+func upstreamSize(aspectRatio, resolution string) string {
+ base := 1024
+ switch strings.ToUpper(strings.TrimSpace(resolution)) {
+ case "2K":
+ base = 2048
+ case "4K":
+ base = 4096
+ }
+ w, h := 1, 1
+ parts := strings.Split(strings.ReplaceAll(strings.TrimSpace(aspectRatio), "x", ":"), ":")
+ if len(parts) == 2 {
+ if a, e1 := strconv.Atoi(strings.TrimSpace(parts[0])); e1 == nil && a > 0 {
+ if b, e2 := strconv.Atoi(strings.TrimSpace(parts[1])); e2 == nil && b > 0 {
+ w, h = a, b
+ }
+ }
+ }
+ if w >= h {
+ return fmt.Sprintf("%dx%d", base, base*h/w)
+ }
+ return fmt.Sprintf("%dx%d", base*w/h, base)
+}
+
+// upstreamQuality maps a resolution tier to the OpenAI quality enum.
+func upstreamQuality(resolution string) string {
+ switch strings.ToUpper(strings.TrimSpace(resolution)) {
+ case "2K":
+ return "medium"
+ case "4K":
+ return "high"
+ case "1K":
+ return "low"
+ }
+ return ""
+}
+
// generateGrokVideo runs grok's imagine video pipeline across the grok pool.
// Mirrors the runway policy: no pre-deduct, skip accounts known out of credits
// (cached remaining <= 0), and treat an out-of-credits / auth failure as a dead
@@ -2567,19 +2840,35 @@ func (s *V1Service) nextCursor(pool string) uint64 {
// retry chain is preserved — on failure the caller's loop simply continues to
// the next account in rotation order.
func (s *V1Service) rotateRoundRobin(pool string, items []model.TokenAccount) {
+ if len(items) <= 1 {
+ return
+ }
+ // Weight = priority: higher-weight accounts come first, so the scheduler tries
+ // them before lower-weight ones (and only falls through when they're at their
+ // concurrency cap). Within the SAME weight all accounts are equal, so they're
+ // rotated by the pool cursor for even distribution.
sort.SliceStable(items, func(i, j int) bool {
+ if items[i].Weight != items[j].Weight {
+ return items[i].Weight > items[j].Weight
+ }
return items[i].ID < items[j].ID
})
- n := len(items)
- if n <= 1 {
- return
+ start := int(s.nextCursor(pool))
+ for i := 0; i < len(items); {
+ j := i + 1
+ for j < len(items) && items[j].Weight == items[i].Weight {
+ j++
+ }
+ if g := j - i; g > 1 {
+ off := start % g
+ if off != 0 {
+ grp := items[i:j]
+ rot := make([]model.TokenAccount, 0, g)
+ rot = append(rot, grp[off:]...)
+ rot = append(rot, grp[:off]...)
+ copy(grp, rot)
+ }
+ }
+ i = j
}
- start := int(s.nextCursor(pool) % uint64(n))
- if start == 0 {
- return
- }
- rotated := make([]model.TokenAccount, 0, n)
- rotated = append(rotated, items[start:]...)
- rotated = append(rotated, items[:start]...)
- copy(items, rotated)
}
diff --git a/frontend/src/components/CustomModelModal.vue b/frontend/src/components/CustomModelModal.vue
new file mode 100644
index 0000000..37e1b40
--- /dev/null
+++ b/frontend/src/components/CustomModelModal.vue
@@ -0,0 +1,172 @@
+
+
+
+
+ id 要与上游模型名一致 —— 生成时按 id 自动路由到「支持该 id 的上游账号」。价格按本地价计费。
+ {{ error }}添加自定义模型(上游 / provider=custom)
+
+