File size: 24,767 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 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 | // 账号状态演进与查询:禁用/12153 连续计数判定、成功与错误入账、复活解冻,
// 以及状态查询(Status/AvailableUIDs/PickByUID/CountsDetailed/ServableNow/List)。
package pool
import (
"log"
"sort"
"strings"
"time"
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
"github.com/linguo2625469/workbuddy2api-panel/internal/logfmt"
)
func (p *Pool) Disable(uid, reason string) {
p.mu.Lock()
defer p.mu.Unlock()
if e, ok := p.byUID[uid]; ok {
p.disableLocked(e, reason)
}
}
// NoteSessionDead 记录一次 ErrSessionDead(12153)——**不立即禁用**。
// 旧行为一次 12153 即 Disable,但 12153 会被临时性触发(网络抖动/上游闪断/refresh
// 竞态),一次失败就永久杀号会误杀健康账号(P0-1 侦察:13 个 disabled 号全部 refresh
// 成功,是历史误判的受害者)。改为连续 sessionDeadThreshold 次才禁用:
// 计数 +1,达到阈值 → Disable(reason=12153 session dead)并清计数;
// refresh 成功 / 任意成功 / 手工复活 → ClearSessionDead 清计数。
// 返回 true 表示本次已达阈值并完成禁用。
// 即使账号已 disabled,计数仍累计并返回 false 前 N-1 次——但 keepalive 会跳过
// disabled 号,实际只有「已 disabled 后复活且计数未清」这类场景才会走到这里。
func (p *Pool) NoteSessionDead(uid string) bool {
p.mu.Lock()
defer p.mu.Unlock()
e, ok := p.byUID[uid]
if !ok {
return false
}
e.sessionDeadFails++
if e.sessionDeadFails < sessionDeadThreshold {
return false
}
e.sessionDeadFails = 0
p.disableLocked(e, sessionDeadReason)
return true
}
// ClearSessionDead 清连续 12153 计数——账号被证明未死的任何时刻调用:
// refresh 成功(RunKeepaliveNow)、chat 成功(NoteSuccess)、手工复活(ReviveDisabled)。
func (p *Pool) ClearSessionDead(uid string) {
p.mu.Lock()
defer p.mu.Unlock()
if e, ok := p.byUID[uid]; ok {
e.sessionDeadFails = 0
}
}
// ReviveDisabled 人工/端点复活入口:清除 disabled + reason + 连续 12153 计数,
// 账号回到池子(若无其他冷却/熔断则立即可选,健康检查自然接管)。
// **不改** Disabled 在选号/状态端点的既有语义:disabled 号依然不参与选号,
// 直到被本方法复活。不存在的 uid 为空操作。
func (p *Pool) ReviveDisabled(uid string) {
p.mu.Lock()
defer p.mu.Unlock()
if e, ok := p.byUID[uid]; ok && e.disabled {
e.disabled = false
e.reason = ""
e.sessionDeadFails = 0
p.dirty.Store(true)
}
}
// Revive 运维口径的"无条件恢复":清禁用、冷却(含软退避计数)与熔断运行态。
// 与 ReviveDisabled(只清禁用)和 ReenableIfCredits(只清冷却、不动熔断)的区别:
// 本方法清除全部惩罚状态,供管理面板"解冻"按钮使用——人工判断该号可用时一键恢复。
// uid 不存在返回 false(供调用方区分"账号不存在"与"已复活")。
func (p *Pool) Revive(uid string) bool {
p.mu.Lock()
defer p.mu.Unlock()
e, ok := p.byUID[uid]
if !ok {
return false
}
e.disabled = false
e.paused = false // 解冻是全清:暂停选号一并解除
e.until = time.Time{}
e.coolKind = 0
e.reason = ""
e.softStreak = 0
e.modelCooldowns = nil // 模型级限流豁免随冷却一并清(防泄漏到后续账号级限流)
e.sessionDeadFails = 0
e.fails = 0
e.retryCount = 0
e.breakerUntil = time.Time{}
p.dirty.Store(true)
return true
}
// Pause 暂停选号:账号退出选号候选,但**照常参与**签到 / 活跃上报 / 保活 / 余额刷新。
// 与 Disable 的区别:不写 reason、不清冷却域、不重置任何计数——账号是「临时让位」
// 而非「判死」,故无需重登或人工解冻,Resume 即可立刻恢复。
// uid 不存在返回 false(供调用方区分"账号不存在"与"已暂停")。
func (p *Pool) Pause(uid string) bool {
p.mu.Lock()
defer p.mu.Unlock()
e, ok := p.byUID[uid]
if !ok {
return false
}
p.pauseLocked(e)
return true
}
// Resume 解除暂停选号(幂等,对未暂停账号为空操作)。uid 不存在返回 false。
func (p *Pool) Resume(uid string) bool {
p.mu.Lock()
defer p.mu.Unlock()
e, ok := p.byUID[uid]
if !ok {
return false
}
p.resumeLocked(e)
return true
}
// reviveCoolingLocked 只解冻余额耗尽冷却(CoolHard 的 until/coolKind/reason)并更新
// credits,不动熔断器(fails/retryCount/breakerUntil)、软限流退避(CoolSoft/softStreak)
// 与模型级台账(modelCooldowns)——限流冷却的恢复证据是重置墙钟到期,不是余额恢复。
// 签到/余额刷新解冻走这里:余额恢复只证明 billing 通道健康,不证明 chat 通道健康,
// 熔断(连续 5xx 信号)与限流冷却均不应被余额刷新覆盖。
// 调用方必须已持有 p.mu。
func (p *Pool) ReenableIfCredits(uid string, remain, total int64) {
p.mu.Lock()
defer p.mu.Unlock()
if e, ok := p.byUID[uid]; ok {
if remain > 0 && !e.disabled {
p.reviveCoolingLocked(e, remain, total)
} else {
e.credits = remain
e.creditsTotal = total
}
// ReenableIfCredits 只有聚合余额上下文;到期明细必须由 SetCreditsDetailed
// 重新写入,不能沿用旧窗口/旧批次的缓存。
e.creditsExpiring = 0
e.creditsEarliestExpiry = time.Time{}
e.creditsEarliestRemaining = 0
p.dirty.Store(true)
}
}
// NoteError 记录一次错误:喂入唯一的连续失败计数器 fails + 累计错误 errTotal。
// 达到 breakerThreshold 触发熔断(指数退避),连续失败语义整体并入熔断器(不再有独立的 err 冷却)。
func (p *Pool) NoteError(uid string) {
p.mu.Lock()
defer p.mu.Unlock()
if e, ok := p.byUID[uid]; ok {
e.errTotal++
e.lastErr = time.Now()
p.recordBreakerFailureLocked(e)
p.dirty.Store(true)
}
}
// NoteSuccess 成功请求累加成功计数、刷新 lastSuccess,并清空连续失败与熔断运行态。
// 二进制模型:清 fails + retryCount + breakerUntil;不碰 until/coolKind(那些是即时冷却,各自到期)。
// 额外清 softStreak:成功是账号已恢复的最强证据,连续软限流计数就此归零、退避回到基数。
// 同样清 sessionDeadFails 与连败降权计数(consecutiveFails/degradeUntil,issue #114):
// 成功证明账号当前可用,连败计数与临时出池截止一并归零。
// **不碰 modelCooldowns**:6004 模型级 limit 每模型独立计时,其他模型成功不得抹掉
// 本模型的冷却截止(这正是"每模型独立"的语义)。模型级冷却只由到期/复活/账号级
// 冷却(Cooldown/reviveCoolingLocked)清除。11102 条目的成功清理由 handler 在
// 成功且模型命中时显式调 BlockModelClear(6004 不清,语义不同)。
func (p *Pool) NoteSuccess(uid string) {
p.mu.Lock()
defer p.mu.Unlock()
if e, ok := p.byUID[uid]; ok {
e.successCount++
e.lastSuccess = time.Now()
e.fails = 0
e.retryCount = 0
e.breakerUntil = time.Time{}
e.softStreak = 0
e.sessionDeadFails = 0
e.consecutiveFails = 0
e.degradeUntil = time.Time{}
p.dirty.Store(true)
}
}
// NoteModelCost 记录一次实测扣费观测,更新该 (账号, 模型) 的成本账本,并顺带
// 扣减账号余额(credits/creditsExpiring/最早到期批次)。credit 为上游 usage.credit(本次真实
// 扣费=消耗量),tokens 为本次请求的 token 总数(prompt+completion,用于折算单位
// 成本)。tokens<=0 时不记录:无法折算单价,记进去会污染账本。
//
// 用 EMA 平滑(alpha=0.3,约 5 次观测收敛):单次异常值不主导选号决策。
// 账本持久化到 state.json(stateAccount.ModelCosts):重启后成本知识保留,
// 限免/夜间免费的跨重启窗口不再重新付学费探测;落盘/恢复均按 modelCostTTL
// 惰性过滤——陈旧价格(时段性优惠)不跨 TTL 复活。
// 限免结束事件:tier 0 观测(per1k≤0)被 credit>0 观测覆盖时打一条明确日志
// (运维据此知道"免费午餐结束了"),判定在写入口做、只看覆盖前值。
//
// credits 签到外回写:credit 是本次请求的**消耗量**,不是剩余余额。顺手扣减
// credits 与到期快照,让选号余额因子随消耗实时收敛——旧口径只在签到
// (每天 09:00/21:00 两次)刷新,两次签到之间(最长 12h)高消耗号持续高权重直到
// 打空撞 402;global 账号不签到,credits 曾是终身冻结。签到仍定期覆盖
// (ReenableIfCredits/SetCreditsDetailed 以 authoritative 余额重置),扣减只是
// 两次签到之间的内插估计;credit=0(免费请求)不动余额。
func (p *Pool) NoteModelCost(uid, model string, credit float64, tokens int) {
if uid == "" || model == "" || tokens <= 0 {
return
}
// 单价按每千 token 归一,消除请求长度差异。
per1k := credit / float64(tokens) * 1000
if per1k < 0 {
per1k = 0
}
p.mu.Lock()
defer p.mu.Unlock()
e, ok := p.byUID[uid]
if !ok {
return
}
if credit > 0 {
d := int64(credit + 0.5) // 四舍五入
if d > e.credits {
d = e.credits // 钳 0:扣穿(对账延迟/消费早于记账)不产生负余额
}
e.credits -= d
if e.creditsExpiring > 0 {
if d > e.creditsExpiring {
e.creditsExpiring = 0
} else {
e.creditsExpiring -= d
}
}
if e.creditsEarliestRemaining > 0 {
if d >= e.creditsEarliestRemaining {
e.creditsEarliestRemaining = 0
e.creditsEarliestExpiry = time.Time{}
} else {
e.creditsEarliestRemaining -= d
}
}
}
if e.modelCost == nil {
e.modelCost = make(map[string]modelCostEntry)
}
const alpha = 0.3
prev, seen := e.modelCost[model]
if !seen {
e.modelCost[model] = modelCostEntry{CostPer1k: per1k, LastSeen: time.Now(), Samples: 1}
} else {
// 限免结束事件(判定在写入口,只看覆盖前值):此前 tier 0(实测免费,
// per1k≤0)且本次实测收费(per1k>0)——账号在该模型上的免费窗口结束。
if prev.CostPer1k <= 0 && per1k > 0 {
log.Printf("[pool] model %s on uid %s: free tier ended, now %.3f credits/1k", model, logfmt.UID8(uid), per1k)
}
e.modelCost[model] = modelCostEntry{
CostPer1k: prev.CostPer1k*(1-alpha) + per1k*alpha,
LastSeen: time.Now(),
Samples: prev.Samples + 1,
}
}
p.dirty.Store(true) // 账本已持久化:写入口统一置脏
}
// RecordTokenUsage 记录一次实际发起的聊天账号尝试及上游返回的 usage 增量。
// usage 字段缺失时仍累计请求次数,但只累计明确存在的 token 字段。
func (p *Pool) RecordTokenUsage(uid string, delta TokenUsageDelta) {
p.mu.Lock()
defer p.mu.Unlock()
e, ok := p.byUID[uid]
if !ok {
return
}
usage := &e.tokenUsage
usage.RequestCount++
usage.LastUsedAt = time.Now()
if delta.Model != "" {
usage.LastModel = delta.Model
}
known := false
if delta.HasPromptTokens && delta.PromptTokens >= 0 {
usage.PromptTokens += delta.PromptTokens
known = true
}
if delta.HasCompletionTokens && delta.CompletionTokens >= 0 {
usage.CompletionTokens += delta.CompletionTokens
known = true
}
if delta.HasTotalTokens && delta.TotalTokens >= 0 {
usage.TotalTokens += delta.TotalTokens
known = true
}
if known {
usage.UsageCount++
}
if delta.HasLatencyMs && delta.LatencyMs >= 0 {
usage.LastLatencyMs = delta.LatencyMs
}
if delta.HasTokensPerSecond && delta.TokensPerSecond >= 0 {
speed := delta.TokensPerSecond
usage.LastTokensPerSecond = &speed
} else {
// 失败或缺少 completion_tokens 时不展示上一次请求的旧吞吐速度。
usage.LastTokensPerSecond = nil
}
p.dirty.Store(true)
}
// Status 查询单账号状态。
func (p *Pool) Status(uid string) (Status, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
e, ok := p.byUID[uid]
if !ok {
return Status{}, false
}
return p.statusOf(uid, e), true
}
// AuthByUID 返回账号的完整凭证(给调度器/运维接口用)。
func (p *Pool) AuthByUID(uid string) *auth.Auth {
p.mu.RLock()
defer p.mu.RUnlock()
if e, ok := p.byUID[uid]; ok {
return e.a
}
return nil
}
// AvailableUIDs 返回当前 healthy 且未占满在途名额的账号 UID 列表(按 UID 排序,稳定输出)。
// 供会话粘性路由(internal/session)做快路径命中校验 + 双段分配;无可用返回空切片。
func (p *Pool) AvailableUIDs() []string {
p.mu.RLock()
defer p.mu.RUnlock()
now := time.Now()
uids := make([]string, 0, len(p.byUID))
for uid, e := range p.byUID {
if !e.healthy(now) {
continue
}
if p.inFlightFull(e) {
continue
}
uids = append(uids, uid)
}
sort.Strings(uids)
return uids
}
// AvailableUIDsForModel 同 AvailableUIDs,但把健康口径换成 healthyForModel:
// 在该模型上被 6004 限流的账号不列入,而在**其他模型**被限流的账号照常列入(模型豁免)。
// 供会话粘性按模型分配与命中校验;model 为空时等价于 AvailableUIDs。
func (p *Pool) AvailableUIDsForModel(model string) []string {
p.mu.RLock()
defer p.mu.RUnlock()
now := time.Now()
uids := make([]string, 0, len(p.byUID))
for uid, e := range p.byUID {
if !e.healthyForModel(now, model) {
continue
}
if p.inFlightFull(e) {
continue
}
uids = append(uids, uid)
}
sort.Strings(uids)
return uids
}
// PickByUIDForModel 同 PickByUID,但用 healthyForModel 校验:绑定号在当前模型被
// 6004 限流时返回 nil,让调用方(handler)解绑并回落普通轮换。
// 这是粘性能"换得动"的关键:绑定只记 uid,若只按账号级 healthy 校验,
// 被模型级限额的号(账号整体仍健康)会被持续选中直到轮换次数耗尽。
func (p *Pool) PickByUIDForModel(uid, model string) *auth.Auth {
p.mu.Lock()
defer p.mu.Unlock()
e, ok := p.byUID[uid]
if !ok {
return nil
}
now := time.Now()
if !e.healthyForModel(now, model) {
return nil
}
// 积分保底(粘性路径):与 pick 的 floorBlocked 同判据——触底 + 收费即拦
// (判据含上游目录倍率兜底,realm 取账号所属域——粘性号已确定,无需外部传入)。
// 返回 nil 后 handler 侧解绑粘性(unbindSticky)走普通轮换换号,粘性号回血
// 后下次会话重新绑定。
// 日志频次:天然每请求至多一条——首次返回 nil 即解绑,后续轮转不再调入本路径
// (无需额外节流);粘性续期中每个新请求一条,恰好是「余额仍在线下」的持续提醒。
if p.floorBlockedForRealmModel(e, model, e.a.Realm(), now) {
log.Printf("WARN: [pool] credit floor: sticky acct=%s model=%s credits=%d < floor=%d, unbind (paid model held out)",
logfmt.Label(e.a.UID, e.a.Nickname), model, e.credits, p.creditFloor)
return nil
}
if p.inFlightFull(e) {
return nil
}
e.lastUsed = now
p.pickSeq++
e.usedSeq = p.pickSeq
return e.a
}
// PickByUID 若 uid 当前 healthy 且未占满在途名额,返回其凭证(记录 lastUsed 防撞号);
// 否则返回 nil。供会话粘性路由命中校验与直取使用。
func (p *Pool) PickByUID(uid string) *auth.Auth {
p.mu.Lock()
defer p.mu.Unlock()
e, ok := p.byUID[uid]
if !ok {
return nil
}
now := time.Now()
if !e.healthy(now) {
return nil
}
if p.inFlightFull(e) {
return nil
}
e.lastUsed = now
p.pickSeq++
e.usedSeq = p.pickSeq
return e.a
}
// CountsDetailed 返回 total/healthy/cooling/disabled/inFlightFull 五类计数。
// cooling 含常规冷却(until)与熔断期(breakerUntil)。
// 注意:healthy 口径不含 inFlight 维度(是状态机权威判定,只看 disabled/until/breakerUntil);
// inFlightFull 是 healthy 的子集——healthy 里已达在途上限的账号数,供 /status 透出满载度。
// 与 ServableNow 的区别见该函数注释。
func (p *Pool) CountsDetailed() (total, healthy, cooling, disabled, inFlightFull int) {
return p.countsDetailedForRealm("")
}
// CountsDetailedForRealm 同 CountsDetailed,但仅统计 Realm()==realm 的账号;
// realm=="" 不加谓词(= CountsDetailed)。供 /status 按域分组透出。
func (p *Pool) CountsDetailedForRealm(realm string) (total, healthy, cooling, disabled, inFlightFull int) {
return p.countsDetailedForRealm(realm)
}
// countsDetailedForRealm 是两函数共用的遍历实现;realm=="" 不加谓词。
func (p *Pool) countsDetailedForRealm(realm string) (total, healthy, cooling, disabled, inFlightFull int) {
p.mu.RLock()
defer p.mu.RUnlock()
now := time.Now()
for _, e := range p.byUID {
if realm != "" && e.a.Realm() != realm {
continue
}
total++
switch {
case e.disabled || e.paused:
// paused(暂停选号)与 disabled 同样不可选,合并计入 disabled 类
//(/status 的「不可用」口径);细粒度区分由 Status.Paused 透出。
disabled++
case !e.healthy(now):
cooling++
default:
healthy++
if p.inFlightFull(e) {
inFlightFull++
}
}
}
return total, healthy, cooling, disabled, inFlightFull
}
// ServableNow 报告池当前是否可服务:存在至少一个 healthy 且未占满在途名额的账号。
// 与 CountsDetailed 的 healthy 口径不同:healthy 只看 disabled/until/breakerUntil(状态机权威判定),
// 不看 inFlight;ServableNow 额外叠加在途维度,与 chat 的真实可达性(Pick 会跳过 inFlightFull 账号)对齐。
// 专供 /healthz 用,避免"全账号 healthy 但都占满"时探活误报 200 而 chat 返回 503 的口径裂缝。
func (p *Pool) ServableNow() bool {
return p.ServableForRealm("")
}
// ServableForRealm 报告某 realm 是否可服务:存在至少一个该 realm 的 healthy 且未占满在途名额的账号。
// 与 ServableNow 同口径(healthy 或模型豁免、排除 inFlightFull),仅叠加 Realm()==realm 谓词。
// realm=="" 退化为 ServableNow(现状语义)。供 /healthz 按 realm 暴露 CN/global 各自可达性。
func (p *Pool) ServableForRealm(realm string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
now := time.Now()
for _, e := range p.byUID {
if realm != "" && e.a.Realm() != realm {
continue
}
if p.inFlightFull(e) {
continue
}
// 存在性语义:账号级 healthy,或处于模型级豁免形态(6004 单模型软冷却——
// 对触发模型不可用,对其他模型仍可选)。探活无请求模型上下文,取"存在可服务
// 模型"与 chat 实际可达性等价(issue #31 探活侧补齐)。
if e.healthy(now) || e.modelExempt() {
return true
}
}
return false
}
// List 返回所有账号状态(按 UID 排序,稳定输出)。
func (p *Pool) List() []Status {
p.mu.RLock()
defer p.mu.RUnlock()
uids := make([]string, 0, len(p.byUID))
for uid := range p.byUID {
uids = append(uids, uid)
}
sort.Strings(uids)
out := make([]Status, 0, len(uids))
for _, uid := range uids {
out = append(out, p.statusOf(uid, p.byUID[uid]))
}
return out
}
func (p *Pool) statusOf(uid string, e *entry) Status {
now := time.Now()
st := Status{
UID: uid,
// 限额台账(issue #36):仅「带解析时间 6004 的模型级软冷却」仍在生效时非空,
// 每模型一行(modelCooldowns 内未到期的条目),多模型同时限流全部展示。
// 到期判据 = 该模型的独立冷却 until 未过;条件满足才输出,随到期自然消失,
// 普通软冷却(无模型级表)/硬冷却不产生台账(零回归)。
RateLimitedModels: p.rateLimitedModelsLocked(e, now),
Realm: e.a.Realm(),
Nickname: e.a.Nickname,
Credits: e.credits,
CreditsTotal: e.creditsTotal,
CreditsExpiring: e.creditsExpiring,
CreditsEarliestExpiry: e.creditsEarliestExpiry,
CreditsEarliestRemaining: e.creditsEarliestRemaining,
Cooling: now.Before(e.until) || now.Before(e.breakerUntil),
Reason: e.reason,
Disabled: e.disabled,
Paused: e.paused,
SuccessCount: e.successCount,
ErrTotal: e.errTotal,
CheckinDone: e.lastCheckinDay == now.Format("2006-01-02"),
TokenUsage: e.tokenUsage,
LastSuccessTime: e.lastSuccess,
LastErrTime: e.lastErr,
Until: e.until,
SoftStreak: e.softStreak,
ModelCosts: p.modelCostsStatusLocked(e, now),
ConsecutiveFails: e.consecutiveFails,
DegradeUntil: e.degradeUntil,
InFlight: int(e.inFlight.Load()),
BreakerFails: e.fails,
BreakerUntil: e.breakerUntil,
}
if st.Disabled {
// 禁用账号透出禁用原因(运维看不到为什么死)。
st.DisabledReason = e.reason
}
if st.Cooling {
// 冷却剩余秒数(向上取整,避免 0 显示为已到期)。
// 常规冷却(until)与熔断期(breakerUntil)可能只有其一在生效,
// 取仍在未来且更晚截止的那个,避免仅熔断期时误报 0 / unknown。
remaining := int64(0)
if now.Before(e.until) {
if r := int64(time.Until(e.until).Seconds() + 0.999); r > remaining {
remaining = r
}
}
if now.Before(e.breakerUntil) {
if r := int64(time.Until(e.breakerUntil).Seconds() + 0.999); r > remaining {
remaining = r
st.CoolKind = "breaker"
}
}
st.CoolRemaining = remaining
if st.CoolKind == "" {
st.CoolKind = e.coolKind.String()
}
}
return st
}
// modelCostsStatusLocked 收集账号的有效成本台账行(P1-anti-monopoly 可观测性)。
// 仅 modelCostTTL 内的观测进台账(过期/零值跳过,与选号读取侧同口径);
// 模型名稳定排序。调用方必须已持有锁。
func (p *Pool) modelCostsStatusLocked(e *entry, now time.Time) []ModelCostStatus {
if len(e.modelCost) == 0 {
return nil
}
models := make([]string, 0, len(e.modelCost))
for m, mc := range e.modelCost {
if mc.LastSeen.IsZero() || now.Sub(mc.LastSeen) > modelCostTTL {
continue // 过期/零值:不进台账(与选号读取侧同口径)
}
models = append(models, m)
}
if len(models) == 0 {
return nil
}
sort.Strings(models)
rows := make([]ModelCostStatus, 0, len(models))
for _, m := range models {
mc := e.modelCost[m]
rows = append(rows, ModelCostStatus{
Model: m,
CostPer1k: mc.CostPer1k,
LastSeen: mc.LastSeen,
Samples: mc.Samples,
})
}
return rows
}
// ---------------------------------------------------------------------------
// 持久化
// ---------------------------------------------------------------------------
// rateLimitedModelsLocked 收集账号当前仍在限额的模型台账行(issue #36)。
// modelCooldowns 未到期条目按模型名稳定排序输出;全部到期/空表返回 nil。
// 调用方必须已持有锁(statusOf 只读路径持 RLock,本函数只读不写)。
func (p *Pool) rateLimitedModelsLocked(e *entry, now time.Time) []RateLimitedModel {
if len(e.modelCooldowns) == 0 {
return nil
}
// 先排序模型名,保证输出稳定(map 遍历无序)。
models := make([]string, 0, len(e.modelCooldowns))
for m := range e.modelCooldowns {
models = append(models, m)
}
sort.Strings(models)
rows := make([]RateLimitedModel, 0, len(models))
for _, m := range models {
mc := e.modelCooldowns[m]
if !mc.Until.IsZero() && now.Before(mc.Until) {
kind := "rate_limit"
if strings.HasPrefix(mc.Reason, "11102") {
kind = "model_unavailable"
}
row := RateLimitedModel{
Model: m,
Kind: kind,
Until: mc.Until,
Reason: mc.Reason,
}
// 上游原始重置墙钟:截断后 until==resetAt 时省略(omitempty),台账只显示真实恢复时刻。
if !mc.ResetAt.IsZero() && !mc.ResetAt.Equal(mc.Until) {
row.ResetAt = mc.ResetAt
}
rows = append(rows, row)
}
}
if len(rows) == 0 {
return nil
}
return rows
}
|