diff --git a/internal/core/channels_gate_test.go b/internal/core/channels_gate_test.go new file mode 100644 index 0000000..401419a --- /dev/null +++ b/internal/core/channels_gate_test.go @@ -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 +} diff --git a/internal/core/kernel_actions.go b/internal/core/kernel_actions.go index d5f9fdc..ffe2722 100644 --- a/internal/core/kernel_actions.go +++ b/internal/core/kernel_actions.go @@ -147,6 +147,11 @@ func (c *Core) actionListSources() interface{} { "has_key": s.APIKey != "" || s.APIKeyEnv != "", "key_hint": maskSecretHint(s.APIKey), "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 diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index feb5fc3..5d06e4b 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -3050,7 +3050,19 @@ local function syncAccountSource(provider, accountId) base_url = plat.base_url, adapter = plat.adapter or "openai", 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, } -- 平台的 chat 端点常与 base_url 不是简单拼接(Gemini 走私有 @@ -4897,7 +4909,9 @@ function plugin.complete_login(req) base_url = plat.base_url, adapter = "openai", api_key = legacyTok, - max_concurrent = 2, + -- 同 syncAccountSource:串行 + 节流(见那里的长注释) + max_concurrent = 1, + rpm = plat.rpm or 20, models = (function() local ms = {} local eff = effectiveModels(plat.id) @@ -5971,7 +5985,7 @@ function plugin.provider_set_enabled(req) if legacy and legacyTok then __llmsproxy_kernel.call("add_source", { source = { 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) 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 local test_src = { 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 local rr, rerr = __llmsproxy_kernel.call("add_source", { source = test_src })