mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
feat(channels): 源改为串行 + 按模型节流(照参考 model-gate.ts)
照参考的实测依据:这些平台的**并发配额按模型**分别计量,与"还剩多少 token"
无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下:
GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过
GLM-5.3 74 次 200 21 次重试 6 次最终 429
两条措施缺一不可:
① **串行**(max_concurrent=1):并发发起必然浪费一个配额;
② **最小间隔**(rpm):串行只保证"不重叠"、不保证"有间隔" —— 连续放行的两次
调用可能只隔几十毫秒,仍会撞并发窗口。
此前插件注册源一律 `max_concurrent = 2` 且**从不设 rpm** ⇒ 既会重叠又无间隔。
现全部改为 `max_concurrent = 1` + `rpm = 20`(默认 3 秒一个,可按平台覆盖)。
⚠️ 参考的另一条教训也记进注释了:闸门**只包 fetch,不包 captcha**。
那边第一版把 mint 与 fetch 一起放进闸门,mint 耗时从 200-500ms 暴涨到
2500-3100ms(因为 mint 被串行后变成"排在 N 个人后面再 mint")。
我们的 rpm 节流在内核层、只作用于上游 dispatch,天然不涉及这一点。
顺带:`list_sources` 补暴露 `rpm` —— 运维与判据都要能看到它。只看
max_concurrent 无法区分"串行但有间隔"与"串行且无间隔"(后者仍会撞窗口)。
判据 `TestChannelSourcesAreSerialWithThrottle`:源必须 max_concurrent=1 且 rpm>0。
回归:9 个包全绿。
This commit is contained in:
63
internal/core/channels_gate_test.go
Normal file
63
internal/core/channels_gate_test.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 源注册必须**串行 + 节流**,而不是并发 2、无间隔。
|
||||||
|
//
|
||||||
|
// 照参考 model-gate.ts 的实测依据:这些平台的**并发配额按模型**分别计量,
|
||||||
|
// 与"还剩多少 token"无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下:
|
||||||
|
//
|
||||||
|
// GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过
|
||||||
|
// GLM-5.3 74 次 200 21 次重试 6 次最终 429
|
||||||
|
//
|
||||||
|
// 两条措施缺一不可:
|
||||||
|
//
|
||||||
|
// ① **串行**(max_concurrent=1):同一时刻只允许一个上游请求在飞 ——
|
||||||
|
// 并发发起必然浪费一个配额。
|
||||||
|
// ② **最小间隔**(rpm):串行只保证"不重叠",不保证"有间隔" —— 连续放行的
|
||||||
|
// 两次调用可能只隔几十毫秒,仍会撞并发窗口。
|
||||||
|
//
|
||||||
|
// 此前插件注册源时一律 max_concurrent=2 且**从不设 rpm** ⇒ 既会重叠又无间隔。
|
||||||
|
func TestChannelSourcesAreSerialWithThrottle(t *testing.T) {
|
||||||
|
c, ps := poolCore(t)
|
||||||
|
m, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||||
|
"platform": "buddy", "account_id": "gated",
|
||||||
|
"credential": `{"access_token":"TOK"}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("account_add: %v", err)
|
||||||
|
}
|
||||||
|
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
|
||||||
|
if j == nil || j["error"] != nil {
|
||||||
|
t.Fatalf("account_add 失败: %#v", m)
|
||||||
|
}
|
||||||
|
aid, _ := j["account_id"].(string)
|
||||||
|
|
||||||
|
srcs := listSourcesViaKernel(t, c)
|
||||||
|
sm, ok := srcHasAccount(srcs, aid)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("账号未出源")
|
||||||
|
}
|
||||||
|
// ① 串行
|
||||||
|
if mc, _ := toFloat(sm["max_concurrent"]); mc != 1 {
|
||||||
|
t.Errorf("源必须 max_concurrent=1(并发发起必然浪费一个配额),实得 %v", sm["max_concurrent"])
|
||||||
|
}
|
||||||
|
// ② 节流(rpm>0 才有间隔)
|
||||||
|
if rpm, _ := toFloat(sm["rpm"]); rpm <= 0 {
|
||||||
|
t.Errorf("源必须设 rpm>0(串行不保证间隔),实得 %v", sm["rpm"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFloat(v interface{}) (float64, bool) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return x, true
|
||||||
|
case int:
|
||||||
|
return float64(x), true
|
||||||
|
case int64:
|
||||||
|
return float64(x), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
@ -147,6 +147,11 @@ func (c *Core) actionListSources() interface{} {
|
|||||||
"has_key": s.APIKey != "" || s.APIKeyEnv != "",
|
"has_key": s.APIKey != "" || s.APIKeyEnv != "",
|
||||||
"key_hint": maskSecretHint(s.APIKey),
|
"key_hint": maskSecretHint(s.APIKey),
|
||||||
"max_concurrent": s.MaxConcurrent,
|
"max_concurrent": s.MaxConcurrent,
|
||||||
|
// rpm must be exposed too: it carries the per-model minimum gap, and
|
||||||
|
// max_concurrent alone cannot distinguish "serial WITH a gap" from
|
||||||
|
// "serial with no gap" — the latter still collides with the
|
||||||
|
// upstream's per-model concurrency window.
|
||||||
|
"rpm": s.RPM,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@ -3050,7 +3050,19 @@ local function syncAccountSource(provider, accountId)
|
|||||||
base_url = plat.base_url,
|
base_url = plat.base_url,
|
||||||
adapter = plat.adapter or "openai",
|
adapter = plat.adapter or "openai",
|
||||||
api_key = tok,
|
api_key = tok,
|
||||||
max_concurrent = 2,
|
-- ⚠️ max_concurrent=1 + rpm 节流,而不是 2。
|
||||||
|
--
|
||||||
|
-- 照参考 model-gate.ts 的实测依据:这些平台的**并发配额按模型**分别计量,
|
||||||
|
-- 与"还剩多少 token"无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下:
|
||||||
|
-- GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过
|
||||||
|
-- GLM-5.3 74 次 200 21 次重试 6 次最终 429
|
||||||
|
-- 两条措施缺一不可:
|
||||||
|
-- ① **串行**:同一时刻只允许一个上游请求在飞(并发发起必然浪费一个配额)
|
||||||
|
-- ② **最小间隔**:串行只保证"不重叠",不保证"有间隔" —— 连续放行的两次
|
||||||
|
-- 调用可能只隔几十毫秒,仍会撞并发窗口。故用 rpm 让内核把它们隔开。
|
||||||
|
-- 这两条都是**客户端自保**(防患于未然),与"上游明确要求排队"是两回事。
|
||||||
|
max_concurrent = 1,
|
||||||
|
rpm = plat.rpm or 20, -- 默认 3 秒一个请求,可按平台覆盖
|
||||||
models = models,
|
models = models,
|
||||||
}
|
}
|
||||||
-- 平台的 chat 端点常与 base_url 不是简单拼接(Gemini 走私有
|
-- 平台的 chat 端点常与 base_url 不是简单拼接(Gemini 走私有
|
||||||
@ -4897,7 +4909,9 @@ function plugin.complete_login(req)
|
|||||||
base_url = plat.base_url,
|
base_url = plat.base_url,
|
||||||
adapter = "openai",
|
adapter = "openai",
|
||||||
api_key = legacyTok,
|
api_key = legacyTok,
|
||||||
max_concurrent = 2,
|
-- 同 syncAccountSource:串行 + 节流(见那里的长注释)
|
||||||
|
max_concurrent = 1,
|
||||||
|
rpm = plat.rpm or 20,
|
||||||
models = (function()
|
models = (function()
|
||||||
local ms = {}
|
local ms = {}
|
||||||
local eff = effectiveModels(plat.id)
|
local eff = effectiveModels(plat.id)
|
||||||
@ -5971,7 +5985,7 @@ function plugin.provider_set_enabled(req)
|
|||||||
if legacy and legacyTok then
|
if legacy and legacyTok then
|
||||||
__llmsproxy_kernel.call("add_source", { source = {
|
__llmsproxy_kernel.call("add_source", { source = {
|
||||||
name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai",
|
name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai",
|
||||||
api_key = legacyTok, max_concurrent = 2,
|
api_key = legacyTok, max_concurrent = 1,
|
||||||
models = (function() local ms = {} local eff = effectiveModels(plat.id)
|
models = (function() local ms = {} local eff = effectiveModels(plat.id)
|
||||||
for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(),
|
for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(),
|
||||||
} })
|
} })
|
||||||
@ -8726,7 +8740,7 @@ function plugin.__sync_source_probe(args)
|
|||||||
end
|
end
|
||||||
local test_src = {
|
local test_src = {
|
||||||
name = want, base_url = plat.base_url, adapter = plat.adapter or "openai",
|
name = want, base_url = plat.base_url, adapter = plat.adapter or "openai",
|
||||||
api_key = tok, max_concurrent = 2, models = models,
|
api_key = tok, max_concurrent = 1, models = models,
|
||||||
}
|
}
|
||||||
if plat.endpoint then test_src.endpoint = plat.endpoint end
|
if plat.endpoint then test_src.endpoint = plat.endpoint end
|
||||||
local rr, rerr = __llmsproxy_kernel.call("add_source", { source = test_src })
|
local rr, rerr = __llmsproxy_kernel.call("add_source", { source = test_src })
|
||||||
|
|||||||
Reference in New Issue
Block a user