Unverified Commit 06093d4f authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #311 from longgexx/main

添加分组级别模型路由配置功能(Anthropic平台)
parents 452fa53c 577ee161
......@@ -12,6 +12,7 @@ import (
"io"
"log"
"net/http"
"os"
"regexp"
"sort"
"strings"
......@@ -38,6 +39,21 @@ const (
maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量
)
func (s *GatewayService) debugModelRoutingEnabled() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv("SUB2API_DEBUG_MODEL_ROUTING")))
return v == "1" || v == "true" || v == "yes" || v == "on"
}
func shortSessionHash(sessionHash string) string {
if sessionHash == "" {
return ""
}
if len(sessionHash) <= 8 {
return sessionHash
}
return sessionHash[:8]
}
// sseDataRe matches SSE data lines with optional whitespace after colon.
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
var (
......@@ -407,6 +423,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
ctx = s.withGroupContext(ctx, group)
if s.debugModelRoutingEnabled() && requestedModel != "" {
groupPlatform := ""
if group != nil {
groupPlatform = group.Platform
}
log.Printf("[ModelRoutingDebug] select entry: group_id=%v group_platform=%s model=%s session=%s sticky_account=%d load_batch=%v concurrency=%v",
derefGroupID(groupID), groupPlatform, requestedModel, shortSessionHash(sessionHash), stickyAccountID, cfg.LoadBatchEnabled, s.concurrencyService != nil)
}
if s.concurrencyService == nil || !cfg.LoadBatchEnabled {
account, err := s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, excludedIDs)
if err != nil {
......@@ -450,6 +475,9 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
return nil, err
}
preferOAuth := platform == PlatformGemini
if s.debugModelRoutingEnabled() && platform == PlatformAnthropic && requestedModel != "" {
log.Printf("[ModelRoutingDebug] load-aware enabled: group_id=%v model=%s session=%s platform=%s", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), platform)
}
accounts, useMixed, err := s.listSchedulableAccounts(ctx, groupID, platform, hasForcePlatform)
if err != nil {
......@@ -467,15 +495,206 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
return excluded
}
// ============ Layer 1: 粘性会话优先 ============
if sessionHash != "" && s.cache != nil {
// 提前构建 accountByID(供 Layer 1 和 Layer 1.5 使用)
accountByID := make(map[int64]*Account, len(accounts))
for i := range accounts {
accountByID[accounts[i].ID] = &accounts[i]
}
// 获取模型路由配置(仅 anthropic 平台)
var routingAccountIDs []int64
if group != nil && requestedModel != "" && group.Platform == PlatformAnthropic {
routingAccountIDs = group.GetRoutingAccountIDs(requestedModel)
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] context group routing: group_id=%d model=%s enabled=%v rules=%d matched_ids=%v session=%s sticky_account=%d",
group.ID, requestedModel, group.ModelRoutingEnabled, len(group.ModelRouting), routingAccountIDs, shortSessionHash(sessionHash), stickyAccountID)
if len(routingAccountIDs) == 0 && group.ModelRoutingEnabled && len(group.ModelRouting) > 0 {
keys := make([]string, 0, len(group.ModelRouting))
for k := range group.ModelRouting {
keys = append(keys, k)
}
sort.Strings(keys)
const maxKeys = 20
if len(keys) > maxKeys {
keys = keys[:maxKeys]
}
log.Printf("[ModelRoutingDebug] context group routing miss: group_id=%d model=%s patterns(sample)=%v", group.ID, requestedModel, keys)
}
}
}
// ============ Layer 1: 模型路由优先选择(优先级高于粘性会话) ============
if len(routingAccountIDs) > 0 && s.concurrencyService != nil {
// 1. 过滤出路由列表中可调度的账号
var routingCandidates []*Account
var filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping int
for _, routingAccountID := range routingAccountIDs {
if isExcluded(routingAccountID) {
filteredExcluded++
continue
}
account, ok := accountByID[routingAccountID]
if !ok || !account.IsSchedulable() {
if !ok {
filteredMissing++
} else {
filteredUnsched++
}
continue
}
if !s.isAccountAllowedForPlatform(account, platform, useMixed) {
filteredPlatform++
continue
}
if !account.IsSchedulableForModel(requestedModel) {
filteredModelScope++
continue
}
if requestedModel != "" && !s.isModelSupportedByAccount(account, requestedModel) {
filteredModelMapping++
continue
}
routingCandidates = append(routingCandidates, account)
}
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] routed candidates: group_id=%v model=%s routed=%d candidates=%d filtered(excluded=%d missing=%d unsched=%d platform=%d model_scope=%d model_mapping=%d)",
derefGroupID(groupID), requestedModel, len(routingAccountIDs), len(routingCandidates),
filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping)
}
if len(routingCandidates) > 0 {
// 1.5. 在路由账号范围内检查粘性会话
if sessionHash != "" && s.cache != nil {
stickyAccountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
if err == nil && stickyAccountID > 0 && containsInt64(routingAccountIDs, stickyAccountID) && !isExcluded(stickyAccountID) {
// 粘性账号在路由列表中,优先使用
if stickyAccount, ok := accountByID[stickyAccountID]; ok {
if stickyAccount.IsSchedulable() &&
s.isAccountAllowedForPlatform(stickyAccount, platform, useMixed) &&
stickyAccount.IsSchedulableForModel(requestedModel) &&
(requestedModel == "" || s.isModelSupportedByAccount(stickyAccount, requestedModel)) {
result, err := s.tryAcquireAccountSlot(ctx, stickyAccountID, stickyAccount.Concurrency)
if err == nil && result.Acquired {
_ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), stickyAccountID)
}
return &AccountSelectionResult{
Account: stickyAccount,
Acquired: true,
ReleaseFunc: result.ReleaseFunc,
}, nil
}
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, stickyAccountID)
if waitingCount < cfg.StickySessionMaxWaiting {
return &AccountSelectionResult{
Account: stickyAccount,
WaitPlan: &AccountWaitPlan{
AccountID: stickyAccountID,
MaxConcurrency: stickyAccount.Concurrency,
Timeout: cfg.StickySessionWaitTimeout,
MaxWaiting: cfg.StickySessionMaxWaiting,
},
}, nil
}
// 粘性账号槽位满且等待队列已满,继续使用负载感知选择
}
}
}
}
// 2. 批量获取负载信息
routingLoads := make([]AccountWithConcurrency, 0, len(routingCandidates))
for _, acc := range routingCandidates {
routingLoads = append(routingLoads, AccountWithConcurrency{
ID: acc.ID,
MaxConcurrency: acc.Concurrency,
})
}
routingLoadMap, _ := s.concurrencyService.GetAccountsLoadBatch(ctx, routingLoads)
// 3. 按负载感知排序
type accountWithLoad struct {
account *Account
loadInfo *AccountLoadInfo
}
var routingAvailable []accountWithLoad
for _, acc := range routingCandidates {
loadInfo := routingLoadMap[acc.ID]
if loadInfo == nil {
loadInfo = &AccountLoadInfo{AccountID: acc.ID}
}
if loadInfo.LoadRate < 100 {
routingAvailable = append(routingAvailable, accountWithLoad{account: acc, loadInfo: loadInfo})
}
}
if len(routingAvailable) > 0 {
// 排序:优先级 > 负载率 > 最后使用时间
sort.SliceStable(routingAvailable, func(i, j int) bool {
a, b := routingAvailable[i], routingAvailable[j]
if a.account.Priority != b.account.Priority {
return a.account.Priority < b.account.Priority
}
if a.loadInfo.LoadRate != b.loadInfo.LoadRate {
return a.loadInfo.LoadRate < b.loadInfo.LoadRate
}
switch {
case a.account.LastUsedAt == nil && b.account.LastUsedAt != nil:
return true
case a.account.LastUsedAt != nil && b.account.LastUsedAt == nil:
return false
case a.account.LastUsedAt == nil && b.account.LastUsedAt == nil:
return false
default:
return a.account.LastUsedAt.Before(*b.account.LastUsedAt)
}
})
// 4. 尝试获取槽位
for _, item := range routingAvailable {
result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency)
if err == nil && result.Acquired {
if sessionHash != "" && s.cache != nil {
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL)
}
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID)
}
return &AccountSelectionResult{
Account: item.account,
Acquired: true,
ReleaseFunc: result.ReleaseFunc,
}, nil
}
}
// 5. 所有路由账号槽位满,返回等待计划(选择负载最低的)
acc := routingAvailable[0].account
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] routed wait: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), acc.ID)
}
return &AccountSelectionResult{
Account: acc,
WaitPlan: &AccountWaitPlan{
AccountID: acc.ID,
MaxConcurrency: acc.Concurrency,
Timeout: cfg.StickySessionWaitTimeout,
MaxWaiting: cfg.StickySessionMaxWaiting,
},
}, nil
}
// 路由列表中的账号都不可用(负载率 >= 100),继续到 Layer 2 回退
log.Printf("[ModelRouting] All routed accounts unavailable for model=%s, falling back to normal selection", requestedModel)
}
}
// ============ Layer 1.5: 粘性会话(仅在无模型路由配置时生效) ============
if len(routingAccountIDs) == 0 && sessionHash != "" && s.cache != nil {
accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
if err == nil && accountID > 0 && !isExcluded(accountID) {
// 粘性命中仅在当前可调度候选集中生效。
accountByID := make(map[int64]*Account, len(accounts))
for i := range accounts {
accountByID[accounts[i].ID] = &accounts[i]
}
account, ok := accountByID[accountID]
if ok && s.isAccountInGroup(account, groupID) &&
s.isAccountAllowedForPlatform(account, platform, useMixed) &&
......@@ -687,6 +906,32 @@ func (s *GatewayService) resolveGroupByID(ctx context.Context, groupID int64) (*
return group, nil
}
func (s *GatewayService) routingAccountIDsForRequest(ctx context.Context, groupID *int64, requestedModel string, platform string) []int64 {
if groupID == nil || requestedModel == "" || platform != PlatformAnthropic {
return nil
}
group, err := s.resolveGroupByID(ctx, *groupID)
if err != nil || group == nil {
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] resolve group failed: group_id=%v model=%s platform=%s err=%v", derefGroupID(groupID), requestedModel, platform, err)
}
return nil
}
// Preserve existing behavior: model routing only applies to anthropic groups.
if group.Platform != PlatformAnthropic {
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] skip: non-anthropic group platform: group_id=%d group_platform=%s model=%s", group.ID, group.Platform, requestedModel)
}
return nil
}
ids := group.GetRoutingAccountIDs(requestedModel)
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] routing lookup: group_id=%d model=%s enabled=%v rules=%d matched_ids=%v",
group.ID, requestedModel, group.ModelRoutingEnabled, len(group.ModelRouting), ids)
}
return ids
}
func (s *GatewayService) resolveGatewayGroup(ctx context.Context, groupID *int64) (*Group, *int64, error) {
if groupID == nil {
return nil, nil, nil
......@@ -868,6 +1113,116 @@ func sortAccountsByPriorityAndLastUsed(accounts []*Account, preferOAuth bool) {
// selectAccountForModelWithPlatform 选择单平台账户(完全隔离)
func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, platform string) (*Account, error) {
preferOAuth := platform == PlatformGemini
routingAccountIDs := s.routingAccountIDsForRequest(ctx, groupID, requestedModel, platform)
var accounts []Account
accountsLoaded := false
// ============ Model Routing (legacy path): apply before sticky session ============
// When load-awareness is disabled (e.g. concurrency service not configured), we still honor model routing
// so switching model can switch upstream account within the same sticky session.
if len(routingAccountIDs) > 0 {
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] legacy routed begin: group_id=%v model=%s platform=%s session=%s routed_ids=%v",
derefGroupID(groupID), requestedModel, platform, shortSessionHash(sessionHash), routingAccountIDs)
}
// 1) Sticky session only applies if the bound account is within the routing set.
if sessionHash != "" && s.cache != nil {
accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
if err == nil && accountID > 0 && containsInt64(routingAccountIDs, accountID) {
if _, excluded := excludedIDs[accountID]; !excluded {
account, err := s.getSchedulableAccount(ctx, accountID)
// 检查账号分组归属和平台匹配(确保粘性会话不会跨分组或跨平台)
if err == nil && s.isAccountInGroup(account, groupID) && account.Platform == platform && account.IsSchedulableForModel(requestedModel) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
if err := s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL); err != nil {
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
}
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] legacy routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), accountID)
}
return account, nil
}
}
}
}
// 2) Select an account from the routed candidates.
forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string)
if hasForcePlatform && forcePlatform == "" {
hasForcePlatform = false
}
var err error
accounts, _, err = s.listSchedulableAccounts(ctx, groupID, platform, hasForcePlatform)
if err != nil {
return nil, fmt.Errorf("query accounts failed: %w", err)
}
accountsLoaded = true
routingSet := make(map[int64]struct{}, len(routingAccountIDs))
for _, id := range routingAccountIDs {
if id > 0 {
routingSet[id] = struct{}{}
}
}
var selected *Account
for i := range accounts {
acc := &accounts[i]
if _, ok := routingSet[acc.ID]; !ok {
continue
}
if _, excluded := excludedIDs[acc.ID]; excluded {
continue
}
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
// avoid selecting accounts that were recently rate-limited/overloaded.
if !acc.IsSchedulable() {
continue
}
if !acc.IsSchedulableForModel(requestedModel) {
continue
}
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
continue
}
if selected == nil {
selected = acc
continue
}
if acc.Priority < selected.Priority {
selected = acc
} else if acc.Priority == selected.Priority {
switch {
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
selected = acc
case acc.LastUsedAt != nil && selected.LastUsedAt == nil:
// keep selected (never used is preferred)
case acc.LastUsedAt == nil && selected.LastUsedAt == nil:
if preferOAuth && acc.Type != selected.Type && acc.Type == AccountTypeOAuth {
selected = acc
}
default:
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
selected = acc
}
}
}
}
if selected != nil {
if sessionHash != "" && s.cache != nil {
if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil {
log.Printf("set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err)
}
}
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] legacy routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), selected.ID)
}
return selected, nil
}
log.Printf("[ModelRouting] No routed accounts available for model=%s, falling back to normal selection", requestedModel)
}
// 1. 查询粘性会话
if sessionHash != "" && s.cache != nil {
accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
......@@ -886,13 +1241,16 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
}
// 2. 获取可调度账号列表(单平台)
forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string)
if hasForcePlatform && forcePlatform == "" {
hasForcePlatform = false
}
accounts, _, err := s.listSchedulableAccounts(ctx, groupID, platform, hasForcePlatform)
if err != nil {
return nil, fmt.Errorf("query accounts failed: %w", err)
if !accountsLoaded {
forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string)
if hasForcePlatform && forcePlatform == "" {
hasForcePlatform = false
}
var err error
accounts, _, err = s.listSchedulableAccounts(ctx, groupID, platform, hasForcePlatform)
if err != nil {
return nil, fmt.Errorf("query accounts failed: %w", err)
}
}
// 3. 按优先级+最久未用选择(考虑模型支持)
......@@ -958,6 +1316,115 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
// 查询原生平台账户 + 启用 mixed_scheduling 的 antigravity 账户
func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, nativePlatform string) (*Account, error) {
preferOAuth := nativePlatform == PlatformGemini
routingAccountIDs := s.routingAccountIDsForRequest(ctx, groupID, requestedModel, nativePlatform)
var accounts []Account
accountsLoaded := false
// ============ Model Routing (legacy path): apply before sticky session ============
if len(routingAccountIDs) > 0 {
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] legacy mixed routed begin: group_id=%v model=%s platform=%s session=%s routed_ids=%v",
derefGroupID(groupID), requestedModel, nativePlatform, shortSessionHash(sessionHash), routingAccountIDs)
}
// 1) Sticky session only applies if the bound account is within the routing set.
if sessionHash != "" && s.cache != nil {
accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
if err == nil && accountID > 0 && containsInt64(routingAccountIDs, accountID) {
if _, excluded := excludedIDs[accountID]; !excluded {
account, err := s.getSchedulableAccount(ctx, accountID)
// 检查账号分组归属和有效性:原生平台直接匹配,antigravity 需要启用混合调度
if err == nil && s.isAccountInGroup(account, groupID) && account.IsSchedulableForModel(requestedModel) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) {
if err := s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL); err != nil {
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
}
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] legacy mixed routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), accountID)
}
return account, nil
}
}
}
}
}
// 2) Select an account from the routed candidates.
var err error
accounts, _, err = s.listSchedulableAccounts(ctx, groupID, nativePlatform, false)
if err != nil {
return nil, fmt.Errorf("query accounts failed: %w", err)
}
accountsLoaded = true
routingSet := make(map[int64]struct{}, len(routingAccountIDs))
for _, id := range routingAccountIDs {
if id > 0 {
routingSet[id] = struct{}{}
}
}
var selected *Account
for i := range accounts {
acc := &accounts[i]
if _, ok := routingSet[acc.ID]; !ok {
continue
}
if _, excluded := excludedIDs[acc.ID]; excluded {
continue
}
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
// avoid selecting accounts that were recently rate-limited/overloaded.
if !acc.IsSchedulable() {
continue
}
// 过滤:原生平台直接通过,antigravity 需要启用混合调度
if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
continue
}
if !acc.IsSchedulableForModel(requestedModel) {
continue
}
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
continue
}
if selected == nil {
selected = acc
continue
}
if acc.Priority < selected.Priority {
selected = acc
} else if acc.Priority == selected.Priority {
switch {
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
selected = acc
case acc.LastUsedAt != nil && selected.LastUsedAt == nil:
// keep selected (never used is preferred)
case acc.LastUsedAt == nil && selected.LastUsedAt == nil:
if preferOAuth && acc.Platform == PlatformGemini && selected.Platform == PlatformGemini && acc.Type != selected.Type && acc.Type == AccountTypeOAuth {
selected = acc
}
default:
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
selected = acc
}
}
}
}
if selected != nil {
if sessionHash != "" && s.cache != nil {
if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil {
log.Printf("set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err)
}
}
if s.debugModelRoutingEnabled() {
log.Printf("[ModelRoutingDebug] legacy mixed routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), selected.ID)
}
return selected, nil
}
log.Printf("[ModelRouting] No routed accounts available for model=%s, falling back to normal selection", requestedModel)
}
// 1. 查询粘性会话
if sessionHash != "" && s.cache != nil {
......@@ -979,9 +1446,12 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
}
// 2. 获取可调度账号列表
accounts, _, err := s.listSchedulableAccounts(ctx, groupID, nativePlatform, false)
if err != nil {
return nil, fmt.Errorf("query accounts failed: %w", err)
if !accountsLoaded {
var err error
accounts, _, err = s.listSchedulableAccounts(ctx, groupID, nativePlatform, false)
if err != nil {
return nil, fmt.Errorf("query accounts failed: %w", err)
}
}
// 3. 按优先级+最久未用选择(考虑模型支持和混合调度)
......
package service
import "time"
import (
"strings"
"time"
)
type Group struct {
ID int64
......@@ -27,6 +30,12 @@ type Group struct {
ClaudeCodeOnly bool
FallbackGroupID *int64
// 模型路由配置
// key: 模型匹配模式(支持 * 通配符,如 "claude-opus-*")
// value: 优先账号 ID 列表
ModelRouting map[string][]int64
ModelRoutingEnabled bool
CreatedAt time.Time
UpdatedAt time.Time
......@@ -90,3 +99,41 @@ func IsGroupContextValid(group *Group) bool {
}
return true
}
// GetRoutingAccountIDs 根据请求模型获取路由账号 ID 列表
// 返回匹配的优先账号 ID 列表,如果没有匹配规则则返回 nil
func (g *Group) GetRoutingAccountIDs(requestedModel string) []int64 {
if !g.ModelRoutingEnabled || len(g.ModelRouting) == 0 || requestedModel == "" {
return nil
}
// 1. 精确匹配优先
if accountIDs, ok := g.ModelRouting[requestedModel]; ok && len(accountIDs) > 0 {
return accountIDs
}
// 2. 通配符匹配(前缀匹配)
for pattern, accountIDs := range g.ModelRouting {
if matchModelPattern(pattern, requestedModel) && len(accountIDs) > 0 {
return accountIDs
}
}
return nil
}
// matchModelPattern 检查模型是否匹配模式
// 支持 * 通配符,如 "claude-opus-*" 匹配 "claude-opus-4-20250514"
func matchModelPattern(pattern, model string) bool {
if pattern == model {
return true
}
// 处理 * 通配符(仅支持末尾通配符)
if strings.HasSuffix(pattern, "*") {
prefix := strings.TrimSuffix(pattern, "*")
return strings.HasPrefix(model, prefix)
}
return false
}
......@@ -545,20 +545,20 @@ func TestOpenAITokenProvider_OAuthServiceNotConfigured(t *testing.T) {
func TestOpenAITokenProvider_TTLCalculation(t *testing.T) {
tests := []struct {
name string
expiresIn time.Duration
name string
expiresIn time.Duration
}{
{
name: "far_future_expiry",
expiresIn: 1 * time.Hour,
name: "far_future_expiry",
expiresIn: 1 * time.Hour,
},
{
name: "medium_expiry",
expiresIn: 10 * time.Minute,
name: "medium_expiry",
expiresIn: 10 * time.Minute,
},
{
name: "near_expiry",
expiresIn: 6 * time.Minute,
name: "near_expiry",
expiresIn: 6 * time.Minute,
},
}
......
......@@ -110,8 +110,8 @@ func TestCompositeTokenCacheInvalidator_SkipNonOAuth(t *testing.T) {
invalidator := NewCompositeTokenCacheInvalidator(cache)
tests := []struct {
name string
account *Account
name string
account *Account
}{
{
name: "gemini_api_key",
......@@ -210,8 +210,8 @@ func TestCompositeTokenCacheInvalidator_DeleteError(t *testing.T) {
invalidator := NewCompositeTokenCacheInvalidator(cache)
tests := []struct {
name string
account *Account
name string
account *Account
}{
{
name: "openai_delete_error",
......
......@@ -276,9 +276,9 @@ func TestTokenRefreshService_RefreshWithRetry_RefreshFailed(t *testing.T) {
err := service.refreshWithRetry(context.Background(), account, refresher)
require.Error(t, err)
require.Equal(t, 0, repo.updateCalls) // 刷新失败不应更新
require.Equal(t, 0, invalidator.calls) // 刷新失败不应触发缓存失效
require.Equal(t, 1, repo.setErrorCalls) // 应设置错误状态
require.Equal(t, 0, repo.updateCalls) // 刷新失败不应更新
require.Equal(t, 0, invalidator.calls) // 刷新失败不应触发缓存失效
require.Equal(t, 1, repo.setErrorCalls) // 应设置错误状态
}
// TestTokenRefreshService_RefreshWithRetry_AntigravityRefreshFailed 测试 Antigravity 刷新失败不设置错误状态
......
-- 040_add_group_model_routing.sql
-- 添加分组级别的模型路由配置功能
-- 添加 model_routing 字段:模型路由配置(JSONB 格式)
-- 格式: {"model_pattern": [account_id1, account_id2], ...}
-- 例如: {"claude-opus-*": [1, 2], "claude-sonnet-*": [3, 4, 5]}
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS model_routing JSONB DEFAULT '{}';
-- 添加字段注释
COMMENT ON COLUMN groups.model_routing IS '模型路由配置:{"model_pattern": [account_id1, account_id2], ...},支持通配符匹配';
-- Add model_routing_enabled field to groups table
ALTER TABLE groups ADD COLUMN model_routing_enabled BOOLEAN NOT NULL DEFAULT false;
......@@ -916,6 +916,26 @@ export default {
fallbackGroup: 'Fallback Group',
fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.',
noFallback: 'No Fallback (Reject)'
},
modelRouting: {
title: 'Model Routing',
tooltip: 'Configure specific model requests to be routed to designated accounts. Supports wildcard matching, e.g., claude-opus-* matches all opus models.',
enabled: 'Enabled',
disabled: 'Disabled',
disabledHint: 'Routing rules will only take effect when enabled',
addRule: 'Add Routing Rule',
modelPattern: 'Model Pattern',
modelPatternPlaceholder: 'claude-opus-*',
modelPatternHint: 'Supports * wildcard, e.g., claude-opus-* matches all opus models',
accounts: 'Priority Accounts',
selectAccounts: 'Select accounts',
noAccounts: 'No accounts in this group',
loadingAccounts: 'Loading accounts...',
removeRule: 'Remove Rule',
noRules: 'No routing rules',
noRulesHint: 'Add routing rules to route specific model requests to designated accounts',
searchAccountPlaceholder: 'Search accounts...',
accountsHint: 'Select accounts to prioritize for this model pattern'
}
},
......
......@@ -992,6 +992,26 @@ export default {
fallbackGroup: '降级分组',
fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝',
noFallback: '不降级(直接拒绝)'
},
modelRouting: {
title: '模型路由配置',
tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。',
enabled: '已启用',
disabled: '已禁用',
disabledHint: '启用后,配置的路由规则才会生效',
addRule: '添加路由规则',
modelPattern: '模型模式',
modelPatternPlaceholder: 'claude-opus-*',
modelPatternHint: '支持 * 通配符,如 claude-opus-* 匹配所有 opus 模型',
accounts: '优先账号',
selectAccounts: '选择账号',
noAccounts: '此分组暂无账号',
loadingAccounts: '加载账号中...',
removeRule: '删除规则',
noRules: '暂无路由规则',
noRulesHint: '添加路由规则以将特定模型请求优先路由到指定账号',
searchAccountPlaceholder: '搜索账号...',
accountsHint: '选择此模型模式优先使用的账号'
}
},
......
......@@ -269,6 +269,9 @@ export interface Group {
// Claude Code 客户端限制
claude_code_only: boolean
fallback_group_id: number | null
// 模型路由配置(仅 anthropic 平台使用)
model_routing: Record<string, number[]> | null
model_routing_enabled: boolean
account_count?: number
created_at: string
updated_at: string
......
......@@ -460,6 +460,149 @@
</div>
</div>
<!-- 模型路由配置 anthropic 平台 -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.modelRouting.title') }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.modelRouting.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<!-- 启用开关 -->
<div class="flex items-center gap-3 mb-3">
<button
type="button"
@click="createForm.model_routing_enabled = !createForm.model_routing_enabled"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.model_routing_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.model_routing_enabled ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ createForm.model_routing_enabled ? t('admin.groups.modelRouting.enabled') : t('admin.groups.modelRouting.disabled') }}
</span>
</div>
<p v-if="!createForm.model_routing_enabled" class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.modelRouting.disabledHint') }}
</p>
<p v-else class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.modelRouting.noRulesHint') }}
</p>
<!-- 路由规则列表仅在启用时显示 -->
<div v-if="createForm.model_routing_enabled" class="space-y-3">
<div
v-for="(rule, index) in createModelRoutingRules"
:key="index"
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<div class="flex items-start gap-3">
<div class="flex-1 space-y-2">
<div>
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.modelPattern') }}</label>
<input
v-model="rule.pattern"
type="text"
class="input text-sm"
:placeholder="t('admin.groups.modelRouting.modelPatternPlaceholder')"
/>
</div>
<div>
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.accounts') }}</label>
<!-- 已选账号标签 -->
<div v-if="rule.accounts.length > 0" class="flex flex-wrap gap-1.5 mb-2">
<span
v-for="account in rule.accounts"
:key="account.id"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ account.name }}
<button
type="button"
@click="removeSelectedAccount(index, account.id, false)"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 账号搜索输入框 -->
<div class="relative account-search-container">
<input
v-model="accountSearchKeyword[`create-${index}`]"
type="text"
class="input text-sm"
:placeholder="t('admin.groups.modelRouting.searchAccountPlaceholder')"
@input="searchAccounts(`create-${index}`)"
@focus="onAccountSearchFocus(index, false)"
/>
<!-- 搜索结果下拉框 -->
<div
v-if="showAccountDropdown[`create-${index}`] && accountSearchResults[`create-${index}`]?.length > 0"
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="account in accountSearchResults[`create-${index}`]"
:key="account.id"
type="button"
@click="selectAccount(index, account, false)"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
:class="{ 'opacity-50': rule.accounts.some(a => a.id === account.id) }"
:disabled="rule.accounts.some(a => a.id === account.id)"
>
<span>{{ account.name }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ account.id }}</span>
</button>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">{{ t('admin.groups.modelRouting.accountsHint') }}</p>
</div>
</div>
<button
type="button"
@click="removeCreateRoutingRule(index)"
class="mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors"
:title="t('admin.groups.modelRouting.removeRule')"
>
<Icon name="trash" size="sm" />
</button>
</div>
</div>
</div>
<!-- 添加规则按钮仅在启用时显示 -->
<button
v-if="createForm.model_routing_enabled"
type="button"
@click="addCreateRoutingRule"
class="mt-3 flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon name="plus" size="sm" />
{{ t('admin.groups.modelRouting.addRule') }}
</button>
</div>
</form>
<template #footer>
......@@ -761,6 +904,149 @@
</div>
</div>
<!-- 模型路由配置 anthropic 平台 -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.modelRouting.title') }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.modelRouting.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<!-- 启用开关 -->
<div class="flex items-center gap-3 mb-3">
<button
type="button"
@click="editForm.model_routing_enabled = !editForm.model_routing_enabled"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.model_routing_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.model_routing_enabled ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ editForm.model_routing_enabled ? t('admin.groups.modelRouting.enabled') : t('admin.groups.modelRouting.disabled') }}
</span>
</div>
<p v-if="!editForm.model_routing_enabled" class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.modelRouting.disabledHint') }}
</p>
<p v-else class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.modelRouting.noRulesHint') }}
</p>
<!-- 路由规则列表仅在启用时显示 -->
<div v-if="editForm.model_routing_enabled" class="space-y-3">
<div
v-for="(rule, index) in editModelRoutingRules"
:key="index"
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<div class="flex items-start gap-3">
<div class="flex-1 space-y-2">
<div>
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.modelPattern') }}</label>
<input
v-model="rule.pattern"
type="text"
class="input text-sm"
:placeholder="t('admin.groups.modelRouting.modelPatternPlaceholder')"
/>
</div>
<div>
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.accounts') }}</label>
<!-- 已选账号标签 -->
<div v-if="rule.accounts.length > 0" class="flex flex-wrap gap-1.5 mb-2">
<span
v-for="account in rule.accounts"
:key="account.id"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ account.name }}
<button
type="button"
@click="removeSelectedAccount(index, account.id, true)"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 账号搜索输入框 -->
<div class="relative account-search-container">
<input
v-model="accountSearchKeyword[`edit-${index}`]"
type="text"
class="input text-sm"
:placeholder="t('admin.groups.modelRouting.searchAccountPlaceholder')"
@input="searchAccounts(`edit-${index}`)"
@focus="onAccountSearchFocus(index, true)"
/>
<!-- 搜索结果下拉框 -->
<div
v-if="showAccountDropdown[`edit-${index}`] && accountSearchResults[`edit-${index}`]?.length > 0"
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="account in accountSearchResults[`edit-${index}`]"
:key="account.id"
type="button"
@click="selectAccount(index, account, true)"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
:class="{ 'opacity-50': rule.accounts.some(a => a.id === account.id) }"
:disabled="rule.accounts.some(a => a.id === account.id)"
>
<span>{{ account.name }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ account.id }}</span>
</button>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">{{ t('admin.groups.modelRouting.accountsHint') }}</p>
</div>
</div>
<button
type="button"
@click="removeEditRoutingRule(index)"
class="mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors"
:title="t('admin.groups.modelRouting.removeRule')"
>
<Icon name="trash" size="sm" />
</button>
</div>
</div>
</div>
<!-- 添加规则按钮仅在启用时显示 -->
<button
v-if="editForm.model_routing_enabled"
type="button"
@click="addEditRoutingRule"
class="mt-3 flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon name="plus" size="sm" />
{{ t('admin.groups.modelRouting.addRule') }}
</button>
</div>
</form>
<template #footer>
......@@ -816,7 +1102,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useOnboardingStore } from '@/stores/onboarding'
......@@ -956,9 +1242,160 @@ const createForm = reactive({
image_price_4k: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null
fallback_group_id: null as number | null,
// 模型路由开关
model_routing_enabled: false
})
// 简单账号类型(用于模型路由选择)
interface SimpleAccount {
id: number
name: string
}
// 模型路由规则类型
interface ModelRoutingRule {
pattern: string
accounts: SimpleAccount[] // 选中的账号对象数组
}
// 创建表单的模型路由规则
const createModelRoutingRules = ref<ModelRoutingRule[]>([])
// 编辑表单的模型路由规则
const editModelRoutingRules = ref<ModelRoutingRule[]>([])
// 账号搜索相关状态
const accountSearchKeyword = ref<Record<string, string>>({}) // 每个规则的搜索关键词 (key: "create-0" 或 "edit-0")
const accountSearchResults = ref<Record<string, SimpleAccount[]>>({}) // 每个规则的搜索结果
const showAccountDropdown = ref<Record<string, boolean>>({}) // 每个规则的下拉框显示状态
let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
// 搜索账号(仅限 anthropic 平台)
const searchAccounts = async (key: string) => {
if (accountSearchTimeout) clearTimeout(accountSearchTimeout)
accountSearchTimeout = setTimeout(async () => {
const keyword = accountSearchKeyword.value[key] || ''
try {
const res = await adminAPI.accounts.list(1, 20, {
search: keyword,
platform: 'anthropic'
})
accountSearchResults.value[key] = res.items.map((a) => ({ id: a.id, name: a.name }))
} catch {
accountSearchResults.value[key] = []
}
}, 300)
}
// 选择账号
const selectAccount = (ruleIndex: number, account: SimpleAccount, isEdit: boolean = false) => {
const rules = isEdit ? editModelRoutingRules.value : createModelRoutingRules.value
const rule = rules[ruleIndex]
if (!rule) return
// 检查是否已选择
if (!rule.accounts.some(a => a.id === account.id)) {
rule.accounts.push(account)
}
// 清空搜索
const key = `${isEdit ? 'edit' : 'create'}-${ruleIndex}`
accountSearchKeyword.value[key] = ''
showAccountDropdown.value[key] = false
}
// 移除已选账号
const removeSelectedAccount = (ruleIndex: number, accountId: number, isEdit: boolean = false) => {
const rules = isEdit ? editModelRoutingRules.value : createModelRoutingRules.value
const rule = rules[ruleIndex]
if (!rule) return
rule.accounts = rule.accounts.filter(a => a.id !== accountId)
}
// 处理账号搜索输入框聚焦
const onAccountSearchFocus = (ruleIndex: number, isEdit: boolean = false) => {
const key = `${isEdit ? 'edit' : 'create'}-${ruleIndex}`
showAccountDropdown.value[key] = true
// 如果没有搜索结果,触发一次搜索
if (!accountSearchResults.value[key]?.length) {
searchAccounts(key)
}
}
// 添加创建表单的路由规则
const addCreateRoutingRule = () => {
createModelRoutingRules.value.push({ pattern: '', accounts: [] })
}
// 删除创建表单的路由规则
const removeCreateRoutingRule = (index: number) => {
createModelRoutingRules.value.splice(index, 1)
// 清理相关的搜索状态
const key = `create-${index}`
delete accountSearchKeyword.value[key]
delete accountSearchResults.value[key]
delete showAccountDropdown.value[key]
}
// 添加编辑表单的路由规则
const addEditRoutingRule = () => {
editModelRoutingRules.value.push({ pattern: '', accounts: [] })
}
// 删除编辑表单的路由规则
const removeEditRoutingRule = (index: number) => {
editModelRoutingRules.value.splice(index, 1)
// 清理相关的搜索状态
const key = `edit-${index}`
delete accountSearchKeyword.value[key]
delete accountSearchResults.value[key]
delete showAccountDropdown.value[key]
}
// 将 UI 格式的路由规则转换为 API 格式
const convertRoutingRulesToApiFormat = (rules: ModelRoutingRule[]): Record<string, number[]> | null => {
const result: Record<string, number[]> = {}
let hasValidRules = false
for (const rule of rules) {
const pattern = rule.pattern.trim()
if (!pattern) continue
const accountIds = rule.accounts.map(a => a.id).filter(id => id > 0)
if (accountIds.length > 0) {
result[pattern] = accountIds
hasValidRules = true
}
}
return hasValidRules ? result : null
}
// 将 API 格式的路由规则转换为 UI 格式(需要加载账号名称)
const convertApiFormatToRoutingRules = async (apiFormat: Record<string, number[]> | null): Promise<ModelRoutingRule[]> => {
if (!apiFormat) return []
const rules: ModelRoutingRule[] = []
for (const [pattern, accountIds] of Object.entries(apiFormat)) {
// 加载账号信息
const accounts: SimpleAccount[] = []
for (const id of accountIds) {
try {
const account = await adminAPI.accounts.getById(id)
accounts.push({ id: account.id, name: account.name })
} catch {
// 如果账号不存在,仍然显示 ID
accounts.push({ id, name: `#${id}` })
}
}
rules.push({ pattern, accounts })
}
return rules
}
const editForm = reactive({
name: '',
description: '',
......@@ -976,7 +1413,9 @@ const editForm = reactive({
image_price_4k: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null
fallback_group_id: null as number | null,
// 模型路由开关
model_routing_enabled: false
})
// 根据分组类型返回不同的删除确认消息
......@@ -1058,6 +1497,7 @@ const closeCreateModal = () => {
createForm.image_price_4k = null
createForm.claude_code_only = false
createForm.fallback_group_id = null
createModelRoutingRules.value = []
}
const handleCreateGroup = async () => {
......@@ -1067,7 +1507,12 @@ const handleCreateGroup = async () => {
}
submitting.value = true
try {
await adminAPI.groups.create(createForm)
// 构建请求数据,包含模型路由配置
const requestData = {
...createForm,
model_routing: convertRoutingRulesToApiFormat(createModelRoutingRules.value)
}
await adminAPI.groups.create(requestData)
appStore.showSuccess(t('admin.groups.groupCreated'))
closeCreateModal()
loadGroups()
......@@ -1084,7 +1529,7 @@ const handleCreateGroup = async () => {
}
}
const handleEdit = (group: Group) => {
const handleEdit = async (group: Group) => {
editingGroup.value = group
editForm.name = group.name
editForm.description = group.description || ''
......@@ -1101,12 +1546,16 @@ const handleEdit = (group: Group) => {
editForm.image_price_4k = group.image_price_4k
editForm.claude_code_only = group.claude_code_only || false
editForm.fallback_group_id = group.fallback_group_id
editForm.model_routing_enabled = group.model_routing_enabled || false
// 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing)
showEditModal.value = true
}
const closeEditModal = () => {
showEditModal.value = false
editingGroup.value = null
editModelRoutingRules.value = []
}
const handleUpdateGroup = async () => {
......@@ -1121,7 +1570,8 @@ const handleUpdateGroup = async () => {
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const payload = {
...editForm,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
model_routing: convertRoutingRulesToApiFormat(editModelRoutingRules.value)
}
await adminAPI.groups.update(editingGroup.value.id, payload)
appStore.showSuccess(t('admin.groups.groupUpdated'))
......@@ -1166,7 +1616,23 @@ watch(
}
)
// 点击外部关闭账号搜索下拉框
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
// 检查是否点击在下拉框或输入框内
if (!target.closest('.account-search-container')) {
Object.keys(showAccountDropdown.value).forEach(key => {
showAccountDropdown.value[key] = false
})
}
}
onMounted(() => {
loadGroups()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment