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:
2026-06-28 21:59:46 +08:00
co-authored by Claude Opus 4.8
parent d289ec378d
commit 3ef84bb9b3
17 changed files with 1149 additions and 64 deletions
+67
View File
@@ -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"])),
}
}