mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
问题:密钥控制只能限制模型范围。实测发现三个缺陷,其中前两个让
per-model token_quota 在真实链路上从未生效:
1. 桶键不含 key。scopeTokens 调 WindowTokens(model, source, win),
桶键是 model / source::model,与调用方无关。实测两把 key 各用
1000 token,窗口报 2000 —— A key 的额度被 B key 消耗。
2. 无 source pin 的桶永远是空的。真实记录 Source 总被填上,桶键存成
"deepseek::m1",而无 pin 的查询找 "m1" —— 读到 0,永远 < quota,
配额形同虚设。实测 WindowTokens("m1","",1h)=0 而 pinned=2000。
3. AUTO scope 走 KeyTokens(key),是全时段累计、永不重置。实测 30 天
前的 200 token 仍计入 1 小时配额(报 210 而非 10)。配了
period: hour 也不会每小时归零。
生产 5 把 user key 全是 token_quota: 0,所以前两条一直没暴露。
改动:
- Stats 新增 per-key 小时桶 keyModelHour(key → model → hour)与
keyHour(key 总量)、keyReqHour(请求数),retention 40 天,与既有
modelHour 对齐以覆盖最长的 month 窗口;LoadAudit 走 aggregateLocked,
所以窗口用量跨重启存活。modelHour 保持 key-blind:它服务的是 AUTO
槽位配额(限制整个网关对某槽位的消耗),语义不同,不应被 per-key
改造污染。
- 每个请求写两份模型桶:裸 model 与 source::model。无 pin 的 scope
条目读前者,有 pin 的读后者。
- GWKey 新增 TokenQuota / ReqQuota / Period / Hours:整钥配额,
跨该 key 所有模型共享一份预算;ReqQuota 覆盖持续请求量(源上的
RPM 只管突发)。
- 配额耗尽返回 429 + Retry-After(rate_limit_exceeded),而不是 403:
403 让客户端以为这把 key 永远不能用该模型,直接放弃;429 + 等待
才能在窗口重置后自动恢复。模型越权仍是 403。
- admin key 永不受配额限制 —— 否则操作者会把自己锁在门外。
- 周期词表在写入时校验,拼错的 period 被拒绝而不是静默当成永不过期
(那与操作者输入的意图正好相反)。
- PUT /api/keys 的配额字段是指针:省略=保留原值,显式 0=解除限制。
否则只改模型范围就会悄悄清空预算。
判据 3 个文件 24 例,9 个变异全部被抓:key 隔离、pin 桶缺失、
AUTO 周期、key-blind 退化、429→403、admin 被限、PUT 清空配额、
Validate 失效、pinned 桶缺失。前三个变异最初漏网 —— 判据只测了
Stats 层没测接线,补了走真实 HTTP 的接线层与 API 层判据后抓住。
端到端验证:真实进程 + 加密配置往返,配额字段与 enc:v1 密钥均正常。
(cherry picked from commit 5306251840)
199 lines
7.5 KiB
Go
199 lines
7.5 KiB
Go
package gateway
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"llmsproxy/internal/config"
|
|
)
|
|
|
|
// ---- data layer: per-key isolation ----
|
|
|
|
func TestKeyWindowTokensIsolatesKeys(t *testing.T) {
|
|
s := NewStats(100)
|
|
now := time.Now().UnixMilli()
|
|
s.Record(Req{Time: now, Key: "keyA", Model: "m1", Prompt: 500, Compl: 500, OK: true, Status: 200})
|
|
s.Record(Req{Time: now, Key: "keyB", Model: "m1", Prompt: 500, Compl: 500, OK: true, Status: 200})
|
|
|
|
if got := s.KeyWindowModelTokens("keyA", "m1", "", 3600); got != 1000 {
|
|
t.Errorf("keyA model tokens = %d, want 1000 (its own usage only)", got)
|
|
}
|
|
if got := s.KeyWindowModelTokens("keyB", "m1", "", 3600); got != 1000 {
|
|
t.Errorf("keyB model tokens = %d, want 1000", got)
|
|
}
|
|
// the key-blind bucket stays global on purpose: it backs the AUTO slot
|
|
// quota, which limits the whole gateway, not one key.
|
|
if got := s.WindowTokens("m1", "", 3600); got != 2000 {
|
|
t.Errorf("WindowTokens = %d, want 2000 (global per-model total must be unchanged)", got)
|
|
}
|
|
}
|
|
|
|
func TestKeyWindowTokensSeparatesSourcePin(t *testing.T) {
|
|
s := NewStats(100)
|
|
now := time.Now().UnixMilli()
|
|
s.Record(Req{Time: now, Key: "keyA", Model: "m1", Source: "srcX", Prompt: 100, Compl: 100, OK: true, Status: 200}) // 200 tok
|
|
s.Record(Req{Time: now, Key: "keyA", Model: "m1", Source: "srcY", Prompt: 200, Compl: 200, OK: true, Status: 200}) // 400 tok
|
|
|
|
// A source pin scopes the cap to that one upstream.
|
|
if got := s.KeyWindowModelTokens("keyA", "m1", "srcX", 3600); got != 200 {
|
|
t.Errorf("srcX-pinned tokens = %d, want 200", got)
|
|
}
|
|
if got := s.KeyWindowModelTokens("keyA", "m1", "srcY", 3600); got != 400 {
|
|
t.Errorf("srcY-pinned tokens = %d, want 400", got)
|
|
}
|
|
// No pin covers the model on every source: a cap on "this model" must not
|
|
// stop applying just because the request was served elsewhere. 200+400.
|
|
if got := s.KeyWindowModelTokens("keyA", "m1", "", 3600); got != 600 {
|
|
t.Errorf("unpinned model tokens = %d, want 600 (both sources)", got)
|
|
}
|
|
}
|
|
|
|
// This is the shape of every real chat record: Source is always populated.
|
|
// The unpinned bucket must still see the tokens, or a per-model quota without
|
|
// a source pin reads an empty bucket and never trips.
|
|
func TestKeyWindowModelTokensUnpinnedSeesSourcedTraffic(t *testing.T) {
|
|
s := NewStats(100)
|
|
s.Record(Req{Time: time.Now().UnixMilli(), Key: "keyA", Model: "m1", Source: "deepseek",
|
|
Prompt: 1000, Compl: 1000, OK: true, Status: 200})
|
|
|
|
if got := s.KeyWindowModelTokens("keyA", "m1", "", 3600); got != 2000 {
|
|
t.Errorf("unpinned tokens = %d, want 2000 — an unpinned per-model quota would never trip otherwise", got)
|
|
}
|
|
if got := s.KeyWindowModelTokens("keyA", "m1", "deepseek", 3600); got != 2000 {
|
|
t.Errorf("pinned tokens = %d, want 2000", got)
|
|
}
|
|
}
|
|
|
|
func TestKeyWindowRespectsResetWindow(t *testing.T) {
|
|
s := NewStats(100)
|
|
old := time.Now().Add(-30 * 24 * time.Hour).UnixMilli()
|
|
s.Record(Req{Time: old, Key: "keyA", Model: "m1", Prompt: 100, Compl: 100, OK: true, Status: 200})
|
|
s.Record(Req{Time: time.Now().UnixMilli(), Key: "keyA", Model: "m1", Prompt: 5, Compl: 5, OK: true, Status: 200})
|
|
|
|
if got := s.KeyWindowTokens("keyA", 3600); got != 10 {
|
|
t.Errorf("1h window = %d, want 10 (30-day-old usage must not count)", got)
|
|
}
|
|
if got := s.KeyWindowTokens("keyA", 0); got != 210 {
|
|
t.Errorf("all-time total = %d, want 210", got)
|
|
}
|
|
}
|
|
|
|
func TestKeyWindowReqsCountsEveryRequest(t *testing.T) {
|
|
s := NewStats(100)
|
|
now := time.Now().UnixMilli()
|
|
// a failed request and an image request both count: a client that loops on
|
|
// failures must still burn its request quota
|
|
s.Record(Req{Time: now, Key: "keyA", Model: "m1", Type: "chat", OK: true, Status: 200})
|
|
s.Record(Req{Time: now, Key: "keyA", Model: "m1", Type: "chat", OK: false, Status: 500})
|
|
s.Record(Req{Time: now, Key: "keyA", Model: "img", Type: "image", OK: true, Status: 200})
|
|
s.Record(Req{Time: now, Key: "keyB", Model: "m1", Type: "chat", OK: true, Status: 200})
|
|
|
|
if got := s.KeyWindowReqs("keyA", 3600); got != 3 {
|
|
t.Errorf("keyA reqs = %d, want 3 (chat ok + chat fail + image)", got)
|
|
}
|
|
if got := s.KeyWindowReqs("keyB", 3600); got != 1 {
|
|
t.Errorf("keyB reqs = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestKeyWindowTokensZeroTokensNotCounted(t *testing.T) {
|
|
s := NewStats(100)
|
|
now := time.Now().UnixMilli()
|
|
// a request that reported no usage must not create a bucket entry
|
|
s.Record(Req{Time: now, Key: "keyA", Model: "", Type: "chat", OK: true, Status: 200})
|
|
if got := s.KeyWindowTokens("keyA", 3600); got != 0 {
|
|
t.Errorf("tokens = %d, want 0", got)
|
|
}
|
|
if got := s.KeyWindowReqs("keyA", 3600); got != 1 {
|
|
t.Errorf("reqs = %d, want 1 (request still happened)", got)
|
|
}
|
|
}
|
|
|
|
// ---- reset window arithmetic ----
|
|
|
|
func TestAutoSecondsToReset(t *testing.T) {
|
|
if got := AutoSecondsToReset("", 0); got != 0 {
|
|
t.Errorf("no period = %d, want 0 (never resets -> no retry hint)", got)
|
|
}
|
|
now := time.Now().Unix()
|
|
for _, tc := range []struct {
|
|
period string
|
|
hours int64
|
|
want int64
|
|
}{
|
|
{"hour", 0, 3600},
|
|
{"week", 0, 7 * 24 * 3600},
|
|
{"month", 0, 30 * 24 * 3600},
|
|
{"nhour", 6, 6 * 3600},
|
|
} {
|
|
got := AutoSecondsToReset(tc.period, tc.hours)
|
|
if got <= 0 || got > tc.want {
|
|
t.Errorf("AutoSecondsToReset(%q,%d) = %d, want in (0,%d]", tc.period, tc.hours, got, tc.want)
|
|
}
|
|
// must never exceed the window itself
|
|
if got < tc.want-now%3600 {
|
|
t.Logf("note: %q hint %ds < remaining-window %ds (rounds to hour boundary)", tc.period, got, tc.want-now%3600)
|
|
}
|
|
if got > tc.want {
|
|
t.Errorf("hint %d exceeds window %d", got, tc.want)
|
|
}
|
|
}
|
|
_ = now
|
|
}
|
|
|
|
func TestAutoSecondsToResetNeverExceedsWindow(t *testing.T) {
|
|
// "nhour" with a tiny window must not hand out a longer wait than the
|
|
// window itself (which would stall a client past its own reset)
|
|
for hours := int64(1); hours <= 48; hours++ {
|
|
got := AutoSecondsToReset("nhour", hours)
|
|
if got <= 0 || got > hours*3600 {
|
|
t.Errorf("nhour/%d = %d, want in (0,%d]", hours, got, hours*3600)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- config validation ----
|
|
|
|
func TestKeyQuotaValidate(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
q config.KeyQuota
|
|
wantErr bool
|
|
}{
|
|
{"unlimited", config.KeyQuota{}, false},
|
|
{"tokens hourly", config.KeyQuota{TokenQuota: 1000, Period: "hour"}, false},
|
|
{"tokens n-hour", config.KeyQuota{TokenQuota: 1000, Period: "nhour", Hours: 6}, false},
|
|
{"reqs weekly", config.KeyQuota{ReqQuota: 100, Period: "week"}, false},
|
|
{"no period never resets", config.KeyQuota{TokenQuota: 1000}, false},
|
|
{"negative tokens", config.KeyQuota{TokenQuota: -1}, true},
|
|
{"negative reqs", config.KeyQuota{ReqQuota: -1}, true},
|
|
{"negative hours", config.KeyQuota{TokenQuota: 5, Hours: -1}, true},
|
|
{"typo period becomes all-time if accepted", config.KeyQuota{TokenQuota: 5, Period: "houre"}, true},
|
|
{"nhour without hours", config.KeyQuota{TokenQuota: 5, Period: "nhour"}, true},
|
|
{"nhour with 0 hours", config.KeyQuota{TokenQuota: 5, Period: "nhour", Hours: 0}, true},
|
|
{"junk period with only req quota", config.KeyQuota{ReqQuota: 5, Period: "daily"}, true},
|
|
{"junk period with no caps is irrelevant", config.KeyQuota{Period: "daily"}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
err := tc.q.Validate()
|
|
if tc.wantErr && err == nil {
|
|
t.Errorf("%s: expected error, got nil", tc.name)
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Errorf("%s: unexpected error: %v", tc.name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeRole(t *testing.T) {
|
|
if got := config.NormalizeRole(""); got != "user" {
|
|
t.Errorf("empty role = %q, want user", got)
|
|
}
|
|
if got := config.NormalizeRole("admin"); got != "admin" {
|
|
t.Errorf("admin role = %q, want admin", got)
|
|
}
|
|
if got := config.NormalizeRole("root"); got != "user" {
|
|
t.Errorf("unknown role = %q, want user (never escalate)", got)
|
|
}
|
|
}
|