File size: 30,623 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 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 | // Package pool 账号池:单一状态机(健康/冷却/熔断/连败降权)+ 在途租约 + 最早到期优先/加权挑选 + state.json 持久化。
package pool
import (
"sync/atomic"
"time"
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
)
// degradeReason 连败降权(issue #114)写 reason 的固定文案:与冷却域的
// reason("429 rate limit" / "waf 403 block" / "余额不足")共用一个字段,
// 运维在 /status 一处即可看到「为什么被降权/冷却」,不新增台账字段。
const degradeReason = "consecutive failures"
type CoolKind int
const (
CoolHard CoolKind = iota // 余额不足 → 冷却到次日 04:00(等签到恢复)
CoolSoft // 429 → 短冷却
)
func (k CoolKind) String() string {
switch k {
case CoolHard:
return "hard_credit"
case CoolSoft:
return "soft_rate"
}
return "unknown"
}
// TokenUsage 账号聊天请求的累计 token 用量摘要(不包含任何原始凭证)。
type TokenUsage struct {
RequestCount int64 `json:"request_count,omitempty"`
UsageCount int64 `json:"usage_count,omitempty"`
PromptTokens int64 `json:"prompt_tokens,omitempty"`
CompletionTokens int64 `json:"completion_tokens,omitempty"`
TotalTokens int64 `json:"total_tokens,omitempty"`
LastLatencyMs int64 `json:"last_latency_ms,omitempty"`
LastTokensPerSecond *float64 `json:"last_tokens_per_second,omitempty"`
LastUsedAt time.Time `json:"last_used_at,omitempty"`
LastModel string `json:"last_model,omitempty"`
}
// TokenUsageDelta 是一次聊天账号尝试的 usage 增量。
// 各 Has* 字段用于区分上游缺少字段与字段值确实为 0。
type TokenUsageDelta struct {
Model string
HasPromptTokens bool
PromptTokens int64
HasCompletionTokens bool
CompletionTokens int64
HasTotalTokens bool
TotalTokens int64
HasLatencyMs bool
LatencyMs int64
HasTokensPerSecond bool
TokensPerSecond float64
}
// Status 单个账号对外暴露的状态(脱敏)。
type Status struct {
UID string `json:"uid"`
Nickname string `json:"nickname,omitempty"`
Credits int64 `json:"credits"`
CreditsTotal int64 `json:"credits_total,omitempty"` // 积分总额度(各套餐聚合);0 = 未知(旧 state/查询失败)
// CreditsExpiring / CreditsEarliestExpiry / CreditsEarliestRemaining 描述当前积分中
// 的到期压力。CreditsExpiring 是配置窗口内的剩余积分;后两者是全部未来到期批次中
// 最早的一批及其剩余量,供 WorkDaddy 同口径的“最早到期优先”路由使用。
CreditsExpiring int64 `json:"credits_expiring,omitempty"`
CreditsEarliestExpiry time.Time `json:"credits_earliest_expiry,omitempty"`
CreditsEarliestRemaining int64 `json:"credits_earliest_remaining,omitempty"`
Cooling bool `json:"cooling"`
CoolKind string `json:"cool_kind,omitempty"`
CoolRemaining int64 `json:"cool_remaining_sec,omitempty"`
Until time.Time `json:"until,omitempty"`
Reason string `json:"reason,omitempty"`
SoftStreak int `json:"soft_streak,omitempty"` // 连续软冷却次数(指数退避指数,见 entry.softStreak)
// RateLimitedModels 当前仍在限额的模型列表(issue #36 限额台账)。
// 仅「带解析时间 6004」触发的模型级独立冷却(modelCooldowns 未到期条目)时非空,
// 每模型一行;运维据此看到"账号 A 的模型 X 还在限额中,预计 Z 时间恢复"。到期即消失(零回归)。
RateLimitedModels []RateLimitedModel `json:"rate_limited_models,omitempty"`
// Realm 账号域(cn/global,auth.Realm() 计算值;含 global.enabled 开关闸)。
// 供面板/状态接口按域分组展示。
Realm string `json:"realm,omitempty"`
Disabled bool `json:"disabled"`
DisabledReason string `json:"disabled_reason,omitempty"` // 仅 disabled 账号:禁用原因(运维可见)
// Paused 暂停选号:退出选号候选(与 disabled 一样不参与选号),但**照常参与**
// 签到 / 活跃上报 / 保活 / 余额刷新四类保号任务。与 disabled 正交——disabled 是
// 「授权/session 终态,需人工 revive」,paused 是「运维临时让位」(多号轮换场景),
// 账号本身健康,只是暂不接流量。
Paused bool `json:"paused,omitempty"`
SuccessCount int64 `json:"success_count,omitempty"`
ErrTotal int64 `json:"err_total,omitempty"`
LastSuccessTime time.Time `json:"last_success,omitempty"`
LastErrTime time.Time `json:"last_err,omitempty"`
// CheckinDone 本地今日已签到(签到成功或上游"今天已签到"幂等拒绝均算)。
// global 域账号无签到体系,恒为 false。面板签到按钮据此显示 签到/已签。
CheckinDone bool `json:"checkin_done,omitempty"`
TokenUsage TokenUsage `json:"token_usage,omitempty"`
// ModelCosts 每模型实测成本台账(P1-anti-monopoly 可观测性):运维据此自查
//「为什么总选它」——tier 0(免费)垄断 / tier 2 单价排序一眼可见。
// 仅 modelCostTTL 内的有效观测,每模型一行(cost_per_1k + last_seen +
// samples);无观测/全部过期 → nil(tier 1 未知层)。
ModelCosts []ModelCostStatus `json:"model_costs,omitempty"`
// ConsecutiveFails 连续失败计数(连败降权用,见 entry.consecutiveFails)。
// 零值也透出(运维口径:与 err_total/session_dead_fails 一致,零值缺失会让人
// 误以为"没记录",实际是零值被 omitempty 省略)。
ConsecutiveFails int `json:"consecutive_fails"`
DegradeUntil time.Time `json:"degrade_until,omitempty"` // 连败降权截止(非零且未过 = 降权中)
// 运行态(不持久化):在途请求数 + 熔断器状态。
InFlight int `json:"in_flight"`
BreakerFails int `json:"breaker_fails"`
BreakerUntil time.Time `json:"breaker_until,omitempty"`
}
// ModelCostStatus 单个 (账号, 模型) 的成本台账行(P1-anti-monopoly 可观测性)。
// tier 不单独落字段:cost_per_1k ≤ 0 即 tier 0(免费),> 0 即 tier 2(收费),
// 无观测即 tier 1——由调用方/面板按值推出,避免双表示漂移。
type ModelCostStatus struct {
Model string `json:"model"`
// CostPer1k 实测每千 token 单价(EMA 平滑值)。≤0 = 实测免费(tier 0)。
CostPer1k float64 `json:"cost_per_1k"`
// LastSeen 最近一次观测时刻(过期即从台账消失,同 modelCostTTL 口径)。
LastSeen time.Time `json:"last_seen"`
// Samples 累计观测次数(EMA 收敛度参考)。
Samples int `json:"samples,omitempty"`
}
// RateLimitedModel 单个被限流模型的台账行(issue #36)。
type RateLimitedModel struct {
Model string `json:"model"`
// Kind 区分限流与模型不可用:6004 是 rate_limit,11102 是 model_unavailable。
Kind string `json:"kind"`
// Until 冷却到期时刻 = 该模型的独立冷却截止(modelCooldowns[m].Until,截断后),
// 多模型限流时不再等于 Status.Until(账号级)。
Until time.Time `json:"until,omitempty"`
// ResetAt 上游「将在 … 重置」的原始墙钟(未经 soft_rate_max 截断);
// 截断后 Until==ResetAt 时省略 ResetAt 让台账自然减少一列。
ResetAt time.Time `json:"reset_at,omitempty"`
// Reason 触发原因(运维可读文案)。
Reason string `json:"reason,omitempty"`
}
// modelCooldown 单个 (账号, 模型) 的模型级独立冷却记录。
// 承载两种「该模型在此账号上不可用」语义:
// - 6004 模型级限流:Until 对齐上游重置墙钟;ResetAt 记录权威恢复时刻。
// - 11102 该后端无此模型:Until 为指数退避 TTL(6h 起、封顶 24h);Hits 记录
// 累计命中次数驱动退避(6004 无 hits 概念,Hits 恒 0)。
type modelCooldown struct {
// Until 该模型的冷却截止(6004:now+min(resetAt-now, soft_rate_max);11102:now+退避 TTL)。
Until time.Time
// ResetAt 上游「将在 … 重置」的原始墙钟(未经 soft_rate_max 截断)。
// 与 Until 的区别同:Until 可能截断,ResetAt 是上游权威恢复时刻。
// 11102 无重置文案,ResetAt 恒零值。
ResetAt time.Time
// Reason 触发原因(透出运维可读文案,同 Status.Reason)。
Reason string
// Hits 11102 负缓存的累计命中次数(驱动指数退避)。6004 条目 Hits 恒 0。
Hits int
// AuditOnly 为 true 时仅用于状态展示(例如无重置时间的 6004),
// healthyForModel 与 modelExempt 必须忽略它,避免改变选号行为。
AuditOnly bool
}
// modelCostTTL 成本观测的有效期。取 6 小时:既覆盖"夜间免费"这类时段性优惠的
// 单次会话,又不至于让昨天的价格决定今天的选择——过期的免费观测若永久有效,
// 白天会把已开始收费的号继续当成免费。
const modelCostTTL = 6 * time.Hour
// modelCostEntry 运行时成本账本(持久化镜像 stateModelCost 与其字段一一对应)。
type modelCostEntry struct {
CostPer1k float64
LastSeen time.Time
Samples int
}
type entry struct {
a *auth.Auth
credits int64
creditsTotal int64 // 积分总额度(UserResource 聚合;0 = 未知)
// creditsExpiring 配置窗口内即将过期的可用积分子集,是 credits 的一部分。
// creditsEarliestExpiry / creditsEarliestRemaining 是全部未来到期批次中的最早一批;
// 两者均由签到/余额刷新更新,供 earliest-expiry 路由和状态观测使用。
creditsExpiring int64
creditsEarliestExpiry time.Time
creditsEarliestRemaining int64
successCount int64 // 累计成功
errTotal int64 // 累计错误(供成功率权重 successRate = successCount/(successCount+errTotal),不清零)
lastErr time.Time // 最近一次错误时间
lastSuccess time.Time // 最近一次成功时间
tokenUsage TokenUsage // 聊天请求 token 用量摘要(持久化)
// lastCheckinDay 最近一次签到成功的本地日期("2006-01-02")。签到成功与上游
// 幂等拒绝("今天已签到")都算;statusOf 据此输出 CheckinDone 供面板按钮显示
// 签到/已签。持久化:跨重启不丢当日状态。
lastCheckinDay string
coolKind CoolKind
until time.Time // 冷却截止(即时冷却:CoolSoft 429 / CoolHard 余额耗尽)
disabled bool
// paused 暂停选号:与 disabled 正交。置位后退出选号候选(healthy 判否),
// 但保号任务遍历只按 Disabled 过滤,故 paused 号天然继续参与签到 / 活跃上报 /
// 保活 / 余额刷新。持久化(state.json),跨重启不丢。
paused bool
reason string
lastUsed time.Time // 最近被选中时刻(防并发撞号)
// usedSeq 单调递增的选中序号:每次被 pick 选中时取 p.pickSeq 自增值。
// Windows 等平台 time.Now() 精度有限(~0.5ms),高并发/快速连续选号时多个
// 账号 lastUsed 完全相等,基于 wall-clock 的 LRU/防惊群判定失效。
// usedSeq 提供严格全序,与时间精度无关。运行态,不持久化。
usedSeq uint64
// breakerUntil / fails / retryCount 为熔断器运行态(不持久化)。
// fails 是唯一的"连续失败"计数器:任何错误喂入,达到 breakerThreshold 触发熔断(指数退避),
// 跨入口累计,成功/熔断/统一复活时清零(保留 retryCount 驱动退避指数)。
breakerUntil time.Time // 熔断截止(指数退避)
fails int // 连续失败计数(熔断用,唯一权威)
retryCount int // 已熔断次数(指数退避的指数)
// softStreak 连续软冷却次数(CoolSoft),独立于熔断器 fails 的**冷却域**计数器:
// fails 会被熔断触发清零、且被 hard 冷却与 NoteError 污染,无法表达"连续软限流"。
// 重置点只有两处(都是账号被证明恢复的时刻):NoteSuccess、reviveCoolingLocked。
// 持久化(stateAccount.SoftStreak):重启后软限流仍在退避,不因重启回到基数。
softStreak int
// modelCooldowns 6004 模型级 limit 的**独立**冷却表:model → 该模型的冷却截止/重置。
// 与 until(全账号级)正交:6004 只写本表、不写 until,因此多个模型同时 6004 时
// 各自独立计时,互不覆盖(A 触发后 B 再触发,A 的冷却截止不被 B 覆盖——这是
// 单 until 字段做不到的)。仅 6004 触发时记录;空 map = 无模型级限流(不豁免)。
// 运行态语义(不持久化):重启清零,退化为仅账号级 until 冷却的现状。
modelCooldowns map[string]modelCooldown
// sessionDeadFails 连续 12153(ErrSessionDead)计数。12153 在真实环境会被临时性触发
// (网络抖动/上游闪断/refresh 竞态),一次失败就永久禁用太粗暴——连续达到阈值才判死。
// 持久化(stateAccount.SessionDeadFails):上游持续 session dead 时重启归零会导致
// 重学(再吃 2 次失败才禁用,期间每次都白打一轮上游);清零点(refresh/chat 成功、
// 手工复活)同样落盘,重启后不残留旧计数。
sessionDeadFails int
// consecutiveFails 连续失败计数(连败降权,issue #114)——「不知道原因的兜底」:
// 覆盖 ErrClient(未知 4xx)与传输层失败(连不上上游)这类 applyErrorPolicy
// default 分支不罚号的形态。与 sessionDeadFails 同构但独立计数:12153 的终态
// 是 Disable,这里的终态是临时出池(degradeUntil)。清零点:NoteSuccess。
// 持久化(stateAccount.ConsecutiveFails + DegradeUntil):restart 归零会让
// 「上游持续故障 + 频繁重启」的组合重新学满 5 次;degradeUntil 持久化让降权期
// 重启不失忆(与 breakerUntil 同口径)。
consecutiveFails int
// degradeUntil 连败降权截止:非零且未到期时该账号不参与 normal 选号(临时
// 出池)。与冷却/熔断**取更长者不叠加**(healthy 判定是并列或门,任一截止
// 未到期即不可选,生效的一定是最远者),到期自动回池,无需显式复位。
degradeUntil time.Time
// modelCost 实测扣费账本:model → 观测。由每次成功请求的 usage.credit
// 折算而来(上游没有"按模型的用量"接口,只能实测)。选号时据此把「该模型上
// 免费/便宜的号」排在前面(pick 的 costTier 硬分层)。
// 持久化(stateAccount.ModelCosts,P1-anti-monopoly):重启后成本知识保留;
// 落盘/恢复按 modelCostTTL 惰性过滤,陈旧观测不复活。
modelCost map[string]modelCostEntry
// inFlight 单账号在途请求数(运行态,不持久化)。用 atomic 避免 Pick 热路径拿写锁。
inFlight atomic.Int64
}
// modelCostOf 返回该账号在指定 model 上的有效成本观测;无观测或观测过期返回 ok=false。
func (e *entry) modelCostOf(model string, now time.Time) (modelCostEntry, bool) {
if model == "" || len(e.modelCost) == 0 {
return modelCostEntry{}, false
}
mc, ok := e.modelCost[model]
if !ok {
return modelCostEntry{}, false
}
if mc.LastSeen.IsZero() || now.Sub(mc.LastSeen) > modelCostTTL {
return modelCostEntry{}, false // 过期:时段性优惠(夜间免费)不得跨时段生效
}
return mc, true
}
// healthy 报告账号当前是否可选(未禁用、未处于任一冷却/熔断/连败降权期)。
// 连败降权与冷却/熔断同入本判定(取更长者不叠加:三个截止是并列的或门,
// 只要任一未到期即不可选,天然「并存取更远者」——不需要显式比较长短)。
func (e *entry) healthy(now time.Time) bool {
if e.disabled || e.paused {
return false
}
if !e.until.IsZero() && now.Before(e.until) {
return false
}
if !e.breakerUntil.IsZero() && now.Before(e.breakerUntil) {
return false
}
if !e.degradeUntil.IsZero() && now.Before(e.degradeUntil) {
return false
}
return true
}
// modelExempt 报告账号是否处于「6004 模型级软冷却」形态:存在任一有效的 6004
// 模型级冷却(modelCooldowns 非空),且尚未禁用、未熔断。
// 此形态下账号仅对限流中的模型不可用,对其他模型仍可选(issue #31)。
// healthyForModel 与 ServableNow 共用本谓词,保证 chat 选号与探活口径一致。
// 调用方负责 now 与冷却有效性的判断(本方法只看形态,不看冷却是否已过期)。
func (e *entry) modelExempt() bool {
if e.disabled || e.paused || !e.until.IsZero() || !e.degradeUntil.IsZero() || !e.breakerUntil.IsZero() {
return false
}
for _, mc := range e.modelCooldowns {
if !mc.AuditOnly && !mc.Until.IsZero() {
return true
}
}
return false
}
// modelCooled 报告账号对指定 model 是否正处 6004 模型级冷却(该模型的独立冷却未过期)。
// 空 reqModel / 未记录 → false(不因模型级维度限制账号)。
func (e *entry) modelCooled(now time.Time, reqModel string) bool {
if reqModel == "" {
return false
}
mc, ok := e.modelCooldowns[reqModel]
if !ok || mc.AuditOnly {
return false
}
return !mc.Until.IsZero() && now.Before(mc.Until)
}
// healthyForModel 报告账号对指定 model 是否可选(含 6004 模型级独立冷却判定):
// - disabled → 永不可选(最高优先级);
// - 该模型正处 6004 独立冷却(modelCooldowns[reqModel] 未过期)→ 不可选
// (多模型限流时各自独立,互不影响);
// - 否则 → 回落到账号级 healthy(until/breakerUntil 维度)。
//
// 对比旧实现(softRateModel 单字段豁免"仅锁一个模型、其他豁免"),新语义天然支持
// 任意多个模型同时限流:被 B 限流的账号对 A 请求仍可选(A 不在 modelCooldowns 拦截
// 且账号级 healthy 成立)。空 reqModel / 未记录模型 → 等价 healthy。
func (e *entry) healthyForModel(now time.Time, reqModel string) bool {
if e.disabled || e.paused {
return false
}
if e.modelCooled(now, reqModel) {
// 该模型在 6004 独立冷却中 → 不可选。
return false
}
// 账号级冷却/熔断先判;若未冷却则由账号级健康决定。
return e.healthy(now)
}
// pruneExpiredModelCosts 删除 modelCost 中已过期的条目(惰性清理,与
// pruneExpiredModelCooldowns 同形同调用点)。
//
// 为什么必须有它:modelCost 此前只在**读**(modelCostOf)、**落盘**(persist)、
// **恢复**(persist)、**status**(state.go)四处做"过期过滤",内存里的条目本身
// 从不回收——即「map 只增不减」。而 entry.modelCost 的注释明确声称
// 「落盘/恢复按 modelCostTTL 惰性过滤,陈旧观测不复活(同 modelCooldowns 口径)」,
// 模型级冷却表正是靠 pruneExpiredModelCooldowns 在 pick 写锁路径做真正删除的
// (见 pick.go「map 不无限膨胀」)。两者口径不一致:一旦某模型的观测过期,它就会
// 永久占据一条内存(进程重启才清),并在后续每一轮 pick 的遍历、每次 status 遍历里
// 被反复判定为过期(只是没人删)。
//
// 观测只在成功请求路径写入(NoteModelCost),且 model 不与目录校验,故增长受
// "历史服务过的模型名"限制——不是无界泄漏,但同样是"只增不减"的无回收表。
// 调用方必须已持有 p.mu 写锁。
func (e *entry) pruneExpiredModelCosts(now time.Time) {
if len(e.modelCost) == 0 {
return
}
for m, mc := range e.modelCost {
if mc.LastSeen.IsZero() || now.Sub(mc.LastSeen) > modelCostTTL {
delete(e.modelCost, m)
}
}
}
// pruneExpiredModelCooldowns 删除 modelCooldowns 中已过期的条目(惰性清理)。
// pick 写锁路径与 revive 调用,防止 map 无限膨胀;status 只读遍历天然跳过过期项,
// 无需清理。调用方必须已持有 p.mu 写锁。
func (e *entry) pruneExpiredModelCooldowns(now time.Time) {
if len(e.modelCooldowns) == 0 {
return
}
for m, mc := range e.modelCooldowns {
if mc.Until.IsZero() || !now.Before(mc.Until) {
delete(e.modelCooldowns, m)
}
}
}
// expiry 返回账号当前仍在生效的最近冷却/熔断/降权截止时间(三个截止取最早者);不在冷却期返回零值。
// 供全冷却兜底选取"最早到期"账号用。连败降权计入兜底口径:降权号参与兜底(其失败
// 形态是「不知道原因」,到期放行半开试探正是兜底语义——CoolHard 才被排除)。
func (e *entry) expiry(now time.Time) time.Time {
var t time.Time
if !e.until.IsZero() && now.Before(e.until) {
t = e.until
}
if !e.breakerUntil.IsZero() && now.Before(e.breakerUntil) {
if t.IsZero() || e.breakerUntil.Before(t) {
t = e.breakerUntil
}
}
if !e.degradeUntil.IsZero() && now.Before(e.degradeUntil) {
if t.IsZero() || e.degradeUntil.Before(t) {
t = e.degradeUntil
}
}
return t
}
// fallbackKind 报告兜底账号属于哪一类冷却(soft:即时软冷却/连败降权;breaker:熔断期)。
// 只对参与兜底的账号调用(CoolHard 已被 pickEarliestExpiryLocked 排除)。判定口径:
// 若熔断截止是当前生效的最近截止(含"仅有熔断无软冷却"),记为 breaker;否则记为 soft
// (连败降权与软冷却同归 soft:都按各自截止到期放行,兜底处置无差异)。
func (e *entry) fallbackKind(now time.Time) string {
if !e.breakerUntil.IsZero() && now.Before(e.breakerUntil) {
if e.until.IsZero() || !now.Before(e.until) || e.breakerUntil.Before(e.until) {
return "breaker"
}
}
return "soft"
}
// stateAccount 单个账号的持久化状态(JSON tag 全小写下划线,向后兼容:缺字段零值)。
type stateAccount struct {
Credits int64 `json:"credits"`
CreditsTotal int64 `json:"credits_total,omitempty"`
Disabled bool `json:"disabled"`
Paused bool `json:"paused,omitempty"`
Reason string `json:"reason,omitempty"`
Until time.Time `json:"until,omitempty"`
CoolKind CoolKind `json:"cool_kind"`
SuccessCount int64 `json:"success_count,omitempty"`
// err_total 累计错误计数。旧版 err_count(连续错误)仍可读:加载时映射到 err_total,
// 仅作一次性迁移,不再回写 err_count。
ErrTotal int64 `json:"err_total,omitempty"`
ErrCount int `json:"err_count,omitempty"` // 兼容旧文件的迁移源,仅读取
LastSuccess time.Time `json:"last_success,omitempty"`
LastErr time.Time `json:"last_err,omitempty"`
// LastCheckinDay 最近一次签到成功的本地日期(entry.lastCheckinDay 同源)。
// 持久化以保留「当日已签」状态:签到后重启,面板按钮不回退成「签到」。
LastCheckinDay string `json:"last_checkin_day,omitempty"`
TokenUsage TokenUsage `json:"token_usage,omitempty"`
// 运行态计数(soft_streak/session_dead_fails/credits_expiring)不用 omitempty:
// 零值缺失会让人误以为"没记录",实际是零值被省略。
SoftStreak int `json:"soft_streak"`
// SessionDeadFails 连续 12153 计数(判定 session 死亡的进度)。持久化以保留
// 「重启后连续计数继续累计」。
SessionDeadFails int `json:"session_dead_fails"`
// ConsecutiveFails 连续失败计数(连败降权进度,见 entry.consecutiveFails)。
ConsecutiveFails int `json:"consecutive_fails"`
// DegradeUntil 连败降权截止(issue #114)。仅未过期才持久化(落盘/恢复均惰性
// 过滤),避免降权期重启失忆;过期/零值不写。指针语义同 BreakerUntil。
DegradeUntil *time.Time `json:"degrade_until,omitempty"`
// BreakerUntil 熔断截止(指数退避)。仅未过期才持久化(落盘/恢复均惰性过滤),
// 避免熔断期重启失忆:breakerUntil 在未来时重启后仍阻断选号。过期/零值不写。
// 用 *time.Time(而非 time.Time):Go 的 omitempty 对非指针 time.Time 的零值
// 不生效(会序列化成 0001-01-01T00:00:00Z);指针 nil 才能真正被 omitempty 省略,
// 与落盘"过期不写"的口径一致。
BreakerUntil *time.Time `json:"breaker_until,omitempty"`
// RetryCount 已熔断次数(指数退避的指数)。持久化以保留"越熔越长"的退避累积——
// 重启归零会让反复熔断只从最小退避开始。恢复时若 BreakerUntil 已过期则归零。
RetryCount int `json:"retry_count,omitempty"`
// CreditsExpiring 快过期积分子集(credits 的子集)。持久化以保留到期路由
// (最早到期路由)的偏好——重启后到下次签到之间不应失忆。
CreditsExpiring int64 `json:"credits_expiring"`
// CreditsEarliestExpiry / CreditsEarliestRemaining 最早未来到期批次及剩余量。
// 与 CreditsExpiring 一并持久化,重启后首次请求仍可沿用最近一次余额快照。
CreditsEarliestExpiry time.Time `json:"credits_earliest_expiry,omitempty"`
CreditsEarliestRemaining int64 `json:"credits_earliest_remaining,omitempty"`
// ModelCooldowns 模型级独立冷却表(model → 冷却记录:6004 重置墙钟 / 11102
// 负缓存退避)。持久化:6004 对齐上游重置墙钟后单模型冷却可长达数小时,
// 跨重启是常态;不持久化会导致 healthyForModel 重启失忆、重新踩雷区。
// 恢复时惰性过滤已过期条目。
ModelCooldowns map[string]stateModelCooldown `json:"model_cooldowns,omitempty"`
// ModelCosts 实测扣费账本(model → 单价观测,见 entry.modelCost)。持久化
// (P1-anti-monopoly):重启后成本知识保留,限免/夜间免费的跨重启窗口不再
// 重新付学费探测。落盘/恢复均按 modelCostTTL 惰性过滤(6h 外不写不恢复——
// 陈旧价格不复活);恢复侧剔除非法值(负 per1k/零 LastSeen 的结构破损条目)。
ModelCosts map[string]stateModelCost `json:"model_costs,omitempty"`
}
// stateModelCooldown 单个 (账号, 模型) 的模型级独立冷却持久化记录,与运行态
// modelCooldown 同构(Until/ResetAt/Reason 字段名与语义对齐),落盘/恢复往返无损。
// Hits 不落盘(重启后 11102 退避从 6h 基数重新学习,同 modelCost 口径)。
type stateModelCooldown struct {
Until time.Time `json:"until"`
ResetAt time.Time `json:"reset_at,omitempty"`
Reason string `json:"reason,omitempty"`
AuditOnly bool `json:"audit_only,omitempty"`
}
// stateModelCost 单个 (账号, 模型) 的成本观测持久化记录,与运行态 modelCostEntry
// 同构(单一表示,内存与落盘不搞两套)。
type stateModelCost struct {
CostPer1k float64 `json:"cost_per_1k"`
LastSeen time.Time `json:"last_seen"`
Samples int `json:"samples,omitempty"`
}
// stateFile 持久化格式。
type stateFile struct {
Accounts map[string]stateAccount `json:"accounts"`
}
// flushInterval 后台落盘周期。
const (
defaultBreakerThreshold = 3
defaultBreakerCooldown = 30 * time.Minute
defaultBreakerCooldownMax = 6 * time.Hour
)
// defaultSoftRateMax 软冷却指数退避的默认封顶:softRateMax 未注入(<=0)时按此值算,
// 避免测试/裸用池时退避无上限。
const defaultSoftRateMax = 2 * time.Hour
// defaultDegrade* 连败降权默认参数(issue #114):连败 5 次临时出池 10 分钟。
const (
defaultDegradeThreshold = 5
defaultDegradeCooldown = 10 * time.Minute
defaultDegradeCooldownMax = 2 * time.Hour
)
// defaultCostExploreInterval costTier 条件探索的默认窗口(issue #136 方案 a′)。
// 取 30m:≤ 48 次/天/模型 的探索上限算术(24h/30m=48),与池规模和 QPS 无关。
// 探索=搭车改道(把一个既有真实用户请求改道给 tier 1 号),零新增上游请求;
// 增量成本只是「该请求本可打免费号、实际打了可能收费的号」的期望计费差,
// 且 tier 1 枯竭(活跃模型全号已学)后税基收敛到 0。config 显式 "0" 关停。
const defaultCostExploreInterval = 30 * time.Minute
// sessionDeadThreshold 连续 ErrSessionDead(12153)达到该次数才永久禁用。
// 12153 会被临时性触发(网络抖动/上游闪断/refresh 竞态),一次失败即禁用的旧行为
// 会误杀健康账号(P0-1:13 个 disabled 号全是误判)。3 次连续才判死:容忍偶发抖动,
// 又不会让真正的死 session 留在池里反复被选中。
const sessionDeadThreshold = 3
// sessionDeadReason 12153 判定为 session 死亡时的持久化 reason。
const sessionDeadReason = "12153 session dead"
// SessionDeadThreshold 暴露连续 12153 的禁用阈值(供 scheduler 日志/运维文档引用)。
func SessionDeadThreshold() int { return sessionDeadThreshold }
// softStreakShiftMax 软冷却退避的最大左移位数(防 1<<streak 溢出成负数/零)。
// 无论 streak 累积多少,封顶逻辑总会先生效,此值只是溢出兜底。
const softStreakShiftMax = 16
// StoreSnapshotter 池状态快照镜像的最小接口(redisstore.Store 满足;Noop 空实现安全)。
// 与本地 state.json 并存,作启动恢复备份:快照比本地新才采用,否则本地优先。
const (
defaultIdleWeightPerHour = 0.5
defaultIdleWeightMax = 5.0
)
// New 构建池;stateFp 非空时尝试加载旧状态,并启动后台周期性落盘 goroutine。
|