feat: 自定义 OpenAI 兼容上游聚合 + 权重/并发调度
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)**(品牌)即基于本项目搭建。
|
它不只是 API 代理:自带**积分计费、CDK 充值、邀请奖励、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)**(品牌)即基于本项目搭建。
|
||||||
|
|
||||||
@@ -74,10 +74,17 @@
|
|||||||
- 图片结果 **base64 直返**,服务端不留存文件,隐私友好
|
- 图片结果 **base64 直返**,服务端不留存文件,隐私友好
|
||||||
|
|
||||||
#### 🔁 多账号池 + 智能故障转移
|
#### 🔁 多账号池 + 智能故障转移
|
||||||
- 账号池轮询调度,单账号出错不影响整体
|
- 账号池调度,单账号出错不影响整体
|
||||||
|
- **权重优先 + 并发感知**:按账号权重从高到低调度,某账号并发满了才轮到下一个;同权重组内 round-robin 均摊。每账号并发数可配(上游账号),其余系统固定
|
||||||
- **额度耗尽→换号** · **认证失效→刷新重试 / 判死** · **临时错误→同号重试 ×3** · **参数错→直接报错**
|
- **额度耗尽→换号** · **认证失效→刷新重试 / 判死** · **临时错误→同号重试 ×3** · **参数错→直接报错**
|
||||||
- **预扣额度**:生成前原子扣减,失败自动退回,杜绝并发超额
|
- **预扣额度**:生成前原子扣减,失败自动退回,杜绝并发超额
|
||||||
|
|
||||||
|
#### 🔗 自定义上游聚合(OpenAI 兼容)
|
||||||
|
- 把任意 **OpenAI 兼容的 v1 端点**当成一个账号接入(填 `base_url` + `key`),无需写代码
|
||||||
|
- **按 model id 自动路由**:上游声明支持哪些 id,生成该 id 时即走对应上游(可覆盖内置 provider);id 留空 = 全部
|
||||||
|
- 模型管理里自由新建自定义模型(id / 类型 / 比例 / 分辨率·价 / 时长·价 / 参考图),按本地价计费
|
||||||
|
- 调用**直连不走代理**;上游可配权重与并发,与内置池统一调度
|
||||||
|
|
||||||
#### 🔐 Token 自动保活
|
#### 🔐 Token 自动保活
|
||||||
- 一次性轮换 token(Krea / Imagine)**到期前 10 分钟主动续期**,新 token 自动落库
|
- 一次性轮换 token(Krea / Imagine)**到期前 10 分钟主动续期**,新 token 自动落库
|
||||||
- Adobe cookie 定时换 token;纯 JWT 到期自动判死
|
- Adobe cookie 定时换 token;纯 JWT 到期自动判死
|
||||||
@@ -110,8 +117,9 @@
|
|||||||
| **Leonardo.ai** | seedream-4.5 | 图像 |
|
| **Leonardo.ai** | seedream-4.5 | 图像 |
|
||||||
| **Krea.ai** | flux-klein-2 | 图像 |
|
| **Krea.ai** | flux-klein-2 | 图像 |
|
||||||
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | 图像 |
|
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | 图像 |
|
||||||
|
| **自定义上游** | 任意 OpenAI 兼容 v1 端点(按 id 路由) | 图像 / 视频 |
|
||||||
|
|
||||||
> 模型由管理后台动态启用并定价,可随时增删。
|
> 模型由管理后台动态启用并定价,可随时增删。自定义上游支持把任何 OpenAI 兼容服务接成账号,按 model id 路由调用。
|
||||||
|
|
||||||
## 🔌 OpenAI 兼容 API
|
## 🔌 OpenAI 兼容 API
|
||||||
|
|
||||||
@@ -238,7 +246,8 @@ backend/ 后端源码(Go)
|
|||||||
│ │ ├── grok/ Grok(grok.com,statsig 伪造,视频)
|
│ │ ├── grok/ Grok(grok.com,statsig 伪造,视频)
|
||||||
│ │ ├── leonardo/ Leonardo
|
│ │ ├── leonardo/ Leonardo
|
||||||
│ │ ├── krea/ Krea
|
│ │ ├── krea/ Krea
|
||||||
│ │ └── imagine/ Imagine.art
|
│ │ ├── imagine/ Imagine.art
|
||||||
|
│ │ └── custom/ 自定义上游(OpenAI 兼容 v1,按 id 路由,直连不走代理)
|
||||||
│ ├── repo/ 数据访问层(用户 / 模型 / 账号 / 日志 / CDK…)
|
│ ├── repo/ 数据访问层(用户 / 模型 / 账号 / 日志 / CDK…)
|
||||||
│ ├── service/ 业务逻辑(生成调度、计费、账号池、保活、维护)
|
│ ├── service/ 业务逻辑(生成调度、计费、账号池、保活、维护)
|
||||||
│ └── storage/ RustFS / S3 媒体存储
|
│ └── storage/ RustFS / S3 媒体存储
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"backend/internal/model"
|
"backend/internal/model"
|
||||||
"backend/internal/provider/adobe"
|
"backend/internal/provider/adobe"
|
||||||
"backend/internal/provider/chatgpt"
|
"backend/internal/provider/chatgpt"
|
||||||
|
"backend/internal/provider/custom"
|
||||||
"backend/internal/provider/grok"
|
"backend/internal/provider/grok"
|
||||||
"backend/internal/provider/imagine"
|
"backend/internal/provider/imagine"
|
||||||
"backend/internal/provider/krea"
|
"backend/internal/provider/krea"
|
||||||
@@ -110,7 +111,8 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
kreaClient := krea.NewClient("")
|
kreaClient := krea.NewClient("")
|
||||||
imagineClient := imagine.NewClient("")
|
imagineClient := imagine.NewClient("")
|
||||||
grokClient := grok.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)
|
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
||||||
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
||||||
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient)
|
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient)
|
||||||
|
|||||||
@@ -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"})
|
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) {
|
func (h *ProviderAdminHandler) ImportKreaCookie(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Cookie string `json:"cookie"`
|
Cookie string `json:"cookie"`
|
||||||
|
|||||||
@@ -371,12 +371,14 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "nano-banana-2",
|
"id": "nano-banana-2",
|
||||||
"provider": "adobe",
|
"provider": "runway",
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
"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"},
|
"resolutions": []string{"1K", "2K", "4K"},
|
||||||
"image_to_image": true,
|
"image_to_image": true,
|
||||||
"description": "Adobe Gemini Flash Nano Banana",
|
"max_reference_images": 6,
|
||||||
|
"reference_mode": "asset",
|
||||||
|
"description": "Runway Nano Banana 2 (图/参考图)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "gemini-veo31",
|
"id": "gemini-veo31",
|
||||||
@@ -405,7 +407,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
|||||||
"provider": "adobe",
|
"provider": "adobe",
|
||||||
"type": "video",
|
"type": "video",
|
||||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||||
"resolutions": []string{"540p", "720p", "1080p"},
|
"resolutions": []string{"720p", "1080p"},
|
||||||
"durations": []string{"5s"},
|
"durations": []string{"5s"},
|
||||||
"max_reference_images": 2,
|
"max_reference_images": 2,
|
||||||
"reference_mode": "frame",
|
"reference_mode": "frame",
|
||||||
@@ -422,6 +424,17 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
|||||||
"reference_mode": "frame",
|
"reference_mode": "frame",
|
||||||
"description": "Runway Gen-4 Turbo video (图生视频)",
|
"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",
|
"id": "seedream-4.5",
|
||||||
"provider": "leonardo",
|
"provider": "leonardo",
|
||||||
@@ -519,11 +532,11 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "nano-banana-2",
|
"id": "nano-banana-2",
|
||||||
"provider": "adobe",
|
"provider": "runway",
|
||||||
"kind": "image",
|
"kind": "image",
|
||||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
"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"},
|
"resolutions": []string{"1K", "2K", "4K"},
|
||||||
"description": "Adobe Gemini Flash Nano Banana",
|
"description": "Runway Nano Banana 2",
|
||||||
"stub": false,
|
"stub": false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -549,7 +562,7 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
|||||||
"provider": "adobe",
|
"provider": "adobe",
|
||||||
"kind": "video",
|
"kind": "video",
|
||||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||||
"resolutions": []string{"540p", "720p", "1080p"},
|
"resolutions": []string{"720p", "1080p"},
|
||||||
"description": "Adobe Firefly Video",
|
"description": "Adobe Firefly Video",
|
||||||
"stub": false,
|
"stub": false,
|
||||||
},
|
},
|
||||||
@@ -562,6 +575,15 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
|||||||
"description": "Runway Gen-4 Turbo video",
|
"description": "Runway Gen-4 Turbo video",
|
||||||
"stub": false,
|
"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",
|
"id": "seedream-4.5",
|
||||||
"provider": "leonardo",
|
"provider": "leonardo",
|
||||||
|
|||||||
@@ -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-krea-cookie", handlers.ProviderAdmin.ImportKreaCookie)
|
||||||
authed.POST("/tokens/import-imagine-token", handlers.ProviderAdmin.ImportImagineToken)
|
authed.POST("/tokens/import-imagine-token", handlers.ProviderAdmin.ImportImagineToken)
|
||||||
authed.POST("/tokens/import-grok-token", handlers.ProviderAdmin.ImportGrokToken)
|
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.POST("/tokens/delete-bulk", handlers.ProviderAdmin.TokenDeleteBulk)
|
||||||
authed.PATCH("/tokens/:pool/:id", handlers.ProviderAdmin.TokenUpdate)
|
authed.PATCH("/tokens/:pool/:id", handlers.ProviderAdmin.TokenUpdate)
|
||||||
authed.DELETE("/tokens/:pool/:id", handlers.ProviderAdmin.TokenDelete)
|
authed.DELETE("/tokens/:pool/:id", handlers.ProviderAdmin.TokenDelete)
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ type ModelConfig struct {
|
|||||||
Durations datatypes.JSON `gorm:"type:jsonb"`
|
Durations datatypes.JSON `gorm:"type:jsonb"`
|
||||||
MaxReferenceImages int `gorm:"not null;default:0"`
|
MaxReferenceImages int `gorm:"not null;default:0"`
|
||||||
ReferenceMode string `gorm:"size:32;not null;default:'none'"`
|
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 controls display order in the model dropdown / admin list: higher
|
||||||
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
|
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
|
||||||
// back to created_at desc. Default 0.
|
// back to created_at desc. Default 0.
|
||||||
@@ -146,6 +150,13 @@ type TokenAccount struct {
|
|||||||
VideoLimited bool `gorm:"not null;default:false"`
|
VideoLimited bool `gorm:"not null;default:false"`
|
||||||
AccountEmail string `gorm:"size:255"`
|
AccountEmail string `gorm:"size:255"`
|
||||||
AccountDisplayName 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
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -290,29 +290,29 @@ func parseCreditsConfig(buf []byte) (remaining int, resetUnix string, ok bool) {
|
|||||||
return 0, "", false
|
return 0, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanConfigMessage(msg []byte) (remaining int, resetUnix string, ok bool) {
|
func scanConfigMessage(msg []byte) (used int, resetUnix string, ok bool) {
|
||||||
var remF float32
|
var usedF float32
|
||||||
haveRem := false
|
seen := false
|
||||||
for len(msg) > 0 {
|
for len(msg) > 0 {
|
||||||
fn, wt, val, rest, good := readField(msg)
|
fn, wt, val, rest, good := readField(msg)
|
||||||
if !good {
|
if !good {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
msg = rest
|
msg = rest
|
||||||
|
seen = true
|
||||||
switch {
|
switch {
|
||||||
case fn == 1 && wt == 5: // float32 remaining credits
|
case fn == 1 && wt == 5: // float32 credits USED this period
|
||||||
remF = float32FromLE(val)
|
usedF = float32FromLE(val)
|
||||||
haveRem = true
|
|
||||||
case fn == 5 && wt == 2: // reset timestamp message { #1 varint=seconds }
|
case fn == 5 && wt == 2: // reset timestamp message { #1 varint=seconds }
|
||||||
if sec, sok := firstVarint(val); sok {
|
if sec, sok := firstVarint(val); sok {
|
||||||
resetUnix = strconv.FormatInt(sec, 10)
|
resetUnix = strconv.FormatInt(sec, 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if haveRem {
|
// A valid config message may OMIT field #1 when used == 0 (proto3 drops zero
|
||||||
return int(remF), resetUnix, true
|
// 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 0, resetUnix, false
|
return int(usedF), resetUnix, seen
|
||||||
}
|
}
|
||||||
|
|
||||||
// readField reads one protobuf field: returns (fieldNum, wireType, value, rest, ok).
|
// readField reads one protobuf field: returns (fieldNum, wireType, value, rest, ok).
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ var validTokenPools = map[string]string{
|
|||||||
"krea": "krea",
|
"krea": "krea",
|
||||||
"imagine": "imagine",
|
"imagine": "imagine",
|
||||||
"grok": "grok",
|
"grok": "grok",
|
||||||
|
"custom": "custom",
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenService struct {
|
type TokenService struct {
|
||||||
@@ -947,6 +948,68 @@ func (s *TokenService) checkPendingGrok(tokenID, ssoToken string) {
|
|||||||
s.finishPending(ctx, "grok", tokenID, "active", false, quotaMeta)
|
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
|
// finishPending writes the terminal status/dead flag and clears the pending_check
|
||||||
// marker (merging any cached quota) for a background import probe.
|
// 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) {
|
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,
|
"pending": pending,
|
||||||
"quota_supported": hasQuota,
|
"quota_supported": hasQuota,
|
||||||
"needs_reset_fetch": typeLabel == "adobe" && item.Status == "active" && strings.TrimSpace(item.CachedQuotaResetAfter) == "",
|
"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"])),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+310
-21
@@ -15,10 +15,13 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"backend/internal/config"
|
"backend/internal/config"
|
||||||
"backend/internal/model"
|
"backend/internal/model"
|
||||||
"backend/internal/provider/adobe"
|
"backend/internal/provider/adobe"
|
||||||
"backend/internal/provider/chatgpt"
|
"backend/internal/provider/chatgpt"
|
||||||
|
"backend/internal/provider/custom"
|
||||||
"backend/internal/provider/grok"
|
"backend/internal/provider/grok"
|
||||||
"backend/internal/provider/imagine"
|
"backend/internal/provider/imagine"
|
||||||
"backend/internal/provider/krea"
|
"backend/internal/provider/krea"
|
||||||
@@ -70,6 +73,7 @@ type V1Service struct {
|
|||||||
krea *krea.Client
|
krea *krea.Client
|
||||||
imagine *imagine.Client
|
imagine *imagine.Client
|
||||||
grok *grok.Client
|
grok *grok.Client
|
||||||
|
custom *custom.Client
|
||||||
store *storage.Client
|
store *storage.Client
|
||||||
// refresh re-mints an Adobe access token from its cookie when a request hits a
|
// 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
|
// 401 mid-flight (set via SetRefresh — wired after construction to avoid an
|
||||||
@@ -195,7 +199,7 @@ type V1VideoRequest struct {
|
|||||||
BaseURL string
|
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{
|
return &V1Service{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
models: models,
|
models: models,
|
||||||
@@ -210,6 +214,7 @@ func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.
|
|||||||
krea: kreaClient,
|
krea: kreaClient,
|
||||||
imagine: imagineClient,
|
imagine: imagineClient,
|
||||||
grok: grokClient,
|
grok: grokClient,
|
||||||
|
custom: customClient,
|
||||||
store: store,
|
store: store,
|
||||||
inflight: &InflightRegistry{},
|
inflight: &InflightRegistry{},
|
||||||
}
|
}
|
||||||
@@ -352,7 +357,7 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
|
|
||||||
var imageBytes []byte
|
var imageBytes []byte
|
||||||
switch modelItem.Provider {
|
switch s.effectiveProvider(genCtx, modelItem) {
|
||||||
case "adobe":
|
case "adobe":
|
||||||
b, execErr := s.generateAdobeImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
|
b, execErr := s.generateAdobeImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
@@ -455,6 +460,23 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
imageBytes = b
|
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:
|
default:
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
|
_ = 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 videoBytes []byte
|
||||||
var execErr error
|
var execErr error
|
||||||
switch modelItem.Provider {
|
switch s.effectiveProvider(genCtx, modelItem) {
|
||||||
case "adobe":
|
case "adobe":
|
||||||
videoBytes, _, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true)
|
videoBytes, _, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true)
|
||||||
case "runway":
|
case "runway":
|
||||||
videoBytes, _, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), true)
|
videoBytes, _, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), true)
|
||||||
case "grok":
|
case "grok":
|
||||||
videoBytes, _, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true)
|
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:
|
default:
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
|
||||||
@@ -570,11 +594,11 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(execErr, ErrNoProviderAccount):
|
case errors.Is(execErr, ErrNoProviderAccount):
|
||||||
return nil, 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
|
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
|
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
|
return nil, ErrProviderTemporary
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr)
|
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 videoURL string
|
||||||
var execErr error
|
var execErr error
|
||||||
switch modelItem.Provider {
|
switch s.effectiveProvider(genCtx, modelItem) {
|
||||||
case "adobe":
|
case "adobe":
|
||||||
_, videoURL, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
|
_, videoURL, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
|
||||||
case "runway":
|
case "runway":
|
||||||
_, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), false)
|
_, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), false)
|
||||||
case "grok":
|
case "grok":
|
||||||
_, videoURL, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
|
_, 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:
|
default:
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
|
||||||
@@ -859,12 +885,17 @@ func (s *V1Service) prepareImage(ctx context.Context, principal *APIPrincipal, i
|
|||||||
if !modelItem.Enabled || modelItem.Type != "image" {
|
if !modelItem.Enabled || modelItem.Type != "image" {
|
||||||
return nil, "", "", 0, ErrUnknownModel
|
return nil, "", "", 0, ErrUnknownModel
|
||||||
}
|
}
|
||||||
// Fail fast before charging if the provider has no usable account.
|
// Fail fast before charging if the provider has no usable account. Use the
|
||||||
if ok, err := s.hasActiveProviderToken(ctx, modelItem.Provider, "image"); err != nil {
|
// 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
|
return nil, "", "", 0, err
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
return nil, "", "", 0, ErrNoProviderAccount
|
return nil, "", "", 0, ErrNoProviderAccount
|
||||||
}
|
}
|
||||||
|
}
|
||||||
refLimit := 0
|
refLimit := 0
|
||||||
if modelItem.ImageToImage {
|
if modelItem.ImageToImage {
|
||||||
refLimit = modelItem.MaxReferenceImages
|
refLimit = modelItem.MaxReferenceImages
|
||||||
@@ -921,8 +952,10 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
|
|||||||
if !modelItem.Enabled || modelItem.Type != "video" {
|
if !modelItem.Enabled || modelItem.Type != "video" {
|
||||||
return nil, "", "", "", 0, ErrUnknownModel
|
return nil, "", "", "", 0, ErrUnknownModel
|
||||||
}
|
}
|
||||||
// Fail fast before charging if the provider has no usable account.
|
// Fail fast before charging — effective provider (custom upstream by id, else native).
|
||||||
if ok, err := s.hasActiveProviderToken(ctx, modelItem.Provider, "video"); err != nil {
|
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
|
return nil, "", "", "", 0, err
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
return nil, "", "", "", 0, ErrNoProviderAccount
|
return nil, "", "", "", 0, ErrNoProviderAccount
|
||||||
@@ -1534,6 +1567,246 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
|
|||||||
return nil, "", lastErr
|
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.
|
// generateGrokVideo runs grok's imagine video pipeline across the grok pool.
|
||||||
// Mirrors the runway policy: no pre-deduct, skip accounts known out of credits
|
// 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
|
// (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
|
// retry chain is preserved — on failure the caller's loop simply continues to
|
||||||
// the next account in rotation order.
|
// the next account in rotation order.
|
||||||
func (s *V1Service) rotateRoundRobin(pool string, items []model.TokenAccount) {
|
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 {
|
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
|
return items[i].ID < items[j].ID
|
||||||
})
|
})
|
||||||
n := len(items)
|
start := int(s.nextCursor(pool))
|
||||||
if n <= 1 {
|
for i := 0; i < len(items); {
|
||||||
return
|
j := i + 1
|
||||||
|
for j < len(items) && items[j].Weight == items[i].Weight {
|
||||||
|
j++
|
||||||
}
|
}
|
||||||
start := int(s.nextCursor(pool) % uint64(n))
|
if g := j - i; g > 1 {
|
||||||
if start == 0 {
|
off := start % g
|
||||||
return
|
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
|
||||||
}
|
}
|
||||||
rotated := make([]model.TokenAccount, 0, n)
|
|
||||||
rotated = append(rotated, items[start:]...)
|
|
||||||
rotated = append(rotated, items[:start]...)
|
|
||||||
copy(items, rotated)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { api, jsonBody } from '../api'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import SelectMenu from './SelectMenu.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'saved'])
|
||||||
|
|
||||||
|
const RATIO_OPTS = ['1:1', '16:9', '9:16', '4:3', '3:4', '21:9', '3:2', '5:4', '4:5', '2:3', '2:1']
|
||||||
|
const IMG_RES = ['1K', '2K', '4K']
|
||||||
|
const VID_RES = ['720p', '1080p', '2K', '4K']
|
||||||
|
const ALL_RES = ['1K', '2K', '4K', '720p', '1080p']
|
||||||
|
const DUR_OPTS = ['5s', '6s', '8s', '10s']
|
||||||
|
|
||||||
|
const id = ref('')
|
||||||
|
const type = ref('image')
|
||||||
|
const ratios = ref(['1:1', '16:9', '9:16'])
|
||||||
|
const maxRefs = ref(0)
|
||||||
|
const refMode = ref('none')
|
||||||
|
const weight = ref(0)
|
||||||
|
// tier -> { price, agent } — blank price means the tier is NOT supported.
|
||||||
|
const res = ref(Object.fromEntries(ALL_RES.map((r) => [r, { price: '', agent: '' }])))
|
||||||
|
const dur = ref(Object.fromEntries(DUR_OPTS.map((d) => [d, { price: '', agent: '' }])))
|
||||||
|
const error = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const isVideo = computed(() => type.value === 'video')
|
||||||
|
// Resolution tiers depend on type: image = 1K/2K/4K, video = 540p/720p/1080p/2K/4K.
|
||||||
|
const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES))
|
||||||
|
|
||||||
|
// 首尾帧(frame) only has first+last slots → cap reference images at 2.
|
||||||
|
const refsCap = computed(() => (refMode.value === 'frame' ? 2 : 99))
|
||||||
|
watch([refMode, maxRefs], () => {
|
||||||
|
if (refMode.value === 'frame' && Number(maxRefs.value) > 2) maxRefs.value = 2
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleRatio(r) {
|
||||||
|
const i = ratios.value.indexOf(r)
|
||||||
|
if (i >= 0) ratios.value.splice(i, 1)
|
||||||
|
else ratios.value.push(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect checked tiers into { tier: price } and { tier: agentPrice }
|
||||||
|
// blank price = tier not supported (skipped), matching the edit form.
|
||||||
|
function collect(tiers, allowed) {
|
||||||
|
const prices = {}, agent = {}, keys = []
|
||||||
|
for (const [k, v] of Object.entries(tiers)) {
|
||||||
|
if (allowed && !allowed.includes(k)) continue
|
||||||
|
const raw = String(v.price ?? '').trim()
|
||||||
|
if (raw === '') continue
|
||||||
|
const n = Number(raw)
|
||||||
|
if (Number.isNaN(n) || n < 0) continue
|
||||||
|
prices[k] = n; keys.push(k)
|
||||||
|
const ar = String(v.agent ?? '').trim()
|
||||||
|
if (ar !== '') { const a = Number(ar); if (!Number.isNaN(a) && a >= 0) agent[k] = a }
|
||||||
|
}
|
||||||
|
return { prices, agent, keys }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const mid = id.value.trim()
|
||||||
|
if (!mid) { error.value = '请填写模型 id'; return }
|
||||||
|
if (refMode.value === 'frame' && Number(maxRefs.value) > 2) maxRefs.value = 2
|
||||||
|
const r = collect(res.value, resOpts.value)
|
||||||
|
if (!r.keys.length) { error.value = '请至少勾选一个分辨率并填价格'; return }
|
||||||
|
const body = {
|
||||||
|
id: mid,
|
||||||
|
name: mid,
|
||||||
|
type: type.value,
|
||||||
|
provider: 'custom',
|
||||||
|
prices: r.prices,
|
||||||
|
prices_agent: r.agent,
|
||||||
|
ratios: ratios.value.slice(),
|
||||||
|
max_reference_images: Number(maxRefs.value) || 0,
|
||||||
|
reference_mode: refMode.value,
|
||||||
|
weight: Number(weight.value) || 0,
|
||||||
|
image_to_image: (Number(maxRefs.value) || 0) > 0,
|
||||||
|
}
|
||||||
|
if (isVideo.value) {
|
||||||
|
body.resolutions = r.keys
|
||||||
|
const d = collect(dur.value)
|
||||||
|
if (!d.keys.length) { error.value = '视频请至少勾选一个时长并填价格'; return }
|
||||||
|
body.duration_prices = d.prices
|
||||||
|
body.duration_prices_agent = d.agent
|
||||||
|
body.durations = d.keys
|
||||||
|
}
|
||||||
|
saving.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const resp = await api('/managed-models', jsonBody('POST', body))
|
||||||
|
if (resp.ok || resp.data?.ok || resp.status === 200) emit('saved')
|
||||||
|
else error.value = resp.data?.detail || '创建失败'
|
||||||
|
} catch (e) { error.value = String(e) }
|
||||||
|
finally { saving.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||||
|
@click.self="emit('close')">
|
||||||
|
<div class="card !shadow-xl mt-10 mb-10 w-full max-w-xl">
|
||||||
|
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold">添加自定义模型(上游 / provider=custom)</h2>
|
||||||
|
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700"><Icon name="close" class="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-4">
|
||||||
|
<p class="text-xs text-slate-500 leading-relaxed">
|
||||||
|
id 要与上游模型名<strong class="text-slate-700">一致</strong> —— 生成时按 id 自动路由到「支持该 id 的上游账号」。价格按本地价计费。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="text-xs text-slate-500 block mb-1">模型 id <span class="text-rose-500">*</span></label>
|
||||||
|
<input v-model="id" class="field font-mono text-xs h-10" placeholder="gpt-image-2" />
|
||||||
|
</div>
|
||||||
|
<div class="w-28">
|
||||||
|
<label class="text-xs text-slate-500 block mb-1">类型</label>
|
||||||
|
<SelectMenu v-model="type" :options="[{value:'image',label:'图像'},{value:'video',label:'视频'}]" />
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<label class="text-xs text-slate-500 block mb-1">权重</label>
|
||||||
|
<input v-model.number="weight" type="number" class="field h-10" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500 block mb-1.5">比例(多选)</label>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<button v-for="r in RATIO_OPTS" :key="r" type="button" @click="toggleRatio(r)"
|
||||||
|
class="px-2 py-1 rounded text-xs ring-1 transition-colors"
|
||||||
|
:class="ratios.includes(r) ? 'bg-indigo-500/15 text-indigo-600 ring-indigo-300' : 'bg-slate-50 text-slate-500 ring-slate-200 hover:ring-slate-300'">{{ r }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500 block mb-1.5">分辨率 · 价格(填普通价 = 支持该档,<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div v-for="r in resOpts" :key="r" class="flex items-center gap-2">
|
||||||
|
<span class="w-16 text-xs font-mono text-slate-500">{{ r }}</span>
|
||||||
|
<input v-model="res[r].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
||||||
|
<input v-model="res[r].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isVideo">
|
||||||
|
<label class="text-xs text-slate-500 block mb-1.5">时长 · 价格(总价 = 分辨率价 + 时长价;<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div v-for="d in DUR_OPTS" :key="d" class="flex items-center gap-2">
|
||||||
|
<span class="w-16 text-xs font-mono text-slate-500">{{ d }}</span>
|
||||||
|
<input v-model="dur[d].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
||||||
|
<input v-model="dur[d].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="text-xs text-slate-500 block mb-1">参考图张数<span v-if="refMode==='frame'" class="text-white/40">(首尾帧最多 2)</span></label>
|
||||||
|
<input v-model.number="maxRefs" type="number" min="0" :max="refsCap" class="field h-10" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="text-xs text-slate-500 block mb-1">参考模式</label>
|
||||||
|
<SelectMenu v-model="refMode" :options="[{value:'none',label:'无'},{value:'asset',label:'参考图'},{value:'frame',label:'首尾帧(视频)'}]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="save" :disabled="saving" class="btn-primary w-full">{{ saving ? '创建中…' : '创建模型' }}</button>
|
||||||
|
<p v-if="error" class="text-xs text-rose-600">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -7,10 +7,14 @@ import Icon from './Icon.vue'
|
|||||||
const emit = defineEmits(['close', 'imported'])
|
const emit = defineEmits(['close', 'imported'])
|
||||||
|
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
|
const weight = ref(0)
|
||||||
const status = ref('')
|
const status = ref('')
|
||||||
const isError = ref(false)
|
const isError = ref(false)
|
||||||
const submitting = 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
|
// 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.
|
// so they can see whether their paste was understood before clicking import.
|
||||||
const detected = computed(() => {
|
const detected = computed(() => {
|
||||||
@@ -56,8 +60,15 @@ async function doSmartImport() {
|
|||||||
: it.type === 'imagine'
|
: it.type === 'imagine'
|
||||||
? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value }))
|
? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value }))
|
||||||
: await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value }))
|
: await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value }))
|
||||||
if (r.ok) ok++
|
if (r.ok) {
|
||||||
else { fail++; errs.push(`${it.type}: ${r.data?.detail || r.status}`) }
|
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) {
|
} catch (e) {
|
||||||
fail++; errs.push(`${it.type}: ${e}`)
|
fail++; errs.push(`${it.type}: ${e}`)
|
||||||
}
|
}
|
||||||
@@ -131,6 +142,10 @@ async function doSmartImport() {
|
|||||||
</template>
|
</template>
|
||||||
<span v-else class="text-rose-600">未识别到任何 Cookie 或 JWT</span>
|
<span v-else class="text-rose-600">未识别到任何 Cookie 或 JWT</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-3 flex items-center gap-2">
|
||||||
|
<label class="text-xs text-slate-500 whitespace-nowrap">权重(本批账号,高的优先)</label>
|
||||||
|
<input v-model.number="weight" type="number" class="field !w-24" placeholder="0" />
|
||||||
|
</div>
|
||||||
<button @click="doSmartImport" :disabled="submitting || !detected.total" class="btn-primary w-full mt-3">
|
<button @click="doSmartImport" :disabled="submitting || !detected.total" class="btn-primary w-full mt-3">
|
||||||
{{ submitting ? '导入中…' : (detected.total ? `识别并导入 (${detected.total})` : '识别并导入') }}
|
{{ submitting ? '导入中…' : (detected.total ? `识别并导入 (${detected.total})` : '识别并导入') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
|
|||||||
<button type="button" @click="toggle" @keydown="onKeydown"
|
<button type="button" @click="toggle" @keydown="onKeydown"
|
||||||
:aria-expanded="open"
|
:aria-expanded="open"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
class="field flex items-center justify-between gap-2 text-left disabled:opacity-50 disabled:cursor-not-allowed"
|
class="field flex items-center justify-between gap-2 text-left h-10 !py-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
:class="[mono ? 'font-mono' : '', selected ? '' : 'text-[color:var(--fg-faint)]']">
|
:class="[mono ? 'font-mono' : '', selected ? '' : 'text-[color:var(--fg-faint)]']">
|
||||||
<span class="truncate">{{ label }}</span>
|
<span class="truncate">{{ label }}</span>
|
||||||
<Icon name="chevron"
|
<Icon name="chevron"
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ async function run() {
|
|||||||
busy.value = false
|
busy.value = false
|
||||||
resultUrl.value = r.data.url
|
resultUrl.value = r.data.url
|
||||||
resultKind.value = r.data.kind || (isVideo ? 'video' : 'image')
|
resultKind.value = r.data.kind || (isVideo ? 'video' : 'image')
|
||||||
status.value = `完成 · ${r.data.provider} · ${r.data.elapsed_ms}ms`
|
status.value = `完成 · ${r.data.provider} · ${(r.data.elapsed_ms / 1000).toFixed(1)}s`
|
||||||
} else if (GATEWAY_TIMEOUT.has(r.status)) {
|
} else if (GATEWAY_TIMEOUT.has(r.status)) {
|
||||||
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。保持锁住,轮询恢复结果。
|
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。保持锁住,轮询恢复结果。
|
||||||
status.value = isVideo ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
status.value = isVideo ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api, jsonBody } from '../api'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ account: { type: Object, default: null } }) // edit mode when set
|
||||||
|
const emit = defineEmits(['close', 'imported'])
|
||||||
|
|
||||||
|
const isEdit = !!props.account
|
||||||
|
const name = ref(props.account?.email || '')
|
||||||
|
const baseUrl = ref(props.account?.base_url || '')
|
||||||
|
const key = ref('') // edit: blank = keep existing key
|
||||||
|
const allModels = ref([]) // existing models to pick from
|
||||||
|
const selected = ref(props.account?.models ? String(props.account.models).split(',').map((x) => x.trim()).filter(Boolean) : [])
|
||||||
|
const weight = ref(Number(props.account?.weight) || 0)
|
||||||
|
const concurrency = ref(Number(props.account?.concurrency) || 1)
|
||||||
|
const status = ref('')
|
||||||
|
const isError = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const r = await api('/managed-models')
|
||||||
|
allModels.value = (r.data?.data || []).map((m) => ({ id: m.id, type: m.type }))
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggle(id) {
|
||||||
|
const i = selected.value.indexOf(id)
|
||||||
|
if (i >= 0) selected.value.splice(i, 1)
|
||||||
|
else selected.value.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!baseUrl.value.trim() || (!isEdit && !key.value.trim())) {
|
||||||
|
status.value = isEdit ? '请填写 URL' : '请填写 URL 和 Key'; isError.value = true; return
|
||||||
|
}
|
||||||
|
submitting.value = true; status.value = ''; isError.value = false
|
||||||
|
try {
|
||||||
|
const r = await api('/tokens/import-custom-account', jsonBody('POST', {
|
||||||
|
id: isEdit ? props.account.id : undefined,
|
||||||
|
name: name.value.trim(),
|
||||||
|
base_url: baseUrl.value.trim(),
|
||||||
|
key: key.value.trim(), // blank in edit = keep existing
|
||||||
|
models: selected.value.join(','),
|
||||||
|
weight: Number(weight.value) || 0,
|
||||||
|
concurrency: Number(concurrency.value) || 1,
|
||||||
|
}))
|
||||||
|
if (r.ok) {
|
||||||
|
status.value = isEdit ? '✓ 已保存' : '✓ 已添加上游'; emit('imported')
|
||||||
|
setTimeout(() => emit('close'), 700)
|
||||||
|
} else {
|
||||||
|
status.value = r.data?.detail || '保存失败'; isError.value = true
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
status.value = String(e); isError.value = true
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||||
|
@click.self="emit('close')">
|
||||||
|
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-lg">
|
||||||
|
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold">{{ isEdit ? '编辑上游' : '添加上游(自定义 OpenAI 兼容)' }}</h2>
|
||||||
|
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
|
||||||
|
<Icon name="close" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-3">
|
||||||
|
<p class="text-xs text-slate-500 leading-relaxed">
|
||||||
|
上游就是一个账号:填 v1 URL + Key。模型按 <strong class="text-slate-700">id 相同</strong>自动路由 ——
|
||||||
|
在「模型管理」加一个 provider=custom、id 与上游一致的模型即可从这个上游调用。调用<strong class="text-slate-700">直连不走代理</strong>。
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500">备注名</label>
|
||||||
|
<input v-model="name" class="field" placeholder="例如:我的中转 / xx-api" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500">v1 URL <span class="text-rose-500">*</span></label>
|
||||||
|
<input v-model="baseUrl" class="field font-mono text-xs" placeholder="https://api.example.com(无需 /v1 结尾)" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500">Key <span v-if="!isEdit" class="text-rose-500">*</span><span v-else class="text-white/40">(留空=不改)</span></label>
|
||||||
|
<input v-model="key" class="field font-mono text-xs" :placeholder="isEdit ? '留空保持原 key' : 'sk-...'" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-1.5">
|
||||||
|
<label class="text-xs text-slate-500">支持的模型(多选,不选 = 全部)</label>
|
||||||
|
<span class="text-[11px] text-slate-400">{{ selected.length ? `已选 ${selected.length}` : '全部' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!allModels.length" class="text-xs text-slate-400 rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-3">
|
||||||
|
暂无模型 —— 可先去模型管理加自定义模型
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-wrap gap-1.5 max-h-44 overflow-y-auto rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-2">
|
||||||
|
<button v-for="m in allModels" :key="m.id" type="button" @click="toggle(m.id)"
|
||||||
|
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs ring-1 transition-colors"
|
||||||
|
:class="selected.includes(m.id) ? 'bg-indigo-500/15 text-indigo-700 ring-indigo-300 font-medium' : 'bg-white text-slate-600 ring-slate-200 hover:ring-slate-300'">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full" :class="m.type === 'video' ? 'bg-violet-400' : 'bg-emerald-400'"></span>{{ m.id }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="text-xs text-slate-500">权重(高的优先)</label>
|
||||||
|
<input v-model.number="weight" type="number" class="field" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="text-xs text-slate-500">并发数(单账号)</label>
|
||||||
|
<input v-model.number="concurrency" type="number" min="1" class="field" placeholder="1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="submit" :disabled="submitting" class="btn-primary w-full mt-1">
|
||||||
|
{{ submitting ? '保存中…' : (isEdit ? '保存' : '添加上游') }}
|
||||||
|
</button>
|
||||||
|
<p v-if="status" class="text-xs" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,12 +3,16 @@ import { ref, computed, onMounted, watch } from 'vue'
|
|||||||
import { api, jsonBody } from '../api'
|
import { api, jsonBody } from '../api'
|
||||||
import { fmtTs, fmtIso, fmtDate, fmtClock } from '../utils/format'
|
import { fmtTs, fmtIso, fmtDate, fmtClock } from '../utils/format'
|
||||||
import ImportModal from '../components/ImportModal.vue'
|
import ImportModal from '../components/ImportModal.vue'
|
||||||
|
import UpstreamModal from '../components/UpstreamModal.vue'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
|
|
||||||
const rows = ref([])
|
const rows = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const quotaStatus = ref('')
|
const quotaStatus = ref('')
|
||||||
const showImport = ref(false)
|
const showImport = ref(false)
|
||||||
|
const showUpstream = ref(false)
|
||||||
|
const editingUpstream = ref(null)
|
||||||
|
function editUpstream(a) { editingUpstream.value = a; showUpstream.value = true }
|
||||||
|
|
||||||
const typeFilter = ref('') // '' | 'openai' | 'adobe' | 'runway' | 'leonardo'
|
const typeFilter = ref('') // '' | 'openai' | 'adobe' | 'runway' | 'leonardo'
|
||||||
const statusFilter = ref('') // '' | 'active' | 'quota' | 'disabled'
|
const statusFilter = ref('') // '' | 'active' | 'quota' | 'disabled'
|
||||||
@@ -97,6 +101,13 @@ const pageNumbers = computed(() => {
|
|||||||
|
|
||||||
let pendingTimer = null
|
let pendingTimer = null
|
||||||
|
|
||||||
|
// Inline-edit an account field (weight / concurrency) → PATCH /tokens/{pool}/{id}.
|
||||||
|
async function saveField(row, field, value) {
|
||||||
|
const v = Number(value)
|
||||||
|
if (Number.isNaN(v)) return
|
||||||
|
await api(`/tokens/${row.pool}/${row.id}`, jsonBody('PATCH', { [field]: v }))
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAccounts() {
|
async function loadAccounts() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
quotaStatus.value = ''
|
quotaStatus.value = ''
|
||||||
@@ -361,10 +372,13 @@ onMounted(loadAccounts)
|
|||||||
<button @click="showImport = true" class="btn-primary">
|
<button @click="showImport = true" class="btn-primary">
|
||||||
<Icon name="plus" class="w-3.5 h-3.5" /> 导入账号
|
<Icon name="plus" class="w-3.5 h-3.5" /> 导入账号
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="editingUpstream = null; showUpstream = true" class="btn-soft">
|
||||||
|
<Icon name="plus" class="w-3.5 h-3.5" /> 添加上游
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-x-auto">
|
||||||
<div v-if="loading && !rows.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
<div v-if="loading && !rows.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
||||||
@@ -374,18 +388,20 @@ onMounted(loadAccounts)
|
|||||||
<button v-if="!rows.length" @click="showImport = true" class="btn-soft mt-1">导入第一个</button>
|
<button v-if="!rows.length" @click="showImport = true" class="btn-soft mt-1">导入第一个</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table v-else class="w-full text-sm table-fixed">
|
<table v-else class="w-full text-sm table-fixed min-w-[1040px]">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col class="w-9" /> <!-- select -->
|
<col class="w-9" /> <!-- select -->
|
||||||
<col /> <!-- identity (flex) -->
|
<col /> <!-- identity (flex) -->
|
||||||
<col class="w-20" /> <!-- type -->
|
<col class="w-20" /> <!-- type -->
|
||||||
<col class="w-24" /> <!-- remaining -->
|
<col class="w-24" /> <!-- remaining -->
|
||||||
|
<col class="w-16" /> <!-- weight -->
|
||||||
|
<col class="w-16" /> <!-- concurrency -->
|
||||||
<col class="w-32" /> <!-- reset -->
|
<col class="w-32" /> <!-- reset -->
|
||||||
<col class="w-28" /> <!-- created -->
|
<col class="w-28" /> <!-- created -->
|
||||||
<col class="w-28" /> <!-- last used -->
|
<col class="w-28" /> <!-- last used -->
|
||||||
<col class="w-40" /> <!-- inflight/success/fail -->
|
<col class="w-40" /> <!-- inflight/success/fail -->
|
||||||
<col class="w-16" /> <!-- status switch -->
|
<col class="w-16" /> <!-- status switch -->
|
||||||
<col class="w-16" /> <!-- actions -->
|
<col class="w-24" /> <!-- actions -->
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||||
@@ -396,6 +412,8 @@ onMounted(loadAccounts)
|
|||||||
<th class="text-left px-5 py-3 font-medium">账户</th>
|
<th class="text-left px-5 py-3 font-medium">账户</th>
|
||||||
<th class="text-left px-3 py-3 font-medium">类型</th>
|
<th class="text-left px-3 py-3 font-medium">类型</th>
|
||||||
<th class="text-right px-3 py-3 font-medium">额度</th>
|
<th class="text-right px-3 py-3 font-medium">额度</th>
|
||||||
|
<th class="text-center px-3 py-3 font-medium">权重</th>
|
||||||
|
<th class="text-center px-3 py-3 font-medium">并发</th>
|
||||||
<th class="text-left px-3 py-3 font-medium">恢复时间</th>
|
<th class="text-left px-3 py-3 font-medium">恢复时间</th>
|
||||||
<th class="text-left px-3 py-3 font-medium">创建时间</th>
|
<th class="text-left px-3 py-3 font-medium">创建时间</th>
|
||||||
<th class="text-left px-3 py-3 font-medium">最后使用</th>
|
<th class="text-left px-3 py-3 font-medium">最后使用</th>
|
||||||
@@ -445,6 +463,20 @@ onMounted(loadAccounts)
|
|||||||
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}{{ a.type === 'grok' ? '%' : '' }}</span>
|
: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>
|
<span v-else class="text-white/25" :title="a._quotaError || ''">—</span>
|
||||||
</td>
|
</td>
|
||||||
|
<!-- weight (editable, all accounts) -->
|
||||||
|
<td class="px-3 py-3.5 align-middle text-center whitespace-nowrap">
|
||||||
|
<input type="number" :value="a.weight" @change="saveField(a, 'weight', $event.target.value)"
|
||||||
|
class="w-12 bg-white/5 border border-white/10 rounded px-1 py-0.5 text-xs text-center tabular-nums focus:border-indigo-400 outline-none"
|
||||||
|
title="权重(高的优先)" />
|
||||||
|
</td>
|
||||||
|
<!-- concurrency (editable only for custom upstreams; others = system fixed) -->
|
||||||
|
<td class="px-3 py-3.5 align-middle text-center whitespace-nowrap">
|
||||||
|
<input v-if="a.type === 'custom'" type="number" min="1" :value="a.concurrency || 1"
|
||||||
|
@change="saveField(a, 'concurrency', $event.target.value)"
|
||||||
|
class="w-12 bg-white/5 border border-white/10 rounded px-1 py-0.5 text-xs text-center tabular-nums focus:border-indigo-400 outline-none"
|
||||||
|
title="并发数(仅上游可设)" />
|
||||||
|
<span v-else class="text-white/25 text-xs" title="系统固定">{{ a.type === 'grok' ? 10 : 1 }}</span>
|
||||||
|
</td>
|
||||||
<!-- reset_after -->
|
<!-- reset_after -->
|
||||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||||
<div v-if="a.reset_after" class="leading-tight" :title="fmtIso(a.reset_after)">
|
<div v-if="a.reset_after" class="leading-tight" :title="fmtIso(a.reset_after)">
|
||||||
@@ -494,10 +526,15 @@ onMounted(loadAccounts)
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<!-- actions -->
|
<!-- actions -->
|
||||||
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
|
<td class="px-3 py-3.5 align-middle whitespace-nowrap">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button v-if="a.type === 'custom'" @click="editUpstream(a)" class="act" title="编辑上游">
|
||||||
|
<Icon name="config" class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
<button @click="deleteAccount(a.pool, a.id)" class="act danger" title="删除">
|
<button @click="deleteAccount(a.pool, a.id)" class="act danger" title="删除">
|
||||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -520,6 +557,7 @@ onMounted(loadAccounts)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ImportModal v-if="showImport" @close="showImport = false" @imported="loadAccounts" />
|
<ImportModal v-if="showImport" @close="showImport = false" @imported="loadAccounts" />
|
||||||
|
<UpstreamModal v-if="showUpstream" :account="editingUpstream" @close="showUpstream = false; editingUpstream = null" @imported="loadAccounts" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import ModelFormModal from '../components/ModelFormModal.vue'
|
import ModelFormModal from '../components/ModelFormModal.vue'
|
||||||
|
import CustomModelModal from '../components/CustomModelModal.vue'
|
||||||
import TestModal from '../components/TestModal.vue'
|
import TestModal from '../components/TestModal.vue'
|
||||||
import { points } from '../credits'
|
import { points } from '../credits'
|
||||||
|
|
||||||
const models = ref([])
|
const models = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
|
const showCustom = ref(false)
|
||||||
const editing = ref(null) // null = add, object = edit
|
const editing = ref(null) // null = add, object = edit
|
||||||
const testing = ref(null) // model being tested, or null
|
const testing = ref(null) // model being tested, or null
|
||||||
|
|
||||||
@@ -120,6 +122,9 @@ onMounted(loadModels)
|
|||||||
<button @click="openAdd" class="btn-primary">
|
<button @click="openAdd" class="btn-primary">
|
||||||
<Icon name="plus" class="w-3.5 h-3.5" /> 新增模型
|
<Icon name="plus" class="w-3.5 h-3.5" /> 新增模型
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="showCustom = true" class="btn-soft">
|
||||||
|
<Icon name="plus" class="w-3.5 h-3.5" /> 自定义模型
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
@@ -268,6 +273,7 @@ onMounted(loadModels)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModelFormModal v-if="showForm" :model="editing" @close="showForm = false" @saved="onSaved" />
|
<ModelFormModal v-if="showForm" :model="editing" @close="showForm = false" @saved="onSaved" />
|
||||||
|
<CustomModelModal v-if="showCustom" @close="showCustom = false" @saved="() => { showCustom = false; loadModels() }" />
|
||||||
<TestModal v-if="testing" :model="testing" @close="testing = null" />
|
<TestModal v-if="testing" :model="testing" @close="testing = null" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user