File size: 8,713 Bytes
6d60378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
package pool

import (
	"testing"
	"time"

	"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
)

// ---------------------------------------------------------------------------
// ModelLockView:把「哪些模型不能用、锁了几个号、还要锁多久」聚合成一张全清单
// (ModelBlocked 是单模型按请求回答,本视图是全清单,口径必须与选号一致)
// ---------------------------------------------------------------------------

// lockViewPool 造一个含两个 cn 账号的池,便于手搓模型级冷却。
func lockViewPool(t *testing.T) *Pool {
	t.Helper()
	withNoPickGap(t)
	p := New("")
	p.Add(&auth.Auth{UID: "u1"})
	p.Add(&auth.Auth{UID: "u2"})
	return p
}

// setModelCooldown 直接写模型级冷却(与 modelblocked_test.go 同法)。
func setModelCooldown(p *Pool, uid, model string, until time.Time, reason string, auditOnly bool) {
	p.mu.Lock()
	e := p.byUID[uid]
	if e.modelCooldowns == nil {
		e.modelCooldowns = map[string]modelCooldown{}
	}
	e.modelCooldowns[model] = modelCooldown{Until: until, Reason: reason, AuditOnly: auditOnly}
	p.mu.Unlock()
}

// TestModelLockViewEmpty 没有未过期模型级冷却 → 返回 nil(面板按空态渲染)。
func TestModelLockViewEmpty(t *testing.T) {
	p := lockViewPool(t)
	if rows := p.ModelLockView(); rows != nil {
		t.Fatalf("无冷却时应返回 nil,得到 %+v", rows)
	}
}

// TestModelLockViewPartial 部分号被锁 → partial,且带出可选/总数、最早解锁与原因。
func TestModelLockViewPartial(t *testing.T) {
	p := lockViewPool(t)
	setModelCooldown(p, "u1", "glm-5.3", time.Now().Add(time.Hour), "6004 model rate limit", false)

	rows := p.ModelLockView()
	if len(rows) != 1 {
		t.Fatalf("want 1 row, got %+v", rows)
	}
	r := rows[0]
	if r.Model != "glm-5.3" || r.Realm != "cn" {
		t.Errorf("model/realm = %q/%q want glm-5.3/cn", r.Model, r.Realm)
	}
	if r.Total != 2 || r.Locked != 1 || r.Servable != 1 {
		t.Errorf("total/locked/servable = %d/%d/%d want 2/1/1", r.Total, r.Locked, r.Servable)
	}
	if r.State != "partial" {
		t.Errorf("state = %q want partial", r.State)
	}
	if r.Reason != "6004 model rate limit" {
		t.Errorf("reason = %q want 6004 model rate limit", r.Reason)
	}
	if d := time.Until(r.UnlockAt); d < 50*time.Minute || d > 70*time.Minute {
		t.Errorf("unlock_at 应在约 1h 后,得到 %v", d)
	}
	if !r.FullyUnlockAt.Equal(r.UnlockAt) {
		t.Errorf("只有一个号被锁时 fully_unlock_at 应等于 unlock_at")
	}
}

// TestModelLockViewLocked 全部参与选号的号都被该模型挡住 → locked(与 ModelBlocked 同义),
// 最早解锁取最小 Until、全池解锁取最大 Until,原因取最早解锁账号的。
func TestModelLockViewLocked(t *testing.T) {
	p := lockViewPool(t)
	base := time.Now()
	setModelCooldown(p, "u1", "glm-5.3", base.Add(2*time.Hour), "reason-late", false)
	setModelCooldown(p, "u2", "glm-5.3", base.Add(time.Hour), "reason-early", false)

	rows := p.ModelLockView()
	if len(rows) != 1 {
		t.Fatalf("want 1 row, got %+v", rows)
	}
	r := rows[0]
	if r.State != "locked" {
		t.Errorf("state = %q want locked", r.State)
	}
	if r.Servable != 0 || r.Locked != 2 || r.Total != 2 {
		t.Errorf("servable/locked/total = %d/%d/%d want 0/2/2", r.Servable, r.Locked, r.Total)
	}
	if r.Reason != "reason-early" {
		t.Errorf("reason 应取最早解锁账号的,得到 %q", r.Reason)
	}
	if d := time.Until(r.UnlockAt); d < 50*time.Minute || d > 70*time.Minute {
		t.Errorf("unlock_at 应约 1h 后,得到 %v", d)
	}
	if d := time.Until(r.FullyUnlockAt); d < 110*time.Minute || d > 130*time.Minute {
		t.Errorf("fully_unlock_at 应约 2h 后,得到 %v", d)
	}
}

// TestModelLockViewStarved 此刻没号能服务、但不是模型冷却造成的(账号级冷却)→ starved,
// 与 locked 区分开:这种情况换模型没用,等一下就好。
func TestModelLockViewStarved(t *testing.T) {
	p := lockViewPool(t)
	setModelCooldown(p, "u1", "glm-5.3", time.Now().Add(time.Hour), "6004 model rate limit", false)
	p.Cooldown("u2", CoolHard, time.Hour, "14018 credits exhausted")

	rows := p.ModelLockView()
	if len(rows) != 1 {
		t.Fatalf("want 1 row, got %+v", rows)
	}
	r := rows[0]
	if r.Servable != 0 || r.Locked != 1 || r.Total != 2 {
		t.Errorf("servable/locked/total = %d/%d/%d want 0/1/2", r.Servable, r.Locked, r.Total)
	}
	if r.State != "starved" {
		t.Errorf("state = %q want starved(模型没全锁,只是此刻没号)", r.State)
	}
}

// TestModelLockViewSkipsAuditOnly AuditOnly 台账只审计不拦路由 → 既不产生行,
// 也不计入 locked(口径与 modelCooled 一致,否则会把其实能调的模型报成不可用)。
func TestModelLockViewSkipsAuditOnly(t *testing.T) {
	p := lockViewPool(t)
	setModelCooldown(p, "u1", "glm-5.3", time.Now().Add(time.Hour), "audit", true)
	if rows := p.ModelLockView(); rows != nil {
		t.Fatalf("AuditOnly 条目不应产生行:%+v", rows)
	}
}

// TestModelLockViewSkipsExpired 已过期的冷却不算锁。
func TestModelLockViewSkipsExpired(t *testing.T) {
	p := lockViewPool(t)
	setModelCooldown(p, "u1", "glm-5.3", time.Now().Add(-time.Minute), "6004", false)
	if rows := p.ModelLockView(); rows != nil {
		t.Fatalf("已过期冷却不应产生行:%+v", rows)
	}
}

// TestModelLockViewSkipsDisabledAndPaused 禁用号/暂停号的不可用与模型无关:
// 既不计入分母 Total,也不该被算成「还能服务的证明」。
func TestModelLockViewSkipsDisabledAndPaused(t *testing.T) {
	p := lockViewPool(t)
	setModelCooldown(p, "u1", "glm-5.3", time.Now().Add(time.Hour), "6004", false)
	p.mu.Lock()
	p.byUID["u2"].disabled = true
	p.mu.Unlock()

	rows := p.ModelLockView()
	if len(rows) != 1 {
		t.Fatalf("want 1 row, got %+v", rows)
	}
	if rows[0].Total != 1 {
		t.Errorf("total = %d want 1(禁用号不计入分母)", rows[0].Total)
	}
	if rows[0].State != "locked" {
		t.Errorf("state = %q want locked(唯一参与选号的号被锁)", rows[0].State)
	}

	p.mu.Lock()
	p.byUID["u2"].disabled = false
	p.byUID["u2"].paused = true
	p.mu.Unlock()

	rows = p.ModelLockView()
	if len(rows) != 1 || rows[0].Total != 1 {
		t.Errorf("暂停号同样不计入分母:%+v", rows)
	}
}

// TestModelLockViewRealmIsolation 同名模型在 cn/global 各自独立计数与原因。
func TestModelLockViewRealmIsolation(t *testing.T) {
	p := realmPool(t)
	setModelCooldown(p, "cn1", "glm-5.3", time.Now().Add(time.Hour), "6004 cn", false)
	setModelCooldown(p, "g1", "glm-5.3", time.Now().Add(time.Hour), "6004 global", false)

	rows := p.ModelLockView()
	if len(rows) != 2 {
		t.Fatalf("同名模型在两个域应各出一行,得到 %+v", rows)
	}
	byRealm := map[string]ModelLockRow{}
	for _, r := range rows {
		byRealm[r.Realm] = r
	}
	cn, ok := byRealm["cn"]
	if !ok || cn.Total != 2 || cn.Locked != 1 || cn.Reason != "6004 cn" {
		t.Errorf("cn 行 = %+v want total 2 locked 1 reason \"6004 cn\"", cn)
	}
	g, ok := byRealm["global"]
	if !ok || g.Total != 2 || g.Locked != 1 || g.Reason != "6004 global" {
		t.Errorf("global 行 = %+v want total 2 locked 1 reason \"6004 global\"", g)
	}
}

// TestModelLockViewSortsLockedFirst 整池不可用排在部分限流前面,方便一眼看到最该处理的。
func TestModelLockViewSortsLockedFirst(t *testing.T) {
	p := lockViewPool(t)
	base := time.Now()
	setModelCooldown(p, "u1", "glm-5.3", base.Add(time.Hour), "6004", false)
	setModelCooldown(p, "u2", "glm-5.3", base.Add(time.Hour), "6004", false)
	setModelCooldown(p, "u1", "kimi-k2", base.Add(time.Hour), "6004", false)

	rows := p.ModelLockView()
	if len(rows) != 2 {
		t.Fatalf("want 2 rows, got %+v", rows)
	}
	if rows[0].State != "locked" || rows[1].State != "partial" {
		t.Errorf("整池不可用应排在部分限流前:%+v", rows)
	}
	if rows[0].Model != "glm-5.3" {
		t.Errorf("首行应为 glm-5.3,得到 %q", rows[0].Model)
	}
}

// TestModelLockViewAfterClear 走真实写入/清除通道(而非只手搓 map):
// BlockModelBackoff 后应出 partial 行,BlockModelClear 后应回到 nil。
func TestModelLockViewAfterClear(t *testing.T) {
	p := lockViewPool(t)
	p.BlockModelBackoff("u1", "glm-5.3", "11102 model [glm-5.3] service info not found")
	rows := p.ModelLockView()
	if len(rows) != 1 || rows[0].State != "partial" {
		t.Fatalf("写入负缓存后应有一行 partial:%+v", rows)
	}
	p.BlockModelClear("u1", "glm-5.3")
	if rows := p.ModelLockView(); rows != nil {
		t.Fatalf("清除后应回到 nil:%+v", rows)
	}
}