Files
ModelRouter/plan.md
root 7f91985583 fix(ui): restore five uniform KPI cards — title + big value + subtitle + real canvas charts (no gray-shell placeholders)
Redo the KPI row properly: five identical cards each with title row, large metric value, subtitle, and an 86px canvas chart. Replaces the previous gray-box canvas placeholders (which dropped the numbers — a regression).

- Active requests: line sparkline (real-time refresh) + live count
- Total requests: 7-day vertical bar chart + current total
- Tokens: stacked bar of per-model token share + big total
- Avg latency: line sparkline (real-time refresh) + avg ms
- Status codes: stacked bar by code + chip subtitle (code/reqs/percent)

All charts drawn with plain Canvas 2D (sparkline/dailyBars helpers), no third-party lib. DPR-aware for crispness. Deployed (bak .bak.20260811l), server active.
2026-08-11 16:57:04 +08:00

249 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ModelRouter 调度层重构方案(基于线上实测)
> 调研对象192.168.2.60 生产实例(`/usr/local/bin/llmsproxy -config /etc/llmsproxy/config.yaml`systemd 托管)
> 调研时间2026-08-10审计文件 `/etc/llmsproxy/runtime.json.audit.jsonl`3978 行 / 1042 条请求记录)
---
## 一、线上实测问题(证据链)
### P1核心用户实测编辑 AUTO 优先级不重载退避状态
- 证据:`/api/status` 实时返回 `qijiar available=false, live_available=true``zen available=false, live_available=true`
- 根因:`Core.SaveAutoRules`core.go:232只写 runtime.json不重建 Provider退避状态存在 `Provider.health`provider.go:24-51只有 `rebuildRegistry`core.go:290会重建。**用户在 WebUI 改完优先级后,被退避锁死的源依旧被跳过。**
- 线上复现AUTO 链 4 槽 `[qijiar/gpt-5.5→zen/flash-free→frank/gpt-5.6-sol→zen/nemotron]`,实时请求 `POST /v1/chat/completions {"model":"AUTO"}``{"error":{"message":"no provider available","type":"upstream_error"}}`0ms 返回;其中 2 个源探活确认在线。
### P2 源级黑名单(粒度错)
- 证据:`zen``429 FreeUsage` 退避(审计 ×4→ 其 9 个配置模型全部不可调度;`gpt-5.5` 的 qijiar 一次失败 → 源整体跳过。
- 根因:健康标志是 `(源)` 级而非 `(源, 模型)`provider.go:24一个模型失败黑掉全源。
### P3 永久黑名单(无法自愈)
- 证据:`frank` 收到一次 `401 API_KEY_DISABLED``markPermanent()`provider.go:48-51, 281-283此后 AUTO 永远跳过它,且有 30 分钟周期的显式探测持续打它产生 502502 又推高退避)。
- 根因:`permanent` 无过期、无重置入口;显式请求路径不检查 `Available()`,照打不误。
### P4 并发打满 = 排队 60s而非立即切换
- 证据/源码:`Provider.Acquire`provider.go:306-324满则等待 `QueueTimeout`(默认 60sconfig.go:154AUTO 逐槽串行,打满的档位拖死整条链。
-`TryAcquire` 语义,路由层无法区分"忙"与"故障"。
### P5 探活与调度状态脱节
- 探活GET /models不污染调度状态是正确设计但**探活结果没有任何一条通道能恢复调度健康**——线上 qijiar/zen "探活通、调度死" 的矛盾即因此产生。
### P6 流式成功不清退避
- 证据/源码:`ChatStream`provider.go:406-490全程无 `reportOK()`;流式源一旦退避,后续成功流也不复位。
### P7 两套 AUTO 逻辑并存,配置互相矛盾
- 线上config.yaml 里 qijiar priority 95/90/85、deepseek 40/30runtime AUTO 链却是 `tier1 gpt-5.5 → tier2 zen/flash-free → tier3 frank/gpt-5.6-sol → tier4 zen/nemotron`
- `Registry.Resolve("AUTO")`registry.go:83-121按"源的最大模型 priority + 健康优先")与 `autoPlans`chat.go:630按 runtime 槽位行为不一致deepseek 全部模型不在 AUTO 链中,但运维以为"priority 高"会被 AUTO 选中。
### P8 错误码策略粗糙
- `deepseek` 402 欠费 ×17`ReportStatus` 不触发任何退避也不在 UI 提示402 不属于 401/403/429/5xx 分支),欠费源被无限重试。
- 400 Invalid schema ×19客户端工具定义问题同样无区分。
- 链全灭时只返回 `no provider available`12 条),无分槽错误汇总,无法定位是谁挂了。
### P9 其他(顺带)
- 种子 admin key `sk-gw-local-0001` 未更换README 明确要求换);`listen: 0.0.0.0:8081` 全端口暴露。
- AUTO 链只有 4 槽13+ 配置模型不参与 AUTO且 webui 编辑链后无任何"健康复位"提示。
- 审计文件 jsonl 无限增长(当前 547KB`AppendAudit` 每事件一次文件 open。
### P10 全量代码审查发现2026-08-11全部核心文件通读后确认
- **P10-1已确认`handleSourcesAPI` 无 admin 校验**api.go:77-123全方法无 `reqRole` 检查,对比 `handleAdaptersAPI` api.go:21、`handleStatsAPI` api.go:138 均有)→ 任何 user 级 key 可 `GET /api/sources` 读取全部上游**明文 api_key**`config.Source.APIKey``json:"api_key"`config.go:42GET 分支直接返回 `g.core.Sources()`),并可 POST/DELETE 增删改源配置、篡改路由。**越权最高优先级修复项。**
- **P10-2推断`hasScopeModel` 不剥前缀**chat.go:219直接用原始模型串与 scope 比对 → 受限 user key 用 `zen:model` 前缀调用会被误判 403未实测待确认修复
- **P10-3已确认runtime.json 源无超时**JSON 源不序列化 timeout`mergedSources` 后 Timeout=0无限→ 生产 4 个源全部无超时;上游假死不返回时请求永久挂起、占满 `max_concurrent` 槽(历史上 2 条 120s `context canceled` 均为客户端主动断开)。补默认超时需注意 `http.Client.Timeout` 对 SSE 流式同样生效,需区分流式/非流式策略。
- **P10-4stats.go**:失败请求也计入配额消耗(`aggregateLocked` 无条件累计 tokens`nhour` 配额按整点小时桶粒度不精确(`WindowTokens` 按桶过滤user 视角 `Snapshot``by_status` 仍是全局分布(轻微泄露);`Record` 持 mu 时做文件 I/Oappend/rotate高 QPS 下有锁竞争。
- **P10-5scheduler.go**`Chat`/`ChatStream`/`Image` 重试次数受 `len(cands)` 限制MaxRetries 超出候选数时无效);`chainDrive` 有界等待期间重跑整档(硬失败会正确 break可接受
- **健全项(评审确认)**TryAcquire 忙不记分、chainDrive 硬失败正确打断等待、探活不污染调度状态、冷却必然到期自愈(无永久黑名单)、`ResolvePinned` 前缀解析41c9b0e与超时/退避公式5s·2ⁿ 封顶 30min正确。
---
## 二、目标架构(定稿)
### 2.1 分层(横向三层 + 状态基座横切)
```
┌────────────────────────────────────────────────┐
│ 直连调度 AUTO 调度 │ ← 同级,共享底座
│ source:model 直派 链快照 + 偏好表 │ 直连失败即返回(不重试)
├────────────────────────────────────────────────┤
│ 源抽象层(每源) │
│ · 信号量 = max_concurrentTryAcquire 非阻塞 │ ← 唯一"忙/闲"判定
│ · Chat / ChatStream / Image / 事件上报 │
├────────────────────────────────────────────────┤
│ adapter 池层(每适配器,启动预热) │
│ worker = Σ(使用该适配器的源 × max_concurrent) │
└────────────────────────────────────────────────┘
▲ 状态基座(不属任何层)
ModelState 表 (source,model) → {pref, failCount, cooldownUntil}
tier 游标表 tier → atomic next记录上次分配位置
```
事件流:所有成功/失败/冷却由**源抽象层**上报到状态基座auto 与直连都只读。直连失败同样写入状态。
### 2.2 数据结构
```go
// 调度链:保存时整体新建、原子替换;调度期只读快照
type Chain struct { Tiers []TierNode } // 按 tier 降序
type TierNode struct { Tier int; Slots []*Slot }
type Slot struct { // 静态配置,链构建时冻结配额
Model, Source string
Kind string
Quota int64; Period string; Hours int64
state *ModelState // 指针 → 跨链存活
}
// 状态基座key=(source,model),配置移除后回收)
type ModelState struct {
pref atomic.Int64 // +1/-5clamp[-20,+20]
failCount atomic.Int64
cooldownUntil atomic.Int64 // 惰性指数退避,无定时器
}
```
### 2.3 调度算法AUTO
```
snap := chain.Snapshot() // O(n) 拷指针,调度期链只读
for tier := snap.Tiers 降序 {
初筛 := 剔除 [冷却中 | 配额超限(slot.Quota>0 && windowTokens>=Quota)] 的槽
if 初筛为空 → 该 tier 顺延,记录原因
base := cursor[tier].Add(1) // per-tier 原子游标(记录上次位置)
ordered := 稳定排序(初筛, 按 pref 降序) // 负面模型沉底
for i := base; i < base+len(ordered); i++ {
slot := ordered[i % n]
if slot.state 冷却中 → continue // 硬跳过(复验)
if !src.TryAcquire() → continue // 忙 = 软跳过(不记分)
resp, err := src.Chat(slot.model)
if err != nil {
src.Release()
state.RecordFailure() // failCount++, pref-5, 冷却 5s·2^n(≤30min)
continue // 单请求内不重试已失败槽
}
state.RecordSuccess() // pref+1, failCount/cooldown 清零
return resp, slot.model, slot.source
}
}
return 503 { 各 tier 错误汇总 } // 全部档位失败才报错,可定位
```
关键语义:
- **主序 = 游标轮转(均衡),偏好只做同起点排序(自适应)**;负偏好"沉底不跳过"——仅当同 tier 无非负候选时才尝试负面模型,成功 +1 自愈。**无永久黑名单、无定时器**。
- **忙 ≠ 失败**:不扣分、不计冷却;同 tier 全忙 → 有界等待≤2s 轮询 TryAcquire尊重 ctx再顺延避免高 QPS 时全线降级。
- **冷却是唯一硬跳过**`cooldownUntil = lastFail + min(5s·2^failCount, 30min)`,必然到期 → 自愈。
- 401/403failCount 一次打高(不设永久位),可被冷却到期/手动重置恢复。
- 流式:失败→切换仅限首 chunk 前;首块后固定。首块前失败 -5正常结束 +1。
- 生图:不进 AUTO 链,直连 `kind:image` 模型,同一状态机制。
### 2.4 生命周期(修 P1
| 事件 | 动作 |
|---|---|
| WebUI 保存 AUTO 链 | 构造新 `Chain` → 写锁原子替换 → **链内全部 ModelState 冷却清零(偏好保留)** |
| 增删/编辑源 | `rebuildRegistry` 重建 ProviderModelState 复用/回收 |
| (source,model) 移出配置 | 回收其 ModelState偏好一并丢弃 |
| 进行中请求 | 不受 swap 影响(入口 Snapshot 已拷贝引用) |
### 2.5 直连
```
p := resolve(source, model) // 唯一归属,无 AUTO 逻辑
p==nil → 400/404!TryAcquire → 429 busy快速失败
成功/失败 → RecordSuccess/RecordFailure同样写状态基座返回不重试
```
---
## 三、实施计划(分阶段上线)
### Phase 0 — 止血热修(改动最小,当天可上)
1. `core.go``SaveAutoRules` 成功后调用 `rebuildRegistry()`;给 `Provider` 增加 `ResetHealth()`(清 failCount/permanent/cooldownrebuild 时顺带重置。
2. `provider.go``ChatStream` 成功(收到 `[DONE]` 或正常结束)调用 `reportOK()`
3. 状态页:`/api/status` 增加 `last_backoff``permanent` 展示 + admin 可"重置源健康"按钮(调用 ResetHealth
- 验证:改优先级 → AUTO 立即按新链调度qijiar/zen 场景恢复;回归 `go test`
### Phase 1 — 状态基座落地provider.go 重构)
1. 引入 `ModelState`(每 (source,model)pref/failCount/cooldownUntil原子
2. `Provider` 增加 `TryAcquire(ctx) error`(非阻塞)与 `RecordFailure(model, code)` / `RecordSuccess(model)``ReportStatus` 改为按模型记账与 401/403 不再永久化。
3. 删除 `permanent` 语义(由有界冷却 + 重置通道取代)。
- 验证单测退避按模型隔离、429/401 行为、TryAcquire 满即返)。
### Phase 2 — 调度层重写scheduler.go + chat.go
1. 新增 `Chain/TierNode/Slot` 与 Snapshot/原子替换;`autoPlans``rotateSameTier` 删除,`singleChatAuto`/`streamChatAuto` 按 2.3 伪码重写。
2. `Registry.Resolve` 移除 AUTO 排序/健康优先职责(只留归属 + pin 解析;`AUTOChain`/`Default` 不再用于调度)。
3. 503 响应携带分槽错误汇总(哪个 tier 哪个源什么错)。
4. 同 tier 全忙时 ≤2s 有界等待再降级。
- 验证scheduler 单测tier 分桶、游标均衡、偏好沉底自愈、配额初筛、忙不记分e2e 增加"上游 429 → 切同 tier → 再切下 tier"、"编辑优先级后退避清零"用例。
### Phase 3 — 收尾
1. UI优先级页展示链上模型的冷却/偏好状态;状态页按模型展示健康。
2. WebUI 保存链时前端提示"健康状态已复位"。
3. 审计错误码分类402 欠费、400 schema 计入独立统计jsonl 轮转(按天/大小)。
4. 运维:更换 admin key`listen` 收敛到内网地址0.0.0.0:8081 → 127.0.0.1 或内网 IP + 反向代理)。
### 回归与上线
- 构建:`go build -tags luajit`(生产机 Debian amd64 已具备工具链,可直接在 60 上编译);本地 Windows 需先补 LuaJIT 库才能链接。
- 测试:`go test -tags luajit ./...` 全绿后按 Phase 0→3 灰度,每个 Phase 观察审计中 `no provider available` 计数与 502 分布。
- 观察指标:`no provider available` 计数归零qijiar/zen 由 `available=false` 恢复;`zen 429` 触发时仅 zen 对应模型短冷却deepseek/qijiar 不受牵连。
---
## 四、与既有代码的对应改动文件
| 文件 | 改动 |
|---|---|
| internal/provider/provider.go | health→ModelState 表TryAcquireReportStatus/ResetHealthChatStream reportOK |
| internal/provider/registry.go | 删除 Resolve(AUTO) 排序/健康逻辑,保留归属/pin |
| internal/scheduler/scheduler.go | 新增 Chain/Slot/游标/偏好排序迭代器 |
| internal/gateway/chat.go | 删除 autoPlans/rotateSameTier重写单次/流式 AUTO错误汇总 |
| internal/core/core.go | SaveAutoRules 原子换链 + 冷却清零ModelState 生命周期 |
| internal/gateway/api.go / keys.go | 状态页/优先级页 UI 展示与重置接口 |
| e2e/e2e_test.go 等 | 新场景用例 |
---
## 五、实施状态追踪(每项改动后更新)
### Phase 0 — 止血热修
- [x] **P0-12026-08-10`core.go`**`SaveAutoRules` 持久化后调用 `rebuildRegistry()`——新建 Provider 即退避归零,**编辑优先级立即生效**(修 P1 主诉);新增 `Core.ResetHealth()` 供管理端手动清退避。
- [x] **P0-22026-08-10`provider.go`**:新增 `Provider.ResetHealth()` / `Provider.HealthInfo()`failCount/cooldownUntil/permanent 只读暴露);`ChatStream` 流式正常结束(`[DONE]`/EOF、非客户端断开、非读错误调用 `reportOK()`——流式成功可恢复退避(修 P6
- [x] **P0-32026-08-10`registry.go` + `server.go`**`SourceStatus` 增加 `fail_count`/`backoff_until`/`permanent` 字段(状态页可分辨"探活通但调度退避");新增 `POST /api/status/reset`admin 专属,写审计)。
- [x] **P0-42026-08-10验证**`go build ./internal/...` 通过;`go test ./internal/config/...` 通过Windows 缺 LuaJIT 库,带 cgo 的包无法本地链接,待生产机验证)。
> 说明P0-1 采用"重建 Provider"实现退避归零(非目标架构的 ModelState 粒度属临时止血Phase 1 引入 per-(源,模型) ModelState 后,`ResetHealth` 语义将迁移到模型级。
### Phase 1 — 状态基座落地
- [x] **P1-12026-08-10`provider.go`**`ModelState` 表落地pref±1/-5、failCount、cooldownUntil 原子,惰性求值无定时器);新增 `ErrBusy``TryAcquire`(非阻塞,满即返)、`ModelAvailable(model)``RecordFailure(model, code)` / `RecordSuccess(model)``Provider.Pref(model)``ReportStatus` 改按模型记账401/403 → 打满档冷却 + 双倍偏好惩罚,**不再永久化**5xx/429 → 指数退避400/402 不惩罚);`ResetHealth` 清全源模型状态;`HealthInfo` 源级聚合permanent 恒 false`Chat/ChatStream/Image` 全部按命中模型记账 + 内部 `Acquire``TryAcquire`(忙即 429不再排队 60s修 P4流式干净收尾 `RecordSuccess`(修 P6
- [x] **P1-22026-08-10`chat.go`**:直连/生图路径 `errors.Is(err, ErrBusy)` → HTTP 429`upstreamErrStatus`AUTO 槽改用 `ModelAvailable(slot.model)` 模型级冷却跳过frank 401 后其模型不被 AUTO 反复打,修 P3 的一半——永久黑名单已随 P1-1 移除)。
- [x] **P1-32026-08-10单测`provider_test.go`**模型级退避隔离m1 失败 m2 照常、401 → failCount 打满 capN + 冷却 ~30min + 偏好 -10非永久reset 即恢复)、单次失败冷却 ≈5s 且成功复位 +1、TryAcquire 满即返、Chat 忙时快速 ErrBusy、流式成功清理冷却P6 回归)。
- [x] **P1-42026-08-10本地验证**`go build ./internal/...``go vet ./internal/...``go test ./internal/config/...` 全绿provider/gateway 测试因 Windows 缺 `-llua` 仅静态检查通过,待生产机 `-tags luajit` 全量跑(同 P0-4 约束)。
- [ ] 生产机192.168.2.60`go test -tags luajit ./internal/provider/...` 回归 — 待 Phase 2/3 完成后一并部署验证。
### Phase 2 — 调度层重写
- [x] **P2-12026-08-10`scheduler.go`**`Chain/TierNode/Slot/Rule` 落地tier 降序;槽静态配置,同 tier 保持配置序per-tier `next atomic.Int64` 游标始 -1首请求从配置序开始`BuildChain` 丢弃 provider 解析失败的槽;`runTier` 按 2.3 语义重写——冷却复验硬跳过、busy 软跳过不记分、**硬失败记账后同档继续下一槽**(单请求不重试已失败槽)、整档遍历完才顺延;`chainDrive` 初筛(配额/冷却)→ 同 tier 按 `Pref` 稳定降序排序 → `NextStart()` 起步轮转 → 全忙/全冷却有界等待(`busyWait` 2s/`busyPoll` 100ms期间刷新冷却再降级`ChainErr` 携带各档 `TierError` + 顺延原因Error() 输出 `all auto tiers failed: tier N src/model: err; ...``ChainChat/ChainChatStream` 返回 (resp, source, model, err);直连 `Chat/ChatStream/Image` 保持不变;**scheduler 不再 import provider**`FromRegistry` 移入 gateway 为 `toScheduler`scheduler 单测不拉 LuaJIT 链接。
- [x] **P2-22026-08-10`registry.go` + `core.go`**`Registry.Resolve` 移除 AUTO 排序/健康优先AUTO→按配置序全量仅用于生图/tool 锚定未知模型→nil→gateway 404`AUTOChain`/`Default` 删除);`Core``autoChain atomic.Pointer[scheduler.Chain]` + `AutoChain()``buildAutoChain`(过滤 image-kind 与已不存在槽,**槽 Source 规范化为所有权源**使汇总/audit/配额窗口键一致)随 `rebuildRegistry``SaveAutoRules` 重建;`SaveAutoRules` = 持久化 → 原子换链 → **链内每槽 `ResetModelCooldown`(偏好保留,不重建 Provider**——修 P1 主诉(编辑优先级立即生效)。
- [x] **P2-32026-08-10`chat.go`**AUTO 分支改走 `AutoChain()`+`quotaExhausted``Quota<=0` 不过滤;`AutoPeriodSeconds`+`stats.WindowTokens` 实时判定);`singleChatAuto`/`streamChatAuto``ChainChat`/`ChainChatStream` 重写,总失败在**任何 SSE 字节前**写 JSON`upstreamErrStatus``ChainErr`→503错误消息即分档汇总`ErrBusy`→429、其余→502失败记录取首个 `TierError` 填 audit source/model直连无候选→404 `model_not_found`
- [x] **P2-42026-08-10单测`scheduler_test.go`**tier 分桶/降序、同 tier 游标轮转交替s1,s2,s1,s2、负偏好沉底仍可达硬失败换槽后由负面槽承接、busy 跳过不记分、整档全忙有界等待(实测 <1s后降级配额耗尽槽不调度全灭 503 汇总Tiers+Skipped 文本断言)、流式首 chunk 前失败换槽游标首帧从 0
- [x] **P2-52026-08-10本地验证Windows 已补齐 Lua** golua 自带 Lua 5.1 头对应的源码编成 `liblua.a` 放入 golua 模块目录golua 官方 Windows 做法本机 `go build/vet/test ./...` 全绿——**provider/gateway/lua/e2e 全部首次真正跑通**并借此揪出三处从未被发现的存量问题:① `RecordFailure(auth)` 只把 failCount 上限用于冷却算式未落盘计数器已修auth `failCount.Store(backoffCapN)`);② gateway 测试种子 key 未进 runtime store 导致全 401已修测试 cfg `GatewayKeys`);③ e2e failover 用例只有一个 chat AUTO 全灭必然 503已修新增第二 chat `fallback`真故障转移e2e `buildBinary` Windows 回退无 tag 构建bundled Lua透传适配器行为一致生产机仍优先 `-tags luajit`
- [x] **P2-62026-08-10Phase 2 场景测试**gateway 新增—— tier 硬失败顺延承接`TestChatAutoChainTierFailover`)、全灭 503 `a/a-m` 分档汇总`TestChatAutoChain503Summary`)、配额耗尽槽跳过且不再打上游`TestChatAutoQuotaSkip`)、`PUT /api/auto` 后冷却立即复位并恢复调度`TestAutoSaveResetsCooldown` P1 回归e2e 新增 `TestEndToEndAuto503`真实二进制 503 汇总)。全部本地跑通
- [x] **P2-72026-08-11**生产机 `go test -tags luajit ./...` 最终回归全绿 e2eluajit 版二进制11.4MB部署 `/usr/local/bin/llmsproxy`备份 `.bak.20260811h`+ 重启在线验证非流式/流式 AUTO权限回归全通过
### Phase 3 — 收尾
- [x] **P3-12026-08-10UI 链上健康展示**`GET /api/auto` 增加 `states``Core.AutoSlotStates` 遍历当前 Chain × `Provider.ModelHealthInfo`pref/failCount/cooldownUntil/cooling优先级页每块按 `model|source` 渲染徽标冷却红/失败橙×N偏好蓝title 说明状态页新增"状态码分布"卡片
- [x] **P3-22026-08-10保存链健康复位提示**`saveSort` toast 变更为 `排序已保存并热重载 · 链上冷却已复位`zh/en)。
- [x] **P3-32026-08-10审计分类与 jsonl 轮转**`Stats.byStatus map[int]*Stat`402 欠费/400 schema 等按状态码独立计数不触发 provider 退避`Snapshot.by_status` 有序输出audit 文件超 `auditRotateBytes`(64MB, var 可测) 轮转 `rename <path>.<unix>.old` 并保留最新 `auditKeepOld`(10) ——`rotateAuditLocked` mu `Record`/`AppendAudit` 内触发
- [x] **P3-42026-08-10运维项代码部分**`main.go` 启动告警——gateway_keys 为空 / 命中种子 keysk-gw-local-0001 提示轮换listen 绑定 0.0.0.0/:: 提示收敛内网生产实践 admin key内网绑定随本次上线执行
- [x] **P3-52026-08-10测试**新增 `stats_test.go`by_status 断言轮转保留上限Record 路径轮转gateway 新增 `TestAutoStatesReportChainHealth`states 契约失败后 fail_count>0+coolingPUT 复位归零);`go vet ./...` + `go test ./...` 全绿。
- [x] **P3-62026-08-10WebUI 右键菜单无法关闭(用户实测)**:根因——`showCtx` 创建菜单后从未赋值 `ctxEl``hideCtx()` 恒为空操作),任何路径(点菜单项/点外部/二次右键)都关不掉;补 `ctxEl = w`,优先级页与密钥页共用 `showCtx` 一并修复(这也是最初版本就存在的 bug
- [ ] **P3-7**:推送 origin → 生产机 pull → `-tags luajit` 全量回归(含 P2-7→ 部署 `/usr/local/bin/llmsproxy` + 重启 service → 观察。
- [x] **P3-82026-08-11审计回放修复用户实测**:重启后统计只剩最后 3000 行≈5.3M tokens历史 273M 消失、且记录里全是 access 脏行——根因 `LoadAudit`:① 只取文件尾 3000 行(而 92% 行是每 3s 的 access 事件);② access/事件行type 空)被当请求灌入聚合;③ Scanner 默认 64KB 截断风险。修复:回放**全部真实请求行**进聚合(恢复 totals/配额窗口),仅视图环形缓冲限 maxRecs跳过 type 空的行;`sc.Buffer` 抬到 16MB。新增 `TestLoadAuditFullReplay`access/坏 JSON/超大行混合回放断言)。
### Phase 4 — 2026-08-11 WebUI 修复与全量代码审查(今日会话)
- [x] **P4-12026-08-11WebUI 聊天"每次回复都失败"(用户实测)**:根因——`sendChat()``addMsg('assistant', '')` 未传 reason 参数 → `thinkEl`/`hintEl` 为 null → 首个携带 `reasoning_content` 的流式 chunk 到达时 index.html 抛 `Cannot set properties of null`,前端表现为请求失败。修复:`addMsg('assistant', '', true)` + 初始隐藏 think 框;提交 `7647b7d`,已推送并部署生产(服务 active、`/api/status` 正常),`go test ./internal/gateway/...` 通过。
- [x] **P4-22026-08-1141s AUTO 延迟归因**:审计 `09:54:42 stream lat=41810 model=deepseek-v4-flash-free src=zen ok=200 pt=17 ct=109`——链上调度仅 ~0.6stier1 qijiar 失败 lat=657ms 后顺延),其余 ~41s 为 zen 上游生成耗时(~380ms/token**非网关调度问题**。
- [x] **P4-32026-08-11全量代码审查**provider.go812 行)/stats.go459 行)/scheduler.go398 行)/registry.go258 行)/api.go/chat.go/server.go/config 全部通读完毕,确认 P10-1~P10-5见上
- [x] **P4-42026-08-11`handleSourcesAPI` 补 admin 校验P10-1**`api.go` 方法开头加 `reqRole(r.Context()) != "admin"` 检查(同 handleAdaptersAPI线上实测 user 级 key GET/POST `/api/sources` 均 403admin 200防越权读取明文 api_key 与篡改源。
- [x] **P4-52026-08-11`hasScopeModel` 剥前缀比对P10-2**:改为 Gateway 方法,先用 `Registry.EffectiveModel` 严格剥 `source-`/`source:`/`source/` 前缀(仅当前缀是真实源名且该源确实服务裸模型时才剥,避免误伤 `deepseek-v4-flash-free` 这类自带 `-` 的模型 ID单测 `TestHasScopeModelWithSourcePrefix` 覆盖前缀剥离与勿误伤。
- [x] **P4-62026-08-11runtime 源默认超时策略P10-3区分流式/非流式)**:新增 `config.DefaultSourceTimeout=120s/DefaultSourceQueueTimeout=60s/DefaultSourceConcurrency=8``Core.mergedSources` 对 runtime 源兜底JSON 源不序列化 timeout`Provider.New` 拆两个 client——非流式 `client{Timeout}` 整请求限时、流式 `stream` 无总超时(共享 Transport 仅 `ResponseHeaderTimeout` 限首字节),长 SSE 不被掐;`ChatStream` 改用 `doRawStream`。单测 + 线上流/非流式验证通过。
- [x] **P4-72026-08-11AUTO 链 tier 序 + 审计导出修复 + UI 密钥视图(今日会话)**:① `BuildChain` 排序由降序改升序tier1=最高优先级先试,修 P10-5 中的序颠倒),单测同步;② 统计/CSV 全量limit 20000、`Stats.AuditRecords` 读磁盘含 `*.old`、key_names 掩码键、非 admin 过滤掩码);③ UI 卡片滚动区修正 + 密钥视图 toggle再点同一密钥回全局+ `rec-exit` 退出入口;④ WebUI 测试"所有档位失败"观察AUTO 链降级正常opus 必死→deepseek 撞 zen 间歇故障→frank key 失效→nemotron 404 全灭 503
- [x] **P4-82026-08-11调度延滞排查结论**homeagent AUTO 请求"看起来停在 opus"实为调度正常降级——完整 ChainErr 列出 tier1→2→3→4 全部尝试,审计只记 `ce.Tiers[0]`(首档)故 UI 显 opuszen 源 `opencode.ai/zen/v1` 直连即 403 `[server_error] Upstream response was not valid JSON`(间歇)/ nemotron 404确认为上游源自身问题非网关透传或 homeagent 适配器。
- [x] **P4-92026-08-11WebUI 统计可视化**(多轮迭代定稿):① 模型用量%改为占总请求的比例(`r.reqs/total`,替代原"相对最大模型"的误导性 100%/97%);② 顶部五宫格统一为同构卡片——**标题 + 大数值 + 副标题 + 86px 图表区**:活跃请求=折线 sparkline实时、总请求=按天柱状图、tokens=各模型占比堆叠条(大数值示总用量)、平均延迟=折线 sparkline实时、状态码=按码值堆叠条 + chip 副标题;全部 canvas 2D 手绘(无第三方库),去掉灰色空壳占位与独立全宽状态卡/冗余副标题。
- [x] **P4-102026-08-11claude "Third-party apps now draw from your extra usage" 400 结论**:该报错为 **Anthropic 官方配额提示**(账号第三方 app 用量额度耗尽,提示到 Anthropic usage 设置充值),**非流量特征被识破**;伪装流量/换 adaptive 适配器解决不了(请求已被 Anthropic 接受并按账号用量计费)。且 qijiar+openai 适配器下 claude 模型本就能 200 成功(审计 16 次,含 claude-opus-4-8/claude-sonnet-5AUTO 链/直连的 claude 失败多为上游 `model_not_found`(无 distributor 渠道)、`Concurrency limit exceeded``extra usage` 配额——均为上游账号/渠道级问题,非网关。若确需走 Anthropic 原生 `/v1/messages`,可为其源切换内置 `anthropic` 适配器(仅改变量 wire 格式,不影响配额)。