Skip to content

Commit 378ecd2

Browse files
committed
feat: 积分自动切换(阈值切号 + 失败 failover + 权威校正 + 被动扣减)
- store: 快照增加 cooldown_until(硬耗尽冷却到下次重置点、失败冷却 5 分钟);SaveSnapshots 批量落盘供 30s stats 循环复用; AutoSwitch.Threshold 为绝对积分值(默认 DefaultAutoSwitchThreshold=20, 校验下限 1、不设上限——所有账号余额都低于阈值时由状态机留守) - bridge: ResolvedBridge 增加 AccountID(并发失败去重依据);新增 FailoverFunc(nil 保留原行为)与 UsageHook 两个可选接缝;AuthError 在响应未提交时(StreamReportedError/context.Canceled 之外)交回调 换 bridge 原地重试一次,错误映射阶梯提取为 handleChatError 共用 - main: bridgeProvider 扩展为切换宿主(不新增组件,复用 SetPAT 惰性 重建、PAT-change 清快照与 patWriteMu 竞争防护)——autoSwitcher 状 态机(跳过冷却号、余额充足优先+5% 滞回、60s 最小驻留、无候选留守 并 WARN)+ 三信号接入:60s 账号循环权威回写快照(IsQuotaExceeded 进硬冷却)、chat 失败冷却切号、usage 帧扣减触发阈值切换 (remaining <= threshold,不依赖 Total>0 前提);快照随 30s stats 循环批量落盘 - admin: /admin/api/accounts/auto 开关+阈值 - web: 账号页新增「自动切换」设置卡(阈值输入为「剩余积分」); 页面契约与行为 harness 同步 - tests: 切换状态机(候选选择/阈值/开关/驻留/并发去重/冷却边界)、 handler failover 三路径(重试成功/放弃回 502/nil 兼容)、快照批 量落盘与冷却持久化、大阈值持久化
1 parent b6e4813 commit 378ecd2

16 files changed

Lines changed: 1142 additions & 164 deletions

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
- **多模态支持** —— 支持图片输入
1414
- **Tool Calls** —— 支持函数调用
1515
- **模型动态加载** —— 自动从网关获取可用模型列表
16-
- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、多账号池(多 PAT 添加 / 一键切换 / 额度检测)、模型查看、请求统计(总量 / 成功失败 / 总输入 / 总输出 / 缓存命中 / **本服务实际扣费额度与均值** / 本周期扣费与重置倒计时 / 按模型消耗 / 近 24 小时趋势)
16+
- **多账号池与自动切换** —— 支持多个 Qoder PAT 池化管理:手动一键切换、按需额度检测(临时会话查一次真实余额);自动切换开启后,激活账号剩余积分低于绝对阈值(默认 20 积分,面板可调)时自动切到余额最多的账号(防振荡滞回 + 最小驻留期),请求撞上游 401/403 时冷却失败号并切号原地重试一次(客户端无感),硬耗尽的账号冷却到下次重置点;旧版单 PAT 配置自动迁移为池中的「默认账号」
17+
- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、多账号池(多 PAT 添加 / 一键切换 / 额度检测 / 自动切换设置)、模型查看、请求统计(总量 / 成功失败 / 总输入 / 总输出 / 缓存命中 / **本服务实际扣费额度与均值** / 本周期扣费与重置倒计时 / 按模型消耗 / 近 24 小时趋势)
1718
- **请求日志** —— 记录每次调用的元信息(时间、模型、密钥、流式与否、token 用量、扣费额度、耗时、状态码与错误摘要,不保存对话内容),面板「日志」页支持按模型 / 密钥 / 状态筛选与分页,保留天数与条数上限可配置、自动清理最旧条目
1819
- **订阅周期感知** —— 从网关 `/algo/api/v3/user/status` 读取真实订阅信息(套餐、组织、`nextResetAt` 重置时间、`isQuotaExceeded` 超额标记),使面板额度按计费周期统计而非终身累计
1920
- **单文件部署** —— 编译为单一二进制文件,零外部依赖
@@ -249,6 +250,7 @@ curl http://localhost:10081/v1/models \
249250
| `/admin/api/accounts/switch` | POST | 手动切换激活账号 |
250251
| `/admin/api/accounts/note` | POST | 修改账号备注 |
251252
| `/admin/api/accounts/check` | POST | 检测账号真实额度(临时会话查询一次并落快照) |
253+
| `/admin/api/accounts/auto` | GET/POST | 自动切换设置(开关 + 剩余积分绝对值阈值) |
252254
| `/admin/api/models` | GET | 获取模型列表 |
253255
| `/admin/api/stats` | GET | 请求统计(总量 / 按模型 / 近 24 小时趋势 / token 用量 / 实际扣费额度与均值 / 计费周期与套餐信息) |
254256
| `/admin/api/config` | GET/POST | 服务器配置 |

‎admin/admin.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) {
8585
mux.HandleFunc("/admin/api/accounts/switch", a.requireAuth(a.handleAccountSwitch))
8686
mux.HandleFunc("/admin/api/accounts/note", a.requireAuth(a.handleAccountNote))
8787
mux.HandleFunc("/admin/api/accounts/check", a.requireAuth(a.handleAccountCheck))
88+
mux.HandleFunc("/admin/api/accounts/auto", a.requireAuth(a.handleAutoSwitch))
8889
mux.HandleFunc("/admin/api/models", a.requireAuth(a.handleModels))
8990
mux.HandleFunc("/admin/api/stats", a.requireAuth(a.handleStats))
9091
mux.HandleFunc("/admin/api/logs", a.requireAuth(a.handleLogs))
@@ -832,6 +833,32 @@ func (a *Admin) handleAccountCheck(w http.ResponseWriter, r *http.Request) {
832833
writeJSON(w, 200, map[string]interface{}{"snapshot": snap})
833834
}
834835

836+
// handleAutoSwitch serves GET/POST /admin/api/accounts/auto: the automatic
837+
// switching switch plus its absolute remaining-credits threshold.
838+
func (a *Admin) handleAutoSwitch(w http.ResponseWriter, r *http.Request) {
839+
switch r.Method {
840+
case http.MethodGet:
841+
as := a.store.GetAutoSwitch()
842+
writeJSON(w, 200, map[string]interface{}{"enabled": as.Enabled, "threshold": as.Threshold})
843+
case http.MethodPost:
844+
var body struct {
845+
Enabled bool `json:"enabled"`
846+
Threshold float64 `json:"threshold"`
847+
}
848+
if err := readJSON(w, r, &body); err != nil {
849+
writeJSONError(w, 400, "请求格式错误")
850+
return
851+
}
852+
if err := a.store.SetAutoSwitch(body.Enabled, body.Threshold); err != nil {
853+
writeJSONError(w, 400, err.Error())
854+
return
855+
}
856+
writeJSON(w, 200, map[string]string{"status": "ok"})
857+
default:
858+
writeJSONError(w, 405, "方法不允许")
859+
}
860+
}
861+
835862
// --- Models endpoint ---
836863

837864
func (a *Admin) handleModels(w http.ResponseWriter, r *http.Request) {

‎admin/admin_test.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,3 +1081,44 @@ func TestPATPostStillMirrorsPool(t *testing.T) {
10811081
s.GetActiveAccountID(), len(s.ListAccounts()))
10821082
}
10831083
}
1084+
1085+
// TestAutoSwitchEndpoint 自动切换设置的读写与校验。
1086+
func TestAutoSwitchEndpoint(t *testing.T) {
1087+
a, s := newAdmin(t)
1088+
// 默认值
1089+
w := doRequest(t, a.handleAutoSwitch, "GET", "/admin/api/accounts/auto", nil)
1090+
var resp struct {
1091+
Enabled bool `json:"enabled"`
1092+
Threshold float64 `json:"threshold"`
1093+
}
1094+
json.Unmarshal(w.Body.Bytes(), &resp)
1095+
if resp.Enabled || resp.Threshold != store.DefaultAutoSwitchThreshold {
1096+
t.Errorf("default = %+v", resp)
1097+
}
1098+
// 保存
1099+
w = doRequest(t, a.handleAutoSwitch, "POST", "/admin/api/accounts/auto",
1100+
map[string]interface{}{"enabled": true, "threshold": 30})
1101+
if w.Code != 200 {
1102+
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
1103+
}
1104+
// 回读
1105+
w = doRequest(t, a.handleAutoSwitch, "GET", "/admin/api/accounts/auto", nil)
1106+
json.Unmarshal(w.Body.Bytes(), &resp)
1107+
if !resp.Enabled || resp.Threshold != 30 {
1108+
t.Errorf("after save = %+v", resp)
1109+
}
1110+
if as := s.GetAutoSwitch(); !as.Enabled || as.Threshold != 30 {
1111+
t.Errorf("store = %+v", as)
1112+
}
1113+
// 非法阈值
1114+
w = doRequest(t, a.handleAutoSwitch, "POST", "/admin/api/accounts/auto",
1115+
map[string]interface{}{"enabled": true, "threshold": 0})
1116+
if w.Code != 400 {
1117+
t.Errorf("threshold 0 should 400, got %d", w.Code)
1118+
}
1119+
// 方法不允许
1120+
w = doRequest(t, a.handleAutoSwitch, "DELETE", "/admin/api/accounts/auto", nil)
1121+
if w.Code != 405 {
1122+
t.Errorf("DELETE should 405, got %d", w.Code)
1123+
}
1124+
}

‎bridge/bridge_test.go‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import (
2424
)
2525

2626
func TestChatRouteReturns401WithoutBearerToken(t *testing.T) {
27-
handler := MakeChatHandler(nil, nil, nil)
27+
handler := MakeChatHandler(nil, nil, nil, nil, nil)
2828

2929
req := httptest.NewRequest("POST", "/v1/chat/completions",
3030
strings.NewReader(`{"model":"Qwen3.7-Max","messages":[]}`))
@@ -41,7 +41,7 @@ func TestChatRouteReturns401WithoutBearerToken(t *testing.T) {
4141

4242
func TestChatRouteRejectsInvalidApiKey(t *testing.T) {
4343
resolver := func(apiKey string) *ResolvedBridge { return nil }
44-
handler := MakeChatHandler(resolver, nil, nil)
44+
handler := MakeChatHandler(resolver, nil, nil, nil, nil)
4545

4646
req := httptest.NewRequest("POST", "/v1/chat/completions",
4747
strings.NewReader(`{"model":"Qwen3.7-Max","messages":[]}`))
@@ -93,7 +93,7 @@ func TestModelsRouteReturnsAllModels(t *testing.T) {
9393
// Unauthorized requests (missing/invalid key) must not be counted.
9494
func TestChatRouteDoesNotRecordUnauthorized(t *testing.T) {
9595
rec := stats.NewRecorder(nil)
96-
handler := MakeChatHandler(nil, rec, nil)
96+
handler := MakeChatHandler(nil, rec, nil, nil, nil)
9797

9898
req := httptest.NewRequest("POST", "/v1/chat/completions",
9999
strings.NewReader(`{"model":"Qwen3.7-Max","messages":[]}`))
@@ -112,7 +112,7 @@ func TestChatRouteDoesNotRecordUnauthorized(t *testing.T) {
112112
func TestChatRouteDoesNotRecordInvalidJSON(t *testing.T) {
113113
rec := stats.NewRecorder(nil)
114114
resolver := func(apiKey string) *ResolvedBridge { return &ResolvedBridge{Bridge: &OpenAiBridge{}} }
115-
handler := MakeChatHandler(resolver, rec, nil)
115+
handler := MakeChatHandler(resolver, rec, nil, nil, nil)
116116

117117
req := httptest.NewRequest("POST", "/v1/chat/completions",
118118
strings.NewReader(`{"model": `))
@@ -690,7 +690,7 @@ func TestChatRouteRejectsMalformedParamTypes(t *testing.T) {
690690
"data: [DONE]",
691691
}}
692692
b := newFakeBridge(t, gw)
693-
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, nil, nil)
693+
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, nil, nil, nil, nil)
694694

695695
body := `{"model":"Fake-Model","messages":[{"role":"user","content":"hi"}],"stream":true,"max_tokens":"8000"}`
696696
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
@@ -713,7 +713,7 @@ func TestChatRouteRejectsMultiChoice(t *testing.T) {
713713
"data: [DONE]",
714714
}}
715715
b := newFakeBridge(t, gw)
716-
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, nil, nil)
716+
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, nil, nil, nil, nil)
717717

718718
body := `{"model":"Fake-Model","messages":[{"role":"user","content":"hi"}],"stream":true,"n":3}`
719719
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
@@ -855,7 +855,7 @@ func TestHandleStreamProviderErrorSurfacesRealCause(t *testing.T) {
855855
// The failure frames carry no content, so nothing is committed to the
856856
// client and the rejection must surface as a real HTTP status carrying the
857857
// embedded 429 — not a 200 SSE stream whose success every SDK would read.
858-
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, nil, nil)
858+
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, nil, nil, nil, nil)
859859
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(
860860
`{"model":"Fake-Model","messages":[{"role":"user","content":"hi"}],"stream":true}`))
861861
req.Header.Set("Authorization", "Bearer sk-test")
@@ -1205,7 +1205,7 @@ func TestMakeChatHandlerMapsBusyTo429(t *testing.T) {
12051205
gw := &busyGateway{busyFirstN: 99}
12061206
b := newBusyBridge(t, gw)
12071207

1208-
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, nil, nil)
1208+
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, nil, nil, nil, nil)
12091209

12101210
w := httptest.NewRecorder()
12111211
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"Fake-Model","messages":[{"role":"user","content":"hi"}]}`))
@@ -1874,7 +1874,7 @@ func TestHandleChatRecordsNonBillableUsage(t *testing.T) {
18741874
b := newFakeBridge(t, gw)
18751875
rec := stats.NewRecorder(nil)
18761876

1877-
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, rec, nil)
1877+
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, rec, nil, nil, nil)
18781878
req := httptest.NewRequest("POST", "/v1/chat/completions",
18791879
strings.NewReader(`{"model":"Fake-Model","messages":[{"role":"user","content":"hi"}]}`))
18801880
req.Header.Set("Authorization", "Bearer sk-test")
@@ -1910,7 +1910,7 @@ func TestHandleChatAttributesCreditsToRequestedModel(t *testing.T) {
19101910
b := newFakeBridge(t, gw)
19111911
rec := stats.NewRecorder(nil)
19121912

1913-
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, rec, nil)
1913+
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, rec, nil, nil, nil)
19141914
req := httptest.NewRequest("POST", "/v1/chat/completions",
19151915
strings.NewReader(`{"model":"Fake-Model","messages":[{"role":"user","content":"hi"}]}`))
19161916
req.Header.Set("Authorization", "Bearer sk-test")
@@ -2017,7 +2017,7 @@ func TestMakeChatHandlerMapsUpstreamErrors(t *testing.T) {
20172017
t.Run(tc.name, func(t *testing.T) {
20182018
gw := &rejectingGateway{status: tc.status, body: tc.body, retryAfter: tc.retryAfter}
20192019
b := newRejectingBridge(t, gw)
2020-
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, nil, nil)
2020+
handler := MakeChatHandler(func(string) *ResolvedBridge { return &ResolvedBridge{Bridge: b} }, nil, nil, nil, nil)
20212021

20222022
req := httptest.NewRequest("POST", "/v1/chat/completions",
20232023
strings.NewReader(`{"model":"Fake-Model","messages":[{"role":"user","content":"hi"}]}`))

0 commit comments

Comments
 (0)