diff --git a/.env.example b/.env.example index 4f1dd3b4..db3602cb 100644 --- a/.env.example +++ b/.env.example @@ -48,9 +48,12 @@ WARM_STARTUP_CONCURRENCY=5 # 单账号同时处理的请求数 PER_ACCOUNT_CONCURRENCY=2 -# 账户选择策略:round-robin 或 fill-first +# 账户选择策略:round-robin、fill-first 或 key-affinity(在有效期内优先复用同一 API key 上一次命中的账户,不可用时从已预热账户轮询切换并绑定新账户,无 key 时按轮询分配;当前仅聊天生成请求生效) ROUTING_STRATEGY=round-robin +# key-affinity 策略下同一 API key 保持使用上一次命中账户的有效时长 +KEY_AFFINITY_TTL=1h + # 生成请求使用的上游通道:playground、build(旧通道,上游已停用其生成),逗号分隔 UPSTREAM_CHANNELS=playground diff --git a/README.md b/README.md index 568abde5..4c6e6aad 100644 --- a/README.md +++ b/README.md @@ -577,7 +577,8 @@ cp .env.example .env | `MAX_ACTIVE_WORKERS` | `10` | 高峰期最多同时运行的 Worker 数 | | `WARM_STARTUP_CONCURRENCY` | `5` | 同时冷启动的 Camoufox Worker 数,预热与按需扩容共用;`WAA_BACKEND=spidermonkey` 时不限制 | | `PER_ACCOUNT_CONCURRENCY` | `2` | 单账号同时执行的请求数 | -| `ROUTING_STRATEGY` | `round-robin` | `round-robin` 轮询;`fill-first` 账号粘性优先 | +| `ROUTING_STRATEGY` | `round-robin` | `round-robin` 轮询;`fill-first` 账号粘性优先;`key-affinity` 在有效期内优先复用同一 API key 上一次命中的账户,不可用时从已预热账户轮询切换并绑定新账户(不回跳),过期或无 key 时按轮询分配(当前仅聊天生成请求生效) | +| `KEY_AFFINITY_TTL` | `1h` | `key-affinity` 策略下同一 API key 保持使用上一次命中账户的有效时长(如 `60m`、`1h`),保存后立即生效 | | `UPSTREAM_CHANNELS` | `playground` | 生成请求使用的上游通道,`playground`、`build` 逗号分隔;`build` 为旧通道,上游已停用其生成 | | `MODEL_MAPPING` | 空 | 客户端模型名到目录模型的映射,每项写成 `别名:模型 ID`,逗号分隔多项 | | `MODEL_CHANNELS` | 空 | 限定模型只走一个通道,每项写成 `模型 ID:playground` 或 `模型 ID:build`;也可在“模型目录”的模型详情中选择,保存后立即生效 | diff --git a/README_en.md b/README_en.md index f19ba0cf..1b2d5848 100644 --- a/README_en.md +++ b/README_en.md @@ -577,7 +577,8 @@ cp .env.example .env | `MAX_ACTIVE_WORKERS` | `10` | Maximum workers active during peak load | | `WARM_STARTUP_CONCURRENCY` | `5` | Camoufox workers cold-started concurrently, shared by prewarming and on-demand scaling; unlimited with `WAA_BACKEND=spidermonkey` | | `PER_ACCOUNT_CONCURRENCY` | `2` | Concurrent requests allowed per account | -| `ROUTING_STRATEGY` | `round-robin` | `round-robin` rotates accounts; `fill-first` reuses the first available account | +| `ROUTING_STRATEGY` | `round-robin` | `round-robin` rotates accounts; `fill-first` reuses the first available account; `key-affinity` reuses the last matched account for each API key within the affinity TTL, switching via round-robin within the prewarmed pool and sticking to the new account on failover, and rotating again once expired or when no key is matched (currently applies to chat generation requests only) | +| `KEY_AFFINITY_TTL` | `1h` | Duration an API key stays on its last used account under `key-affinity` (e.g. `60m`, `1h`), applied on save | | `UPSTREAM_CHANNELS` | `playground` | Upstream channels for generation requests, `playground` and `build` comma separated; `build` is the legacy channel whose generation upstream has disabled | | `MODEL_MAPPING` | empty | Client model names mapped to catalog models, each item `alias:model ID`, comma separated | | `MODEL_CHANNELS` | empty | Limit a model to one channel, each item `model ID:playground` or `model ID:build`; also selectable in model details on the Model catalog page, applied on save | diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index 04c296e2..d7203a78 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -252,8 +252,10 @@ type AccountSelection struct { PlaygroundOnly bool Channel Channel PreferredChannel Channel - anyTier bool - anyState bool + // StickySeed 是会话亲和种子,仅 key-affinity 策略用于候选账户排序 + StickySeed string + anyTier bool + anyState bool } const preferredBootstrapModelID = "gemini-flash-latest" @@ -289,6 +291,12 @@ type AccountStore struct { paths []string } +// stickyBinding 记录亲和种子上次命中的账户与更新时间 +type stickyBinding struct { + accountID string + updatedAt time.Time +} + // AccountPool 在账户之间执行能力与并发槽位调度 type AccountPool struct { mu sync.Mutex @@ -297,6 +305,8 @@ type AccountPool struct { resources map[string]string perAccountConcurrency int routingStrategy string + keyAffinityTTL time.Duration + stickyBySeed map[string]stickyBinding lastPicked map[string]string lastPickedChannel map[string]Channel channels []Channel @@ -739,7 +749,8 @@ func NewAccountPool(accounts []*Account, perAccountConcurrency int) *AccountPool p := &AccountPool{ accounts: append([]*Account(nil), accounts...), byID: make(map[string]*Account, len(accounts)), resources: make(map[string]string), perAccountConcurrency: perAccountConcurrency, changed: make(chan struct{}), - routingStrategy: "round-robin", lastPicked: make(map[string]string), lastPickedChannel: make(map[string]Channel), + routingStrategy: "round-robin", keyAffinityTTL: 60 * time.Minute, stickyBySeed: make(map[string]stickyBinding), + lastPicked: make(map[string]string), lastPickedChannel: make(map[string]Channel), } for _, account := range p.accounts { if account.runtime.Cooldowns == nil { @@ -2459,6 +2470,20 @@ func (p *AccountPool) tryAcquireLocked(selection AccountSelection, now time.Time account.LastUsed = now p.lastPicked[selectionAccessScope(selection)] = account.ID p.lastPickedChannel[selectionAccessScope(selection)] = candidate.channel + if seed := strings.TrimSpace(selection.StickySeed); seed != "" && p.routingStrategy == "key-affinity" { + for k, v := range p.stickyBySeed { + if now.Sub(v.updatedAt) > p.keyAffinityTTL { + delete(p.stickyBySeed, k) + } + } + prev, exists := p.stickyBySeed[seed] + if !exists || now.Sub(prev.updatedAt) > p.keyAffinityTTL || prev.accountID == account.ID || !p.stickyAccountHealthyLocked(prev.accountID, selection, now) { + p.stickyBySeed[seed] = stickyBinding{accountID: account.ID, updatedAt: now} + } else { + // 原主账户健康且仅因当前并发槽满临时溢出借用其他账户,保持原主账户绑定不变,防止多并发时主账户轮换漂移 + p.stickyBySeed[seed] = stickyBinding{accountID: prev.accountID, updatedAt: now} + } + } return &AccountLease{ pool: p, account: account, authGeneration: account.authGeneration, modelAccessGeneration: account.modelAccessGeneration, refreshRuntime: refreshRuntime, checkedAt: now.UTC(), @@ -2608,21 +2633,107 @@ func (p *AccountPool) selectionIndicesLocked(selection AccountSelection) ([]int, sort.Slice(indices, func(left, right int) bool { return p.accounts[indices[left]].ID < p.accounts[indices[right]].ID }) - if p.routingStrategy == "round-robin" { + if p.routingStrategy == "round-robin" || p.routingStrategy == "key-affinity" { last := p.lastPicked[selectionAccessScope(selection)] start := sort.Search(len(indices), func(index int) bool { return p.accounts[indices[index]].ID > last }) indices = append(indices[start:], indices[:start]...) } + if p.routingStrategy == "key-affinity" { + now := time.Now() + if targetID := p.stickyAccountIDLocked(selection.StickySeed, now); targetID != "" { + for pos, idx := range indices { + if p.accounts[idx].ID == targetID { + if pos > 0 { + rotated := make([]int, 0, len(indices)) + rotated = append(rotated, idx) + rotated = append(rotated, indices[:pos]...) + rotated = append(rotated, indices[pos+1:]...) + indices = rotated + } + break + } + } + } else if strings.TrimSpace(selection.StickySeed) != "" && len(indices) > 1 { + // 新种子或需要重绑的种子优先分配当前被绑定次数最少的账户(平局按 round-robin 顺序),避免多个 Key 扎堆绑定同一账户 + counts := make(map[string]int, len(indices)) + for k, v := range p.stickyBySeed { + if now.Sub(v.updatedAt) <= p.keyAffinityTTL { + counts[v.accountID]++ + } else { + delete(p.stickyBySeed, k) + } + } + sort.SliceStable(indices, func(left, right int) bool { + return counts[p.accounts[indices[left]].ID] < counts[p.accounts[indices[right]].ID] + }) + } + } return indices, nil } -// SetRoutingStrategy 设置账户轮询或优先填满策略 +// stickyAccountIDLocked 返回种子在有效期内绑定的上次账户 ID,未绑定或已过期时返回空 +func (p *AccountPool) stickyAccountIDLocked(seed string, now time.Time) string { + seed = strings.TrimSpace(seed) + if seed == "" { + return "" + } + binding, exists := p.stickyBySeed[seed] + if !exists { + return "" + } + if now.Sub(binding.updatedAt) > p.keyAffinityTTL { + delete(p.stickyBySeed, seed) + return "" + } + return binding.accountID +} + +// stickyAccountHealthyLocked 判断已绑定的主账户是否仍然启用、就绪且未全部冷却(忽略瞬时并发槽满) +func (p *AccountPool) stickyAccountHealthyLocked(accountID string, selection AccountSelection, now time.Time) bool { + account := p.byID[accountID] + if account == nil || !account.Config.Enabled || account.State != AccountReady { + return false + } + if selection.ModelID != "" && !p.accountSupportsAnyChannelLocked(account, selection) { + return false + } + if _, cooling := p.accountChannelCooldownLocked(account, selection, now); cooling { + return false + } + return true +} + +// StickyAccountID 返回 key-affinity 策略下种子当前未过期的绑定主账户,未启用或未绑定时返回空 +func (p *AccountPool) StickyAccountID(seed string) string { + p.mu.Lock() + defer p.mu.Unlock() + if p.routingStrategy != "key-affinity" { + return "" + } + return p.stickyAccountIDLocked(seed, time.Now()) +} + +// RoutingStrategy 返回当前账户选择策略 +func (p *AccountPool) RoutingStrategy() string { + p.mu.Lock() + defer p.mu.Unlock() + return p.routingStrategy +} + +// SetRoutingStrategy 设置账户轮询、优先填满或按 API key 亲和策略 func (p *AccountPool) SetRoutingStrategy(strategy string) { p.mu.Lock() p.routingStrategy = strategy p.mu.Unlock() } +// SetKeyAffinityTTL 设置 key-affinity 策略下同一 API key 保持同一账户的有效期 +func (p *AccountPool) SetKeyAffinityTTL(ttl time.Duration) { + p.mu.Lock() + p.keyAffinityTTL = ttl + p.mu.Unlock() +} + // SetSessionCheck 设置认证失败后确认账户会话是否失效的检查 func (p *AccountPool) SetSessionCheck(check func(context.Context, *Account) error) { p.mu.Lock() diff --git a/internal/aistudio/accounts_affinity_test.go b/internal/aistudio/accounts_affinity_test.go new file mode 100644 index 00000000..28953059 --- /dev/null +++ b/internal/aistudio/accounts_affinity_test.go @@ -0,0 +1,201 @@ +package aistudio + +import ( + "context" + "testing" + "time" +) + +// affinityTestPool 构造三个 gemini 账户的 key-affinity 测试池,单账户并发为 1 +func affinityTestPool(t *testing.T, strategy string) *AccountPool { + t.Helper() + model := Model{ID: "gemini", Methods: []string{"generateContent"}, Capabilities: map[string]bool{"chat_model": true}} + accounts := []*Account{ + {ID: "a@example.com", Config: AccountConfig{Enabled: true}, State: AccountReady, Models: []Model{model}, modelAccessGeneration: 1}, + {ID: "b@example.com", Config: AccountConfig{Enabled: true}, State: AccountReady, Models: []Model{model}, modelAccessGeneration: 1}, + {ID: "c@example.com", Config: AccountConfig{Enabled: true}, State: AccountReady, Models: []Model{model}, modelAccessGeneration: 1}, + } + pool := NewAccountPool(accounts, 1) + pool.SetRoutingStrategy(strategy) + pool.SetUpstreamChannels([]Channel{ChannelPlayground}) + return pool +} + +func affinitySelection(seed string) AccountSelection { + return AccountSelection{ModelID: "gemini", Method: "generateContent", StickySeed: seed} +} + +func acquireAffinity(t *testing.T, pool *AccountPool, selection AccountSelection) (*AccountLease, string) { + t.Helper() + lease, _, err := pool.TryAcquireFor(context.Background(), selection) + if err != nil || lease == nil { + t.Fatalf("lease=%v err=%v", lease, err) + } + return lease, lease.Account().ID +} + +// TestKeyAffinityDeterministic 同一种子在有效期内反复获取都粘在同一账户 +func TestKeyAffinityDeterministic(t *testing.T) { + pool := affinityTestPool(t, "key-affinity") + lease, first := acquireAffinity(t, pool, affinitySelection("seed-a")) + if err := lease.Release(); err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 3; attempt++ { + lease, got := acquireAffinity(t, pool, affinitySelection("seed-a")) + if got != first { + t.Fatalf("attempt %d: account=%s want=%s", attempt, got, first) + } + if err := lease.Release(); err != nil { + t.Fatal(err) + } + } +} + +// TestKeyAffinitySpreadsSeeds 首次出现的不同种子按轮询依次分配到不同账户(不会碰撞扎堆) +func TestKeyAffinitySpreadsSeeds(t *testing.T) { + pool := affinityTestPool(t, "key-affinity") + seen := make(map[string]bool) + for _, seed := range []string{"seed-1", "seed-2", "seed-3"} { + lease, got := acquireAffinity(t, pool, affinitySelection(seed)) + seen[got] = true + if err := lease.Release(); err != nil { + t.Fatal(err) + } + } + if len(seen) != 3 { + t.Fatalf("3 个新种子只分到 %d 个账户: %v", len(seen), seen) + } +} + +// TestKeyAffinityTransientConcurrencyOverflowPreservesPrimary 主账户仅因并发槽满临时溢出借用其他账户时,不改绑主账户;主账户冷却或不可用时才改绑 +func TestKeyAffinityTransientConcurrencyOverflowPreservesPrimary(t *testing.T) { + pool := affinityTestPool(t, "key-affinity") + // 1. seed-a 首次获取 -> 绑定主账户 first + lease1, first := acquireAffinity(t, pool, affinitySelection("seed-a")) + // 2. 不释放 lease1(模拟同一 Key 的并发子请求到达,此时 first 仅并发满但未冷却) + lease2, second := acquireAffinity(t, pool, affinitySelection("seed-a")) + if second == first { + t.Fatalf("期望并发溢出借用其他账户,仍拿到 %s", second) + } + // 3. 两个并发请求均结束释放 + if err := lease2.Release(); err != nil { + t.Fatal(err) + } + if err := lease1.Release(); err != nil { + t.Fatal(err) + } + // 4. seed-a 下一轮主请求到达 -> 必须继续留在原主账户 first(不会像 A A B -> B B C 那样漂移到 second) + lease3, third := acquireAffinity(t, pool, affinitySelection("seed-a")) + if err := lease3.Release(); err != nil { + t.Fatal(err) + } + if third != first { + t.Fatalf("并发溢出后主账户从 %s 漂移到了 %s", first, third) + } + // 5. 当主账户 first 进入冷却(真正不可用)时,seed-a 切换到新账户并正式改绑到新账户(冷却恢复后也不回跳) + if err := pool.MarkCooldownIfGeneration(first, ChannelCooldownScope(ChannelPlayground, "gemini"), 1, time.Now(), time.Now().Add(time.Hour), "rate_limit"); err != nil { + t.Fatal(err) + } + lease4, rebound := acquireAffinity(t, pool, affinitySelection("seed-a")) + if err := lease4.Release(); err != nil { + t.Fatal(err) + } + if rebound == first { + t.Fatalf("冷却账户 %s 仍被选中", first) + } + // 清除原账户 first 的冷却 + pool.ClearCooldown(first, ChannelCooldownScope(ChannelPlayground, "gemini")) + lease5, afterClear := acquireAffinity(t, pool, affinitySelection("seed-a")) + defer func() { + if err := lease5.Release(); err != nil { + t.Fatal(err) + } + }() + if afterClear != rebound { + t.Fatalf("冷却改绑后又跳回了 %s,期望留在新主账户 %s", afterClear, rebound) + } + if sticky := pool.StickyAccountID("seed-a"); sticky != rebound { + t.Fatalf("StickyAccountID=%s want=%s", sticky, rebound) + } +} + +// TestKeyAffinityTTLExpiration 超过亲和有效期后重新按轮询分配账户 +func TestKeyAffinityTTLExpiration(t *testing.T) { + pool := affinityTestPool(t, "key-affinity") + pool.SetKeyAffinityTTL(20 * time.Millisecond) + lease1, first := acquireAffinity(t, pool, affinitySelection("seed-a")) + if err := lease1.Release(); err != nil { + t.Fatal(err) + } + // 等待超过 TTL + time.Sleep(35 * time.Millisecond) + // 过期后再次请求应推进轮询到下一个账户 + lease2, second := acquireAffinity(t, pool, affinitySelection("seed-a")) + defer func() { + if err := lease2.Release(); err != nil { + t.Fatal(err) + } + }() + if second == first { + t.Fatalf("TTL 过期后仍停留在 %s,期望按轮询分配新账户", second) + } +} + +// TestKeyAffinityWithinAllowedSubset 当上次粘性账户不在 AllowedAccountIDs 子集内时从子集中轮询选取 +func TestKeyAffinityWithinAllowedSubset(t *testing.T) { + pool := affinityTestPool(t, "key-affinity") + lease1, first := acquireAffinity(t, pool, affinitySelection("seed-a")) + if err := lease1.Release(); err != nil { + t.Fatal(err) + } + var subset []string + for _, id := range []string{"a@example.com", "b@example.com", "c@example.com"} { + if id != first { + subset = append(subset, id) + } + } + selection := affinitySelection("seed-a") + selection.AllowedAccountIDs = subset + lease2, second := acquireAffinity(t, pool, selection) + defer func() { + if err := lease2.Release(); err != nil { + t.Fatal(err) + } + }() + if second == first { + t.Fatalf("got excluded account %s", second) + } +} + +// TestKeyAffinityEmptySeedRotates 空种子回退到 round-robin 轮转,避免无 Key 时把全部流量压向单个账户 +func TestKeyAffinityEmptySeedRotates(t *testing.T) { + pool := affinityTestPool(t, "key-affinity") + seen := make(map[string]bool) + for attempt := 0; attempt < 4; attempt++ { + lease, got := acquireAffinity(t, pool, affinitySelection("")) + seen[got] = true + if err := lease.Release(); err != nil { + t.Fatal(err) + } + } + if len(seen) < 2 { + t.Fatalf("key-affinity 空种子 4 次获取都落在同一账户 %v,期望回退轮转", seen) + } +} + +// TestKeyAffinityIgnoredUnderRoundRobin round-robin 策略忽略种子继续按账户与通道轮转 +func TestKeyAffinityIgnoredUnderRoundRobin(t *testing.T) { + pool := affinityTestPool(t, "round-robin") + seen := make(map[string]bool) + for attempt := 0; attempt < 4; attempt++ { + lease, got := acquireAffinity(t, pool, affinitySelection("seed-a")) + seen[got] = true + if err := lease.Release(); err != nil { + t.Fatal(err) + } + } + if len(seen) < 2 { + t.Fatalf("round-robin 下同种子 4 次获取都落在 %v", seen) + } +} diff --git a/internal/aistudio/channel.go b/internal/aistudio/channel.go index 771ddbb4..82b97d2b 100644 --- a/internal/aistudio/channel.go +++ b/internal/aistudio/channel.go @@ -229,7 +229,7 @@ func (p *AccountPool) accountChannelCooldownLocked(account *Account, selection A return earliest, !earliest.Until.IsZero() } -// channelCandidatesLocked 按账户 ID 与通道顺序展开候选,轮询策略从上次选中组合之后开始 +// channelCandidatesLocked 按账户与通道顺序展开候选,轮询策略(及无种子的 key-affinity)从上次选中组合之后开始 func (p *AccountPool) channelCandidatesLocked(indices []int, selection AccountSelection) []channelCandidate { channels := p.selectionChannelsLocked(selection) candidates := make([]channelCandidate, 0, len(indices)*len(channels)) @@ -238,6 +238,12 @@ func (p *AccountPool) channelCandidatesLocked(indices []int, selection AccountSe candidates = append(candidates, channelCandidate{index: index, channel: channel}) } } + if p.routingStrategy == "fill-first" || (p.routingStrategy == "key-affinity" && strings.TrimSpace(selection.StickySeed) != "") { + return candidates + } + if (p.routingStrategy != "round-robin" && p.routingStrategy != "key-affinity") || len(indices) <= 1 && len(channels) <= 1 { + return candidates + } rank := func(channel Channel) int { return slices.Index(channels, channel) } sort.SliceStable(candidates, func(left, right int) bool { leftID, rightID := p.accounts[candidates[left].index].ID, p.accounts[candidates[right].index].ID @@ -246,9 +252,6 @@ func (p *AccountPool) channelCandidatesLocked(indices []int, selection AccountSe } return rank(candidates[left].channel) < rank(candidates[right].channel) }) - if p.routingStrategy != "round-robin" || len(indices) <= 1 && len(channels) <= 1 { - return candidates - } key := selectionAccessScope(selection) lastAccount := p.lastPicked[key] lastChannel := p.lastPickedChannel[key] diff --git a/internal/api/admin.go b/internal/api/admin.go index 8d5c456e..9f6dd0e7 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -280,6 +280,7 @@ type RuntimeConfig struct { WarmStartupConcurrency int `json:"warm_startup_concurrency"` PerAccountConcurrency int `json:"per_account_concurrency"` RoutingStrategy string `json:"routing_strategy"` + KeyAffinityTTL string `json:"key_affinity_ttl"` UpstreamChannels []string `json:"upstream_channels"` ModelMapping string `json:"model_mapping"` ModelChannels string `json:"model_channels"` diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 25f2cf78..d04bf335 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -2,7 +2,9 @@ package api import ( "context" + "crypto/sha256" "crypto/subtle" + "encoding/hex" "errors" "net" "net/http" @@ -19,6 +21,15 @@ import ( type accessLogContextKey struct{} +// apiKeyAffinitySeedContextKey 是请求上下文中 API key 亲和种子的键 +type apiKeyAffinitySeedContextKey struct{} + +// APIKeyAffinitySeed 返回请求命中的 API key 的 SHA-256 十六进制亲和种子,未配置或未命中 key 时为空 +func APIKeyAffinitySeed(ctx context.Context) string { + seed, _ := ctx.Value(apiKeyAffinitySeedContextKey{}).(string) + return seed +} + type accessLogMetadata struct { mu sync.Mutex admin AdminService @@ -491,10 +502,15 @@ func authMiddleware(configuredKeys string, next http.Handler) http.Handler { } } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if len(keys) > 0 && !checkAPIKeyMatches(r, keys) { + matched := matchAPIKey(r, keys) + if len(keys) > 0 && matched == "" { writeAuthError(w, r) return } + if matched != "" { + sum := sha256.Sum256([]byte(matched)) + r = r.WithContext(context.WithValue(r.Context(), apiKeyAffinitySeedContextKey{}, hex.EncodeToString(sum[:]))) + } markAccessLogAuthorized(r.Context()) next.ServeHTTP(w, r) }) @@ -514,19 +530,19 @@ func apiKeyCandidates(r *http.Request) []string { return candidates } -// checkAPIKeyMatches 判断请求头或 URL 中任一 API key 是否与配置的某个 key 相同 -func checkAPIKeyMatches(r *http.Request, keys []string) bool { +// matchAPIKey 返回请求头或 URL 中与配置的某个 key 相同的候选 API key,未命中时为空 +func matchAPIKey(r *http.Request, keys []string) string { for _, cand := range apiKeyCandidates(r) { if cand == "" { continue } for _, key := range keys { if subtle.ConstantTimeCompare([]byte(cand), []byte(key)) == 1 { - return true + return cand } } } - return false + return "" } // bearerToken 返回 Authorization Bearer 凭据 diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 7dca6cfe..47c8b3e7 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -1,6 +1,8 @@ package api import ( + "crypto/sha256" + "encoding/hex" "net/http" "net/http/httptest" "testing" @@ -64,3 +66,42 @@ func TestAuthMiddlewarePriorityAndMatching(t *testing.T) { }) } } + +// TestAuthMiddlewareAPIKeyAffinitySeed 验证命中的 API key 以 SHA-256 种子进入请求上下文 +func TestAuthMiddlewareAPIKeyAffinitySeed(t *testing.T) { + seedOf := func(key string) string { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) + } + tests := []struct { + name string + keys string + headers map[string]string + wantSeed string + }{ + {name: "bearer k2", keys: "k1,k2", headers: map[string]string{"Authorization": "Bearer k2"}, wantSeed: seedOf("k2")}, + {name: "x-goog k1", keys: "k1,k2", headers: map[string]string{"X-Goog-API-Key": "k1"}, wantSeed: seedOf("k1")}, + {name: "no keys configured", keys: "", wantSeed: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSeed := "unset" + handler := authMiddleware(tt.keys, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotSeed = APIKeyAffinitySeed(r.Context()) + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if gotSeed != tt.wantSeed { + t.Errorf("seed = %q, want %q", gotSeed, tt.wantSeed) + } + }) + } +} diff --git a/internal/app/admin.go b/internal/app/admin.go index 20c8f063..e27fc118 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -867,6 +867,10 @@ func (admin *runtimeAdmin) UpdateRuntimeConfig(_ context.Context, value api.Runt if err != nil { return api.RuntimeConfig{}, invalidConfigError(fmt.Errorf("REQUEST_TIMEOUT 无效: %w", err)) } + keyAffinityTTL, err := time.ParseDuration(value.KeyAffinityTTL) + if err != nil { + return api.RuntimeConfig{}, invalidConfigError(fmt.Errorf("KEY_AFFINITY_TTL 无效: %w", err)) + } cfg := config.Config{ AutoStart: value.AutoStart, RequestBodyLog: value.RequestBodyLog, @@ -882,6 +886,7 @@ func (admin *runtimeAdmin) UpdateRuntimeConfig(_ context.Context, value api.Runt WarmStartupConcurrency: value.WarmStartupConcurrency, PerAccountConcurrency: value.PerAccountConcurrency, RoutingStrategy: value.RoutingStrategy, + KeyAffinityTTL: keyAffinityTTL, UpstreamChannels: value.UpstreamChannels, ModelMapping: strings.TrimSpace(value.ModelMapping), ModelChannels: strings.TrimSpace(value.ModelChannels), @@ -897,6 +902,7 @@ func (admin *runtimeAdmin) UpdateRuntimeConfig(_ context.Context, value api.Runt return api.RuntimeConfig{}, err } admin.pool.SetRoutingStrategy(cfg.RoutingStrategy) + admin.pool.SetKeyAffinityTTL(cfg.KeyAffinityTTL) admin.pool.SetModelChannels(modelChannels(cfg.ModelChannels)) admin.requests.log("service", "INFO", "服务配置已保存") models, _ := admin.CatalogModels(context.Background()) @@ -1182,6 +1188,7 @@ func runtimeConfigDTO(cfg config.Config) api.RuntimeConfig { WarmStartupConcurrency: cfg.WarmStartupConcurrency, PerAccountConcurrency: cfg.PerAccountConcurrency, RoutingStrategy: cfg.RoutingStrategy, + KeyAffinityTTL: cfg.KeyAffinityTTL.String(), UpstreamChannels: cfg.UpstreamChannels, ModelMapping: cfg.ModelMapping, ModelChannels: cfg.ModelChannels, diff --git a/internal/app/admin_config_test.go b/internal/app/admin_config_test.go index fb709a3f..cbe02dea 100644 --- a/internal/app/admin_config_test.go +++ b/internal/app/admin_config_test.go @@ -68,7 +68,17 @@ func TestAdminConfig(t *testing.T) { t.Fatalf("empty enabled password error = %v", err) } value.AdminAuthEnabled = false + value.RoutingStrategy = "key-affinity" + value.KeyAffinityTTL = "0s" + if _, err := admin.UpdateRuntimeConfig(ctx, value); err == nil { + t.Fatal("zero KeyAffinityTTL was accepted") + } + value.KeyAffinityTTL = "90m" if _, err := admin.UpdateRuntimeConfig(ctx, value); err != nil { t.Fatal(err) } + saved, err = config.Load(path) + if err != nil || saved.KeyAffinityTTL.String() != "1h30m0s" { + t.Fatalf("saved KeyAffinityTTL = %v, err = %v", saved.KeyAffinityTTL, err) + } } diff --git a/internal/app/dispatch.go b/internal/app/dispatch.go index dfbf3c8f..801142aa 100644 --- a/internal/app/dispatch.go +++ b/internal/app/dispatch.go @@ -25,13 +25,17 @@ func newDispatchQueue() *dispatchQueue { return &dispatchQueue{queues: make(map[string][]*dispatchWaiter)} } -// dispatchKey 返回可互相替代的请求共用的队列键 -func dispatchKey(selection aistudio.AccountSelection) string { +// dispatchKey 返回可互相替代的请求共用的队列键,仅在 key-affinity 策略下按亲和种子拆分队列 +func dispatchKey(selection aistudio.AccountSelection, routingStrategy string) string { allowed := slices.Clone(selection.AllowedAccountIDs) slices.Sort(allowed) + seed := "" + if routingStrategy == "key-affinity" { + seed = strings.TrimSpace(selection.StickySeed) + } return strings.Join([]string{ selection.ModelID, selection.Method, selection.Capability, selection.AccountID, selection.ResourceID, - strings.Join(allowed, ","), strconv.FormatBool(selection.PlaygroundOnly), + strings.Join(allowed, ","), strconv.FormatBool(selection.PlaygroundOnly), seed, }, "\x00") } diff --git a/internal/app/dispatch_test.go b/internal/app/dispatch_test.go new file mode 100644 index 00000000..0ae909a4 --- /dev/null +++ b/internal/app/dispatch_test.go @@ -0,0 +1,27 @@ +package app + +import ( + "testing" + + "github.com/Mag1cFall/AIStudio2API/internal/aistudio" +) + +// TestDispatchKeyIncludesStickySeed 仅在 key-affinity 策略下将种子纳入队列键,其他策略不受影响 +func TestDispatchKeyIncludesStickySeed(t *testing.T) { + base := aistudio.AccountSelection{ModelID: "gemini", Method: "generateContent"} + withSeedA, withSeedB := base, base + withSeedA.StickySeed = "seed-a" + withSeedB.StickySeed = "seed-b" + if dispatchKey(withSeedA, "key-affinity") == dispatchKey(withSeedB, "key-affinity") { + t.Error("key-affinity 下不同亲和种子的 dispatchKey 相同") + } + if dispatchKey(withSeedA, "key-affinity") != dispatchKey(withSeedA, "key-affinity") { + t.Error("相同亲和种子的 dispatchKey 不同") + } + if dispatchKey(withSeedA, "round-robin") != dispatchKey(withSeedB, "round-robin") { + t.Error("round-robin 下不同亲和种子不应拆分队列") + } + if dispatchKey(withSeedA, "fill-first") != dispatchKey(withSeedB, "fill-first") { + t.Error("fill-first 下不同亲和种子不应拆分队列") + } +} diff --git a/internal/app/runtime.go b/internal/app/runtime.go index eb27840a..205a1e0c 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -66,6 +66,7 @@ func newRuntime( requests.log("service", "INFO", "运行时装配 | 3/3 | 创建协议客户端 | 出口="+egress) pool := aistudio.NewAccountPool(accounts, cfg.PerAccountConcurrency) pool.SetRoutingStrategy(cfg.RoutingStrategy) + pool.SetKeyAffinityTTL(cfg.KeyAffinityTTL) pool.SetUpstreamChannels(upstreamChannels(cfg.UpstreamChannels)) pool.SetModelChannels(modelChannels(cfg.ModelChannels)) proxies := aistudio.NewProxyPool(pool, proxyList) @@ -1271,10 +1272,11 @@ func (service *trackedService) acquireWarmLease(ctx context.Context, selection a failedWorkers := make(map[string]struct{}) var promoteFailure error var waiter *dispatchWaiter + var stickyWaitUntil time.Time defer func() { service.workers.dispatch.leave(waiter) }() wait := func(delay time.Duration) error { if waiter == nil { - waiter = service.workers.dispatch.join(dispatchKey(selection)) + waiter = service.workers.dispatch.join(dispatchKey(selection, service.pool.RoutingStrategy())) } return waitScheduling(ctx, waiter.wake, delay) } @@ -1302,6 +1304,21 @@ func (service *trackedService) acquireWarmLease(ctx context.Context, selection a standbyReady, opening := service.workers.withoutOpening(groups.StandbyReady) groups.StandbyReady = standbyReady warmAvailable := append(append([]string(nil), groups.WarmReady...), groups.WarmAvailable...) + if !fixedAccount && len(warmAvailable) > 0 { + if stickyID := service.pool.StickyAccountID(selection.StickySeed); stickyID != "" && + !slices.Contains(warmAvailable, stickyID) && slices.Contains(groups.WarmBusy, stickyID) { + now := time.Now() + if stickyWaitUntil.IsZero() { + stickyWaitUntil = now.Add(affinityBusyWaitLimit) + } + if remaining := stickyWaitUntil.Sub(now); remaining > 0 { + if err := wait(min(remaining, schedulingRecheck)); err != nil { + return nil, err + } + continue + } + } + } if len(warmAvailable) > 0 { candidate := selection candidate.AllowedAccountIDs = warmAvailable @@ -1423,6 +1440,9 @@ func (service *trackedService) acquireWarmLease(ctx context.Context, selection a // schedulingRecheck 为等待调度信号时的定时复查间隔 const schedulingRecheck = time.Second +// affinityBusyWaitLimit 为 key-affinity 策略下绑定主账户仅因并发槽满时排队等待原账户释放的最长时间,超时后再借用其他热账户 +const affinityBusyWaitLimit = 3 * time.Second + // cooldownQueueLimit 为全部候选账户冷却时请求排队等待恢复的上限,覆盖分钟限额窗口 const cooldownQueueLimit = time.Minute @@ -1710,6 +1730,7 @@ func (service *trackedService) generateWithRetry( ModelID: modelID, Method: "generateContent", AccountID: selectionAccountID, ResourceID: selectionResourceID, } + selection.StickySeed = api.APIKeyAffinitySeed(requestCtx) if resourceID != "" || request.Config.SpeechConfig != nil && request.Config.SpeechConfig.Mode != "" { selection.PlaygroundOnly = true } diff --git a/internal/config/config.go b/internal/config/config.go index 5ea62b70..b15b9301 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,7 @@ const ( defaultMaxActiveWorkers = 10 defaultWarmConcurrency = 5 defaultAccountConcurrency = 2 + defaultKeyAffinityTTL = 60 * time.Minute ) var configKeys = [...]string{ @@ -39,6 +40,7 @@ var configKeys = [...]string{ "WARM_STARTUP_CONCURRENCY", "PER_ACCOUNT_CONCURRENCY", "ROUTING_STRATEGY", + "KEY_AFFINITY_TTL", "UPSTREAM_CHANNELS", "MODEL_MAPPING", "MODEL_CHANNELS", @@ -121,6 +123,7 @@ type Config struct { WarmStartupConcurrency int PerAccountConcurrency int RoutingStrategy string + KeyAffinityTTL time.Duration UpstreamChannels []string ModelMapping string ModelChannels string @@ -152,6 +155,7 @@ func Default() Config { WarmStartupConcurrency: defaultWarmConcurrency, PerAccountConcurrency: defaultAccountConcurrency, RoutingStrategy: "round-robin", + KeyAffinityTTL: defaultKeyAffinityTTL, UpstreamChannels: []string{"playground"}, WAABackend: WAABackendCamoufox, MediaOutput: MediaOutputInline, @@ -309,6 +313,12 @@ func Load(path string) (Config, error) { if value, ok := values["ROUTING_STRATEGY"]; ok { cfg.RoutingStrategy = strings.TrimSpace(value) } + if value, ok := values["KEY_AFFINITY_TTL"]; ok { + cfg.KeyAffinityTTL, err = parsePositiveDuration("KEY_AFFINITY_TTL", value) + if err != nil { + return Config{}, err + } + } if value, ok := values["UPSTREAM_CHANNELS"]; ok { cfg.UpstreamChannels = parseUpstreamChannels(value) } @@ -373,6 +383,7 @@ func (c Config) Save(path string) error { "WARM_STARTUP_CONCURRENCY": strconv.Itoa(c.WarmStartupConcurrency), "PER_ACCOUNT_CONCURRENCY": strconv.Itoa(c.PerAccountConcurrency), "ROUTING_STRATEGY": c.RoutingStrategy, + "KEY_AFFINITY_TTL": c.KeyAffinityTTL.String(), "UPSTREAM_CHANNELS": strings.Join(c.UpstreamChannels, ","), "MODEL_MAPPING": c.ModelMapping, "MODEL_CHANNELS": c.ModelChannels, @@ -432,8 +443,11 @@ func (c Config) Validate() error { if c.PerAccountConcurrency <= 0 { return fmt.Errorf("PER_ACCOUNT_CONCURRENCY 必须是正整数") } - if c.RoutingStrategy != "round-robin" && c.RoutingStrategy != "fill-first" { - return fmt.Errorf("ROUTING_STRATEGY 必须是 round-robin 或 fill-first") + if c.RoutingStrategy != "round-robin" && c.RoutingStrategy != "fill-first" && c.RoutingStrategy != "key-affinity" { + return fmt.Errorf("ROUTING_STRATEGY 必须是 round-robin、fill-first 或 key-affinity") + } + if c.KeyAffinityTTL <= 0 { + return fmt.Errorf("KEY_AFFINITY_TTL 必须是正数时长") } if err := validateUpstreamChannels(c.UpstreamChannels); err != nil { return err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 24347901..1d1c5619 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,10 @@ package config -import "testing" +import ( + "path/filepath" + "testing" + "time" +) // TestValidateProxyCredentials 验证代理 URL 接受账号密码并拒绝空用户名 func TestValidateProxyCredentials(t *testing.T) { @@ -13,3 +17,36 @@ func TestValidateProxyCredentials(t *testing.T) { t.Error("空用户名被接受") } } + +// TestValidateRoutingStrategyKeyAffinity 验证路由策略接受 key-affinity 并拒绝未知值,且支持配置亲和有效时长 +func TestValidateRoutingStrategyKeyAffinity(t *testing.T) { + cfg := Default() + if cfg.KeyAffinityTTL != 60*time.Minute { + t.Fatalf("默认 KeyAffinityTTL = %v, want 60m", cfg.KeyAffinityTTL) + } + cfg.RoutingStrategy = "key-affinity" + if err := cfg.Validate(); err != nil { + t.Errorf("key-affinity 被拒绝: %v", err) + } + cfg.RoutingStrategy = "bogus" + if err := cfg.Validate(); err == nil { + t.Error("未知策略被接受") + } + cfg.RoutingStrategy = "key-affinity" + cfg.KeyAffinityTTL = 0 + if err := cfg.Validate(); err == nil { + t.Error("零值 KeyAffinityTTL 被接受") + } + cfg.KeyAffinityTTL = 45 * time.Minute + path := filepath.Join(t.TempDir(), ".env") + if err := cfg.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.KeyAffinityTTL != 45*time.Minute { + t.Errorf("加载后 KeyAffinityTTL = %v, want 45m", loaded.KeyAffinityTTL) + } +} diff --git a/internal/i18n/messages/de.json b/internal/i18n/messages/de.json index 61f45403..fbaaff24 100644 --- a/internal/i18n/messages/de.json +++ b/internal/i18n/messages/de.json @@ -290,6 +290,9 @@ "REQUEST_BODY_LOG 必须是 true 或 false": "REQUEST_BODY_LOG muss true oder false sein", "REQUEST_TIMEOUT 必须是正数时长": "REQUEST_TIMEOUT muss eine positive Dauer sein", "REQUEST_TIMEOUT 无效: %w": "REQUEST_TIMEOUT ist ungültig: {0}", + "KEY_AFFINITY_TTL 必须是正数时长": "KEY_AFFINITY_TTL muss eine positive Dauer sein", + "KEY_AFFINITY_TTL 无效: %w": "KEY_AFFINITY_TTL ist ungültig: {0}", + "ROUTING_STRATEGY 必须是 round-robin、fill-first 或 key-affinity": "ROUTING_STRATEGY muss round-robin, fill-first oder key-affinity sein", "ROUTING_STRATEGY 必须是 round-robin 或 fill-first": "ROUTING_STRATEGY muss round-robin oder fill-first sein", "SEARCH_MODEL_ALIASES 必须是 true 或 false": "SEARCH_MODEL_ALIASES muss true oder false sein", "SOCKS 版本 %d 不受支持": "SOCKS-Version {0} wird nicht unterstützt", diff --git a/internal/i18n/messages/en.json b/internal/i18n/messages/en.json index e1d04813..9c401668 100644 --- a/internal/i18n/messages/en.json +++ b/internal/i18n/messages/en.json @@ -290,6 +290,9 @@ "REQUEST_BODY_LOG 必须是 true 或 false": "REQUEST_BODY_LOG must be true or false", "REQUEST_TIMEOUT 必须是正数时长": "REQUEST_TIMEOUT must be a positive duration", "REQUEST_TIMEOUT 无效: %w": "Invalid REQUEST_TIMEOUT: {0}", + "KEY_AFFINITY_TTL 必须是正数时长": "KEY_AFFINITY_TTL must be a positive duration", + "KEY_AFFINITY_TTL 无效: %w": "Invalid KEY_AFFINITY_TTL: {0}", + "ROUTING_STRATEGY 必须是 round-robin、fill-first 或 key-affinity": "ROUTING_STRATEGY must be round-robin, fill-first, or key-affinity", "ROUTING_STRATEGY 必须是 round-robin 或 fill-first": "ROUTING_STRATEGY must be round-robin or fill-first", "SEARCH_MODEL_ALIASES 必须是 true 或 false": "SEARCH_MODEL_ALIASES must be true or false", "SOCKS 版本 %d 不受支持": "Unsupported SOCKS version {0}", diff --git a/internal/i18n/messages/fr.json b/internal/i18n/messages/fr.json index bd80c81c..d8e08929 100644 --- a/internal/i18n/messages/fr.json +++ b/internal/i18n/messages/fr.json @@ -290,6 +290,9 @@ "REQUEST_BODY_LOG 必须是 true 或 false": "REQUEST_BODY_LOG doit être true ou false", "REQUEST_TIMEOUT 必须是正数时长": "REQUEST_TIMEOUT doit être une durée positive", "REQUEST_TIMEOUT 无效: %w": "REQUEST_TIMEOUT non valide : {0}", + "KEY_AFFINITY_TTL 必须是正数时长": "KEY_AFFINITY_TTL doit être une durée positive", + "KEY_AFFINITY_TTL 无效: %w": "KEY_AFFINITY_TTL non valide : {0}", + "ROUTING_STRATEGY 必须是 round-robin、fill-first 或 key-affinity": "ROUTING_STRATEGY doit être round-robin, fill-first ou key-affinity", "ROUTING_STRATEGY 必须是 round-robin 或 fill-first": "ROUTING_STRATEGY doit être round-robin ou fill-first", "SEARCH_MODEL_ALIASES 必须是 true 或 false": "SEARCH_MODEL_ALIASES doit être true ou false", "SOCKS 版本 %d 不受支持": "Version SOCKS {0} non prise en charge", diff --git a/internal/i18n/messages/ja.json b/internal/i18n/messages/ja.json index 2717d66b..7051193d 100644 --- a/internal/i18n/messages/ja.json +++ b/internal/i18n/messages/ja.json @@ -290,6 +290,9 @@ "REQUEST_BODY_LOG 必须是 true 或 false": "REQUEST_BODY_LOG は true または false である必要があります", "REQUEST_TIMEOUT 必须是正数时长": "REQUEST_TIMEOUT は正の時間である必要があります", "REQUEST_TIMEOUT 无效: %w": "REQUEST_TIMEOUT が無効です: {0}", + "KEY_AFFINITY_TTL 必须是正数时长": "KEY_AFFINITY_TTL は正の時間である必要があります", + "KEY_AFFINITY_TTL 无效: %w": "KEY_AFFINITY_TTL が無効です: {0}", + "ROUTING_STRATEGY 必须是 round-robin、fill-first 或 key-affinity": "ROUTING_STRATEGY は round-robin、fill-first または key-affinity である必要があります", "ROUTING_STRATEGY 必须是 round-robin 或 fill-first": "ROUTING_STRATEGY は round-robin または fill-first である必要があります", "SEARCH_MODEL_ALIASES 必须是 true 或 false": "SEARCH_MODEL_ALIASES は true または false である必要があります", "SOCKS 版本 %d 不受支持": "SOCKS バージョン {0} はサポートされていません", diff --git a/internal/i18n/messages/ko.json b/internal/i18n/messages/ko.json index a31ca95c..f7340ea9 100644 --- a/internal/i18n/messages/ko.json +++ b/internal/i18n/messages/ko.json @@ -290,6 +290,9 @@ "REQUEST_BODY_LOG 必须是 true 或 false": "REQUEST_BODY_LOG는 true 또는 false여야 합니다", "REQUEST_TIMEOUT 必须是正数时长": "REQUEST_TIMEOUT은 양수 시간이어야 합니다", "REQUEST_TIMEOUT 无效: %w": "REQUEST_TIMEOUT이 잘못되었습니다: {0}", + "KEY_AFFINITY_TTL 必须是正数时长": "KEY_AFFINITY_TTL은 양수 시간이어야 합니다", + "KEY_AFFINITY_TTL 无效: %w": "KEY_AFFINITY_TTL이 잘못되었습니다: {0}", + "ROUTING_STRATEGY 必须是 round-robin、fill-first 或 key-affinity": "ROUTING_STRATEGY는 round-robin, fill-first 또는 key-affinity여야 합니다", "ROUTING_STRATEGY 必须是 round-robin 或 fill-first": "ROUTING_STRATEGY는 round-robin 또는 fill-first여야 합니다", "SEARCH_MODEL_ALIASES 必须是 true 或 false": "SEARCH_MODEL_ALIASES는 true 또는 false여야 합니다", "SOCKS 版本 %d 不受支持": "SOCKS 버전 {0}은(는) 지원되지 않습니다", diff --git a/internal/i18n/messages/zh-TW.json b/internal/i18n/messages/zh-TW.json index c51db579..e6857a2f 100644 --- a/internal/i18n/messages/zh-TW.json +++ b/internal/i18n/messages/zh-TW.json @@ -290,6 +290,9 @@ "REQUEST_BODY_LOG 必须是 true 或 false": "REQUEST_BODY_LOG 必須是 true 或 false", "REQUEST_TIMEOUT 必须是正数时长": "REQUEST_TIMEOUT 必須是正數時長", "REQUEST_TIMEOUT 无效: %w": "REQUEST_TIMEOUT 無效: {0}", + "KEY_AFFINITY_TTL 必须是正数时长": "KEY_AFFINITY_TTL 必須是正數時長", + "KEY_AFFINITY_TTL 无效: %w": "KEY_AFFINITY_TTL 無效: {0}", + "ROUTING_STRATEGY 必须是 round-robin、fill-first 或 key-affinity": "ROUTING_STRATEGY 必須是 round-robin、fill-first 或 key-affinity", "ROUTING_STRATEGY 必须是 round-robin 或 fill-first": "ROUTING_STRATEGY 必須是 round-robin 或 fill-first", "SEARCH_MODEL_ALIASES 必须是 true 或 false": "SEARCH_MODEL_ALIASES 必須是 true 或 false", "SOCKS 版本 %d 不受支持": "SOCKS 版本 {0} 不支援", diff --git a/web/src/components/SettingsPanel.vue b/web/src/components/SettingsPanel.vue index fc6bb804..6700c67f 100644 --- a/web/src/components/SettingsPanel.vue +++ b/web/src/components/SettingsPanel.vue @@ -67,6 +67,7 @@ const sections: { 'warm_startup_concurrency', 'per_account_concurrency', 'routing_strategy', + 'key_affinity_ttl', ], }, { @@ -533,8 +534,26 @@ async function saveConfig(): Promise { + + + +