Skip to content
This repository was archived by the owner on Oct 22, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions cmd/wild-work/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -685,15 +685,16 @@ func main() {
// 硬冷却截到每日发奖窗口(方案 B):qwenwork 每日 00:00 UTC+8 发额度,
// 账本实测 00:01~00:20 到账。固定 12h 会把 19:07 的 402 锁到次日 07:07,
// 白锁 7 小时;按窗口截断后 00:30 即解冻,额度一到账就能用。
HardCooldownFor: cfg.HardCooldownFor,
SoftCooldown: cfg.SoftRateDur,
ErrThreshold: cfg.Cooldown.ErrThresh,
ErrCooldown: cfg.ErrCooldownDur,
ModelFallbacks: cfg.ModelFallback, // 启动时载入降级链(此前仅面板保存后热更新,重启即丢)
ReasoningEfforts: cfg.ReasoningEfforts, // 启动时载入思考强度映射(同上)
MaxRequestBody: int64(cfg.Server.MaxRequestBodyMB) << 20, // 0 = server 默认 128MiB
WebUI: sub,
AttachAPI: appInst.HandleAPI,
HardCooldownFor: cfg.HardCooldownFor,
SoftCooldown: cfg.SoftRateDur,
ErrThreshold: cfg.Cooldown.ErrThresh,
ErrCooldown: cfg.ErrCooldownDur,
ModelFallbacks: cfg.ModelFallback, // 启动时载入降级链(此前仅面板保存后热更新,重启即丢)
ReasoningEfforts: cfg.ReasoningEfforts, // 启动时载入思考强度映射(同上)
MaxRequestBody: int64(cfg.Server.MaxRequestBodyMB) << 20, // 0 = server 默认 128MiB
StickyMaxRequests: cfg.Server.StickyMaxRequests,
WebUI: sub,
AttachAPI: appInst.HandleAPI,
})
inner.SetCustomRuntimes(initCustomServerRts)

Expand Down
3 changes: 2 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"timeout_seconds": 120
},
"server": {
"max_request_body_mb": 128
"max_request_body_mb": 128,
"sticky_max_requests": 50
},
"login": {
"lobsterai_callback_port": 0
Expand Down
14 changes: 14 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"log"
"net"
"os"
"path/filepath"
Expand Down Expand Up @@ -149,6 +150,10 @@ type Config struct {
// 多张截图的 base64 会线性累积;gateway 转发前会把图缩到 1568px/JPEG,
// 所以这个上限只需容纳**客户端原始体**,不代表上游会收到这么大的包(issue #176)。
MaxRequestBodyMB int `json:"max_request_body_mb,omitempty"`

// StickyMaxRequests 同一渠道粘性账号连续成功使用多少次后轮换。
// 默认 50;非法值回退默认值,不阻断启动。
StickyMaxRequests int `json:"sticky_max_requests"`
} `json:"server"`

// Login 登录流程的可调项。
Expand Down Expand Up @@ -248,6 +253,9 @@ type ModelFallbackRule struct {
To []string `json:"to"`
}

// DefaultStickyMaxRequests 粘性路由的默认轮换阈值。
const DefaultStickyMaxRequests = 50

// Default 默认配置。
func Default() *Config {
c := &Config{
Expand All @@ -269,6 +277,7 @@ func Default() *Config {
c.Schedule.CheckinTimes = []string{"09:00", "21:00"}
c.Schedule.KeepaliveHours = []int{22}
c.Upstream.TimeoutSeconds = 120
c.Server.StickyMaxRequests = DefaultStickyMaxRequests
c.Compat.DefaultChannel = "workbuddy"
c.Compat.MaxTokensCap = 32000
c.Features.LoopGuard = true // 思考链死循环检测默认开启(面板可关)
Expand Down Expand Up @@ -520,6 +529,11 @@ func (c *Config) normalize() error {
if c.Server.MaxRequestBodyMB > 1024 {
c.Server.MaxRequestBodyMB = 1024
}
if c.Server.StickyMaxRequests <= 0 {
log.Printf("WARN config: server.sticky_max_requests=%d invalid, fallback=%d",
c.Server.StickyMaxRequests, DefaultStickyMaxRequests)
c.Server.StickyMaxRequests = DefaultStickyMaxRequests
}
if c.Listen.Port <= 0 {
c.Listen.Port = 7863
}
Expand Down
101 changes: 101 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package config

import (
"bytes"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)

Expand All @@ -20,6 +24,102 @@ func TestDefault(t *testing.T) {
if c.HardCreditDur.Hours() != 12 {
t.Errorf("hard=%v", c.HardCreditDur)
}
if c.Server.StickyMaxRequests != DefaultStickyMaxRequests {
t.Errorf("server.sticky_max_requests=%d, want %d", c.Server.StickyMaxRequests, DefaultStickyMaxRequests)
}
}

func TestStickyMaxRequestsLegacyConfigDefaultsWithoutWriting(t *testing.T) {
t.Setenv("WILDWORK_API_KEY", "")
dir := t.TempDir()
fp := filepath.Join(dir, "config.json")
before := []byte(`{"server":{"max_request_body_mb":128}}`)
if err := os.WriteFile(fp, before, 0o600); err != nil {
t.Fatal(err)
}

c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Server.StickyMaxRequests != DefaultStickyMaxRequests {
t.Fatalf("缺字段应使用默认值 %d,实际 %d", DefaultStickyMaxRequests, c.Server.StickyMaxRequests)
}
after, err := os.ReadFile(fp)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(after, before) {
t.Fatalf("加载配置不得主动写盘:\nbefore=%s\nafter=%s", before, after)
}

if err := Save(c, fp); err != nil {
t.Fatal(err)
}
saved, err := os.ReadFile(fp)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(saved, []byte(`"sticky_max_requests": 50`)) {
t.Fatalf("用户保存配置后应写入默认值:%s", saved)
}
}

func TestStickyMaxRequestsExplicitValue(t *testing.T) {
t.Setenv("WILDWORK_API_KEY", "")
dir := t.TempDir()
fp := filepath.Join(dir, "config.json")
if err := os.WriteFile(fp, []byte(`{"server":{"sticky_max_requests":7}}`), 0o600); err != nil {
t.Fatal(err)
}
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Server.StickyMaxRequests != 7 {
t.Fatalf("显式配置应为 7,实际 %d", c.Server.StickyMaxRequests)
}
}

func TestStickyMaxRequestsInvalidFallsBackWithWarning(t *testing.T) {
for _, value := range []int{0, -3} {
t.Run(strconv.Itoa(value), func(t *testing.T) {
t.Setenv("WILDWORK_API_KEY", "")
dir := t.TempDir()
fp := filepath.Join(dir, "config.json")
raw := []byte(`{"server":{"sticky_max_requests":` + strconv.Itoa(value) + `}}`)
if err := os.WriteFile(fp, raw, 0o600); err != nil {
t.Fatal(err)
}

var logs bytes.Buffer
oldOutput, oldFlags := log.Writer(), log.Flags()
log.SetOutput(&logs)
log.SetFlags(0)
defer func() {
log.SetOutput(oldOutput)
log.SetFlags(oldFlags)
}()

c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Server.StickyMaxRequests != DefaultStickyMaxRequests {
t.Fatalf("非法值应回退为 %d,实际 %d", DefaultStickyMaxRequests, c.Server.StickyMaxRequests)
}
if got := logs.String(); !strings.Contains(got, "WARN config: server.sticky_max_requests=") {
t.Fatalf("非法值应记录可检索的 warning,实际日志:%q", got)
}
after, err := os.ReadFile(fp)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(after, raw) {
t.Fatalf("非法值只在内存回退,不得加载时主动写盘:\nbefore=%s\nafter=%s", raw, after)
}
})
}
}

func TestListenParsing(t *testing.T) {
Expand Down Expand Up @@ -48,6 +148,7 @@ func TestListenParsing(t *testing.T) {
}

func TestLoadFile(t *testing.T) {
t.Setenv("WILDWORK_API_KEY", "")
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"listen":"127.0.0.1:9999","api_key":"k","region":"cn"}`), 0o600)
Expand Down
12 changes: 8 additions & 4 deletions internal/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ type Config struct {
// MaxRequestBody 请求体上限(字节)。0 = 用 DefaultMaxRequestBody(128MiB)。
MaxRequestBody int64

// StickyMaxRequests 同渠道粘性账号连续成功使用多少次后轮换。0 = 默认 50。
StickyMaxRequests int

HardCooldown time.Duration
SoftCooldown time.Duration
ErrThreshold int
Expand Down Expand Up @@ -197,6 +200,9 @@ func NewHandler(cfg Config) *Handler {
if cfg.MaxRequestBody > 0 {
MaxRequestBody = cfg.MaxRequestBody
}
if cfg.StickyMaxRequests <= 0 {
cfg.StickyMaxRequests = config.DefaultStickyMaxRequests
}
h := &Handler{cfg: cfg, mux: http.NewServeMux(), sticky: make(map[string]*stickyEntry), ledger: cfg.Ledger, stats: cfg.Stats, fallbacks: cfg.ModelFallbacks, efforts: cfg.ReasoningEfforts}
h.mux.HandleFunc("POST /v1/chat/completions", h.withAuth(h.chatCompletions))
h.mux.HandleFunc("POST /v1/images/generations", h.withAuth(h.imagesGenerations))
Expand Down Expand Up @@ -232,8 +238,6 @@ func (h *Handler) stickyKey(kind provider.Kind) string { return kind.String() }
//
// 任一条件触发则降级为 PickForModel(model) 选新账号并重置粘性记录。
func (h *Handler) pickWithSticky(rt *Runtime, model string) *auth.Auth {
const defaultMaxReqs = 50

h.stickyMu.RLock()
sticky := h.sticky[h.stickyKey(rt.Kind)]
h.stickyMu.RUnlock()
Expand Down Expand Up @@ -269,9 +273,9 @@ func (h *Handler) pickWithSticky(rt *Runtime, model string) *auth.Auth {

// 新建粘性记录
h.stickyMu.Lock()
h.sticky[h.stickyKey(rt.Kind)] = &stickyEntry{uid: acct.UID, maxReqs: defaultMaxReqs}
h.sticky[h.stickyKey(rt.Kind)] = &stickyEntry{uid: acct.UID, maxReqs: h.cfg.StickyMaxRequests}
h.stickyMu.Unlock()
log.Printf("new sticky route platform=%s uid=%s maxReqs=%d", rt.Kind, acct.UID, defaultMaxReqs)
log.Printf("new sticky route platform=%s uid=%s maxReqs=%d", rt.Kind, acct.UID, h.cfg.StickyMaxRequests)
return acct
}

Expand Down
69 changes: 69 additions & 0 deletions internal/server/sticky_max_requests_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package server

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"wild-work/internal/auth"
"wild-work/internal/config"
"wild-work/internal/pool"
"wild-work/internal/provider"
)

// TestStickyMaxRequestsFromConfig 粘性轮换阈值来自 Handler 配置,而不是包内硬编码。
func TestStickyMaxRequestsFromConfig(t *testing.T) {
p := pool.New("")
p.Add(&auth.Auth{UID: "A", AccessToken: "at-A", ExpiresAt: time.Now().Add(time.Hour).Unix()})
p.Add(&auth.Auth{UID: "B", AccessToken: "at-B", ExpiresAt: time.Now().Add(time.Hour).Unix()})
p.SetCreditDetail("A", 1000, 0, 0, time.Time{})
p.SetCreditDetail("B", 500, 0, 0, time.Time{})
up := &kindedUpstream{status: http.StatusOK}
up.succeed = func(string) bool { return true }
rt := &Runtime{Kind: provider.GLM, Pool: p, Upstream: up}
h := NewHandler(Config{
Runtimes: map[provider.Kind]*Runtime{provider.GLM: rt},
StickyMaxRequests: 2,
})

if got := h.pickWithSticky(rt, "m"); got == nil || got.UID != "A" {
t.Fatalf("首次应选余额更高的 A,实际 %+v", got)
}
if got := h.StickySnapshot()["glm"]; got.UID != "A" || got.Max != 2 {
t.Fatalf("粘性记录应使用配置阈值 2,实际 %+v", got)
}

for i := 0; i < 2; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
strings.NewReader(`{"model":"glm/m","messages":[{"role":"user","content":"hi"}],"stream":false}`))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("第 %d 次请求应成功,实际 status=%d body=%s", i+1, rec.Code, rec.Body.String())
}
}
h.stickyMu.RLock()
count := h.sticky[rt.Kind.String()].reqCount
h.stickyMu.RUnlock()
if count != 2 {
t.Fatalf("连续成功 2 次后粘性计数应达到阈值 2")
}
if got := h.pickWithSticky(rt, "m"); got == nil {
t.Fatal("阈值耗尽后应重新选号")
}
if got := h.StickySnapshot()["glm"]; got.Max != 2 || got.Count != 0 {
t.Fatalf("阈值耗尽后应重建计数并继续使用配置阈值 2,实际 %+v", got)
}
}

// TestStickyMaxRequestsDefaultForLegacyHandlerCall 直接构造 Handler 的旧调用方
// 未传新字段时仍保持原有默认 50。
func TestStickyMaxRequestsDefaultForLegacyHandlerCall(t *testing.T) {
h := NewHandler(Config{})
if h.cfg.StickyMaxRequests != config.DefaultStickyMaxRequests {
t.Fatalf("未注入时应回落默认值 %d,实际 %d",
config.DefaultStickyMaxRequests, h.cfg.StickyMaxRequests)
}
}