From 3ef84bb9b35a5263019dbd951049f207b240fa03 Mon Sep 17 00:00:00 2001 From: chiyi Date: Sun, 28 Jun 2026 21:59:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E5=AE=9A=E4=B9=89=20OpenAI=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E4=B8=8A=E6=B8=B8=E8=81=9A=E5=90=88=20+=20?= =?UTF-8?q?=E6=9D=83=E9=87=8D/=E5=B9=B6=E5=8F=91=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - custom provider:把任意 v1 端点当账号(base_url+key),按 model id 路由, 直连不走代理;支持 images/generations、images/edits、Sora 式异步视频 - 调度器全局权重优先 + 每账号并发感知(上游账号可配并发,其余系统固定) - 后台:添加/编辑上游、自定义模型表单(每档普通/代理价,留空=不支持)、 账号权重/并发列与导入权重 - grok-video / nano-banana-2(runway) 写入硬编码目录(开源自带); 去掉 540p,Adobe 视频最低 720p - UI:SelectMenu 与输入框等高、测试耗时改用秒 Co-Authored-By: Claude Opus 4.8 --- README.md | 17 +- backend/internal/bootstrap/app.go | 4 +- .../internal/http/handler/provider_admin.go | 32 ++ .../internal/http/handler/user_generation.go | 48 ++- backend/internal/http/router/router.go | 1 + backend/internal/model/models.go | 11 + backend/internal/provider/custom/client.go | 298 +++++++++++++++ backend/internal/provider/grok/client.go | 20 +- backend/internal/service/tokens.go | 67 ++++ backend/internal/service/v1.go | 339 ++++++++++++++++-- frontend/src/components/CustomModelModal.vue | 172 +++++++++ frontend/src/components/ImportModal.vue | 19 +- frontend/src/components/SelectMenu.vue | 2 +- frontend/src/components/TestModal.vue | 2 +- frontend/src/components/UpstreamModal.vue | 123 +++++++ frontend/src/views/AccountsView.vue | 52 ++- frontend/src/views/ModelsView.vue | 6 + 17 files changed, 1149 insertions(+), 64 deletions(-) create mode 100644 backend/internal/provider/custom/client.go create mode 100644 frontend/src/components/CustomModelModal.vue create mode 100644 frontend/src/components/UpstreamModal.vue 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 @@ + + + diff --git a/frontend/src/components/ImportModal.vue b/frontend/src/components/ImportModal.vue index e00e4f7..05bb19b 100644 --- a/frontend/src/components/ImportModal.vue +++ b/frontend/src/components/ImportModal.vue @@ -7,10 +7,14 @@ 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' } + // 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(() => { @@ -56,8 +60,15 @@ async function doSmartImport() { : it.type === 'imagine' ? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value })) : await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value })) - if (r.ok) ok++ - else { fail++; errs.push(`${it.type}: ${r.data?.detail || r.status}`) } + 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}`) } @@ -131,6 +142,10 @@ async function doSmartImport() { 未识别到任何 Cookie 或 JWT +
+ + +
diff --git a/frontend/src/components/SelectMenu.vue b/frontend/src/components/SelectMenu.vue index fc25919..266dc5d 100644 --- a/frontend/src/components/SelectMenu.vue +++ b/frontend/src/components/SelectMenu.vue @@ -73,7 +73,7 @@ onUnmounted(() => document.removeEventListener('mousedown', onDocClick)) + -
+
加载中…
@@ -374,18 +388,20 @@ onMounted(loadAccounts)
- +
+ + - + @@ -396,6 +412,8 @@ onMounted(loadAccounts) + + @@ -445,6 +463,20 @@ onMounted(loadAccounts) :class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}{{ a.type === 'grok' ? '%' : '' }} + + + + - @@ -520,6 +557,7 @@ onMounted(loadAccounts) + diff --git a/frontend/src/views/ModelsView.vue b/frontend/src/views/ModelsView.vue index cae4de6..1bc076e 100644 --- a/frontend/src/views/ModelsView.vue +++ b/frontend/src/views/ModelsView.vue @@ -3,12 +3,14 @@ import { ref, computed, onMounted } from 'vue' import { api } from '../api' import Icon from '../components/Icon.vue' import ModelFormModal from '../components/ModelFormModal.vue' +import CustomModelModal from '../components/CustomModelModal.vue' import TestModal from '../components/TestModal.vue' import { points } from '../credits' const models = ref([]) const loading = ref(false) const showForm = ref(false) +const showCustom = ref(false) const editing = ref(null) // null = add, object = edit const testing = ref(null) // model being tested, or null @@ -120,6 +122,9 @@ onMounted(loadModels) + @@ -268,6 +273,7 @@ onMounted(loadModels) +
账户 类型 额度权重并发 恢复时间 创建时间 最后使用 + + + + {{ a.type === 'grok' ? 10 : 1 }} +
@@ -494,10 +526,15 @@ onMounted(loadAccounts)
- + +
+ + +