Releases: deltrivx/AutoBuddy
Release list
v0.9.30
AutoBuddy v0.9.30
修复账号卡片读停更旧 JSON 导致新账号模型不显示、429 响应体进日志、×N 重复打印。
1. 新账号有调用次数,模型却不显示
现象
用户反馈:新加的账号调用次数是 11 次,但模型没体现出来。
实测
账号 17521565831(16acb0ee)在用量明细里确实有 18 条记录:
deepseek-v4.1-flash 1 次
glm-5.3 8 次
hy4-preview-f 9 次
但 /api/account-models 返回它的 used / gatewayCalls 全为 None。
根因:v0.9.29 迁移引入的回归
/api/account-models 仍直接读 token_stats_logs.json:
tracker_file = data_dir / "token_stats_logs.json"
with open(tracker_file, "r", encoding="utf-8") as f:
tracker_logs = _json.load(f) or []而这个文件在 10-02 迁移到 SQLite 后就再没被写入过 ——
mtime 与最新记录都停在 10-02 14:06。
新账号的调用只存在于 SQLite,旧 JSON 里根本没有,于是看起来
就像「新账号没被调用过」。
修复
新增 token_tracker.load_tracker_records():读取明细时自动走当前
存储后端(SQLite 或 JSON)。调用方不该再直接读那个 JSON 文件 ——
否则换后端时又会出现同样的双份数据源问题。
account-models 改用它;新路径失败时退回旧文件兜底,绝不整页空白。
2. 上游非 200 的响应体现在会记进日志
排查 429 时发现一个阻碍定性的缺口:日志里只有状态码,
rate limit / quota / 上游错误体一条都没有。
于是同一个 429 无法区分:
| 可能 | 处理 |
|---|---|
| 速率限流 | 等一会儿就好 |
| 额度耗尽 | 得充值或换号 |
历史上额度耗尽正是 HTTP 429 + code=14018 Credits exhausted ——
两者都是 429,没有响应体就只能靠猜。
新增 _log_upstream_error(),挂在 _looks_like_model_unavailable()
这个两条转发路径(流式 / 非流式)的共同判定入口上,覆盖每一次
上游错误。写入前抹掉 requestId / traceId(每次都不同,不抹掉
就是一条一条刷屏),并截断到 400 字符。
3. 周期汇报的 ×N 不再每个周期重复打印
实测数据
2727 条 ×N 行里,835 条与上一条一字不差;/health 更是
连续 27 条 ×1 完全相同。
三个成因,两个要修
① ×N 字符本身是正确的(不用改)
实测 200 ×6 的字节为 c39736,即 U+00D7 × + 6 ——
符号与数字各一个,不存在「重复」。用户看到的「重复」是整行重复。
② 周期汇报绕过了黑名单(修)
周期汇报是直接 print,不走 logging filter,所以 NOISY_PATHS
黑名单对它无效 —— /health 这类探活接口每 60s 稳定产出一条,
独占 1561 条。新增 _is_noisy_access(),复用同一份黑名单静音它们。
③ 同内容同次数 = 没有新信息(修)
与上一周期同内容且同次数的汇报不再打印
(_ACCESS_DEDUP_LAST_PRINTED 记录上次打印值)。
测试
全量 19 个测试文件 ✅ 全绿
五模块 AST ✅ 通过
注入 JS node --check ✅ 通过
升级说明
重建容器即可,/data 数据不受影响。
- 无数据库结构变更;
- 本次修复的账号卡片问题,升级后立即生效(不再依赖停更的旧 JSON);
- 旧 JSON 文件保留在原地不删,但已无任何读取方依赖它。
v0.9.29
AutoBuddy v0.9.29
用量明细迁到 SQLite(增量幂等写入 + 索引),聚合逻辑与输出契约一行未改。
为什么要迁
明细原先是单个 JSON 文件,每次记录都要:
读整个文件 → append → 写整个文件
窗口写满 3000 条时约 1.28MB 一轮 I/O,请求一密就把磁盘打满。
v0.9.27 的攒批只降低了频率,单次落盘仍是整文件重写,成本没变。
sub2api(43k★)的做法是明细落到数据库表,写入为增量 INSERT 且幂等
(ON CONFLICT ... DO NOTHING),并建复合索引服务聚合查询。
关键取舍:只换存储,不改聚合
这是本次最重要的一条设计决定。
聚合逻辑与输出契约( daily / models / projects / dailyByModel /
requests / sessions / summary )一行未改,换的只是
「明细从哪来、往哪写」。
好处:
- 前端零改动;
- 现有回归测试继续验证语义,迁移风险可被测试守住;
- 真正的瓶颈在写入,换成 INSERT 后即解决 —— 不做多余的重构。
新增 gateway/token_store.py:
| 能力 | 实现 |
|---|---|
| 写入 | INSERT OR IGNORE(等价 sub2api 的 ON CONFLICT DO NOTHING) |
| 幂等 | 同 request_id 重复写/重放/并发提交都不重复计数 |
| 聚合折算 | UPSERT 累加,避免读-改-写竞态 |
| 并发 | WAL 模式:读写互不阻塞 |
索引对齐 sub2api 的复合索引思路(列顺序 = 查询条件顺序):
idx_usage_logs_date (date)
idx_usage_logs_model_date (model, date)
idx_usage_logs_account_date (account_id, date)
idx_usage_logs_variant_date (variant, date)
idx_usage_logs_ts (ts DESC)
迁移过程中修掉的三处真实缺陷
这三处都是迁移本身引入的风险,不是原有问题,逐一修掉:
① 迁移会把超期旧记录两头落空
旧 JSON 里必然混有超期记录(线上 3000 条里就有)。若一股脑 INSERT 进
明细表,读取时会被时间窗口排除,而它们又没被折算进聚合 ——
明细里没有、聚合里也没有,等于静默丢数据。
现按保留期分流:新鲜的进明细,超期的直接折算进聚合。
② 兜底折算写错了后端
队列撑不住时的降级路径固定写 JSON 聚合文件;SQLite 模式下读取侧从
SQLite 读,这批数据落到没人看的地方 —— 同样是静默丢失。
现按 TOKEN_STORE 分发到当前后端。
③ 保留策略每次落盘都扫全表
明细超期是以天为单位的,每秒扫一次纯属浪费。改为按
_RETENTION_CHECK_SEC(默认 3600s)节流。
注意:不能只在启动时跑一次 —— 长跑进程跨过午夜后日期就变了,
必须周期性重判。
一处会直接崩的导入问题(自查发现)
gateway/ 没有 __init__.py,且容器内以顶层模块方式运行
(sys.path 含 /app/gateway)。
写成硬 from gateway import token_store 会 ModuleNotFoundError,
网关一启动就挂。
现改为两种模式都试,与 main.py / web_proxy.py 的既有约定一致:
try:
from gateway import token_store
except ImportError:
import token_store兼容与回滚
- 默认
sqlite;设AB_TOKEN_STORE=json即回到旧行为。 - 首次读取时自动把旧 JSON 明细与聚合迁入 SQLite,幂等
(meta标记 +INSERT OR IGNORE),线上历史数据不丢。 - 超期旧记录在迁移时会被折算进聚合,不会两头落空。
- 明细默认 7 天(
AB_TOKEN_DETAIL_DAYS),聚合 90 天
(AB_TOKEN_ROLLUP_DAYS),与 v0.9.28 语义一致。
测试
新增 _test_token_store_sqlite.py 26 项:
[1] 幂等写入:同 request_id 重复写不重复计数
[2] 超期明细折算进聚合,input/output/records 全部保住
[3] 迁移:旧 JSON 超期记录不两头落空,重复迁移不翻倍
[4] 输出契约与 JSON 后端一致(结构不变)
[5] 默认后端必须是 sqlite,且保留 AB_TOKEN_STORE 回滚开关
三个既有测试改为显式钉住 TOKEN_STORE = "json" —— 它们断言的是
JSON 文件行为(原子写、.tmp 残留、直接读 TRACKER_FILE)。
钉住后端既保留回归覆盖,也顺带守住了可回滚路径。
全量 19 个测试文件 ✅ 全绿
五模块 AST ✅ 通过
两种导入模式(顶层 / 包) ✅ 均通过
升级说明
重建容器即可,/data 数据不受影响。
- 新增数据文件
/data/.autobuddy/token_stats.db(WAL 模式下还会有
-wal/-shm两个伴生文件,属正常现象,勿手动删除)。 - 旧 JSON 文件保留在原地不删,迁移完成后不再写入;
确认运行稳定后可自行清理。 - 无环境变量强制要求;
AB_TOKEN_STORE仅在需要回滚时设置。
v0.9.28
AutoBuddy v0.9.28
补上 v0.9.27 遗留的两处缺口:队列撑不住时不再静默丢弃、明细改为按时间保留。
背景
v0.9.27 参照 sub2api / CLIProxyAPI 重构了统计存储。sub2api 的完整调研
报告随后送达,回看发现有两处没做到位,本版补齐。
1. 队列撑不住时不再静默丢弃
sub2api 的原则写得很硬 —— submitMandatoryUsageRecordTask 永不静默丢弃:
它的有界 worker 池(128 worker / 16384 队列)在队列满时提交方内联执行,
关停窗口的丢弃也会降级同步,保证计费一条不丢。
我 v0.9.27 的兜底却写着:
if len(_PENDING) < _PENDING_MAX_BACKLOG:
_PENDING[:0] = batch
else:
print(f"... dropping {len(batch)}") # ← 悄悄扔掉这正是「悄悄少数据」,与 sub2api 的做法背道而驰。
现在改成:队列真的撑不住时,把这一批折算进聚合(fold_into_rollup)
再丢弃明细。
| 明细 | 汇总数字 | |
|---|---|---|
| 旧行为 | 丢失 | 随之丢失 |
| 新行为 | 丢失 | 完整保住(token 总数、调用次数、按天/模型/账号分布) |
统计页的数字仍然正确,只是查不到单次调用的详情。
「悄悄少数据」和「降级保汇总」是两件事,后者才是可接受的兜底。
2. 明细改为按时间保留
sub2api 明确提醒:
别用滑动窗口。你的「3000 条」本质是行数上限,改成时间维度保留
(usage_logs_days),历史跨度就不受数据量绑架。
v0.9.27 只把聚合行改成了按天保留,明细仍是 MAX_DETAIL = 3000 的
行数上限。而实测网关每天约 2000 条调用 —— 忙的时候只能看两天,
闲的时候能看一个月,能回溯多久是不确定的。
现在主维度改为时间:
- 新增
DETAIL_MAX_DAYS(默认 7 天,AB_TOKEN_DETAIL_DAYS可调) - 新增
_split_expired()按天裁剪,超期明细先折算进聚合再移除 MAX_DETAIL降级为文件体积护栏,只在时间维度不够用时兜底
一个易漏的细节:没有 date 字段的记录一律保留 ——
宁可多留,也不能把数据误判成过期删掉。
为什么明细是 7 天而不是 sub2api 的 90 天
sub2api 明细留 90 天,是因为它在 PostgreSQL 里有分区 + 按月 DROP,
存得起。本网关明细是单个 JSON 文件,每请求都要读写一次,
7 天 × 约 2000 条/天 ≈ 1.4 万条会让文件膨胀到数 MB,得不偿失。
所以这里做了取舍:明细 7 天(可查单次调用),聚合 90 天(可查长期趋势)。
趋势图本来就读聚合,长期回溯不受影响。
关于 sub2api 的第 5 条(上游数据刷新)
sub2api 建议:上游额度用惰性读 + 分级 TTL(成功 3min / 错误 1min
负缓存 / 探测 10min)+ singleflight + jitter,并把快照写回账号记录。
本项目的 officialUsage 由官方闭源引擎采集,实测无法触发重写 ——
该路径用不上这套机制,v0.9.27 已改为暴露陈旧度
(officialUsageFreshness 的 stale / ageDays / reason)如实告知。
我们自己能控的上游调用(如签到状态)本来就有 60s TTL,与此一致。
测试
_test_token_storage.py 从 16 项扩到 24 项:
[5] 明细按时间保留
超期明细被划为待折算
近期与今天的明细保留
无日期字段的记录一律保留
条数远未达上限时仍按时间裁剪(证明主维度是时间)
[6] 队列撑不住时不得静默丢弃
源码中不再出现 dropping 分支
降级为折算保汇总
折算后 token 总数被保住
全量 18 个测试文件 ✅ 全绿
五模块 AST ✅ 通过
升级说明
重建容器即可,/data 数据不受影响。
新增可选环境变量:
| 变量 | 默认 | 含义 |
|---|---|---|
AB_TOKEN_DETAIL_DAYS |
7 | 明细保留天数(0 = 不按时间裁剪) |
无数据库结构变更;现有明细与聚合文件会被就地沿用。
升级后首次落盘会把超过 7 天的明细折算进聚合 —— 这是预期行为,
长期趋势数据不会丢。
v0.9.27
AutoBuddy v0.9.27
参照 sub2api / CLIProxyAPI 重构统计存储(攒批 + 原子写 + 分级保留),并让官方数据陈旧可见。
为什么要改
v0.9.26 修好了「词元统计只剩两天」,但暴露了更根本的问题:
统计每记录一次就要重写整个明细文件,窗口写满时是 1.28MB 一轮 I/O。
于是对照两个同类中转项目的成熟做法做了一轮重构:
| 项目 | 规模 | 参考价值 |
|---|---|---|
| sub2api | 43k★ | 用量攒批写入、分级保留、DB 侧 GROUP BY 聚合 |
| CLIProxyAPI | 53.7k★ | 缓存显式 TTL、ticker 自动刷新、防抖、fsnotify 热加载 |
1. 写入改为攒批
sub2api 的做法(usage_log_repo_insert.go):
usageLogCreateBatchMaxSize = 64
usageLogCreateBatchWindow = 3 * time.Millisecond
usageLogBestEffortBatchMaxSize = 256
usageLogBestEffortBatchWindow = 20 * time.Millisecond原先本网关是「读整个 JSON → append → 写整个 JSON」,每请求一次。
现在只入内存队列,满 32 条或 1 秒才真正落盘
(AB_TOKEN_PENDING_MAX / AB_TOKEN_PENDING_SEC 可调)。
两个必须配套的细节
攒批会引入新问题,缺一个就出错:
① 读侧强制 flush —— get_aggregated_token_stats() 读取前先把队列落盘。
攒批是为了省写入,但「刚发生的调用在统计页看不到」不能接受。
② 后台线程驱动时间窗口 —— sub2api 的窗口是后台 goroutine 的 ticker,
不依赖新请求到来。这里同样必须有:否则最后一批不足 32 条的记录
会一直留在内存,进程被 SIGKILL(容器重建很常见,atexit 根本不执行)
就彻底丢了。已补 daemon 线程按周期落盘,并保留 atexit 兜底。
2. 原子写
原先 open(w) 直接写,写到一半进程被杀会留下半截文件,
下次读取整个统计页就炸。改成写临时文件 + os.replace()
(同一分区内是原子操作),明细与 rollup 两处都改。
3. 聚合行分级保留
sub2api 按粒度分档保留(1m 明细 3 天 → 1d 汇总 90 天),
原则是越粗的档保留越久 —— 既能回溯长期趋势,又不会让文件无限膨胀。
新增 _prune_rollup(),默认保留 90 天(AB_TOKEN_ROLLUP_DAYS 可调),
每次折算时顺带裁剪。
4. 官方数据陈旧:修不了,就让它可见
CLIProxyAPI 对所有缓存显式设 TTL(如 AntigravityReasoningReplayCacheTTL = 1h),
3h ticker 自动刷新 + 30s 最小间隔防抖。核心思想:
陈旧是可知的,不是默认透明的。
本项目的 officialUsage 由官方闭源引擎 autobuddy-engine 采集。
实测直连 :57890 调 credits/stats 也不触发重写(POST 返回 405、
文件 mtime 不变)—— 该环节在闭源二进制内,网关侧无法修复。
既然修不了上游,就必须把陈旧如实暴露。过去页面显示
「最近更新 9-24」却不说原因,用户只能以为是网关坏了。
现在 /api/credits/stats 一并下发 officialUsageFreshness:
{"collectedAt": 1790179275670,
"rangeEnd": "2026-09-24",
"ageDays": 8.6,
"stale": true,
"reason": "官方用量数据由闭源底层引擎采集,当前未再刷新:最近一次采集距今约 8.6 天,数据截止 2026-09-24。该采集环节在官方二进制内部,网关侧无法触发重写;本页的今日消耗已改用实时数据校准,不受此影响。"}默认超过 2 天标为陈旧(AB_OFFICIAL_USAGE_STALE_DAYS 可调)。
测试
新增 _test_token_storage.py 16 项,锁定四条契约:
攒批:不足一批也会在时间窗口内自动落盘(后台线程驱动)
原子写:不留 .tmp 临时文件、始终是完整 JSON
分级保留:超期聚合行被裁剪,近期保留且能继续累加
陈旧可见:过期给 stale/reason,缺失/无时间戳也各有说明,不静默
已有两个测试按攒批后的真实读取路径补了 flush_pending(force=True) ——
它们原先直接读文件,攒批后读不到,这正是「读侧必须 flush」这条设计的由来。
全量 18 个测试文件 ✅ 全绿
五模块 AST ✅ 通过
注入 JS node --check ✅ 通过
升级说明
重建容器即可,/data 数据不受影响。
新增可选环境变量(都有默认值,无需配置):
| 变量 | 默认 | 含义 |
|---|---|---|
AB_TOKEN_PENDING_MAX |
32 | 攒够多少条落盘 |
AB_TOKEN_PENDING_SEC |
1.0 | 攒批时间窗口(秒) |
AB_TOKEN_ROLLUP_DAYS |
90 | 聚合行保留天数 |
AB_OFFICIAL_USAGE_STALE_DAYS |
2 | 官方数据超过几天标陈旧 |
无数据库结构变更;现有 token_stats_logs.json 与
token_stats_rollup.json 会被就地沿用,无需手工迁移。
v0.9.26
AutoBuddy v0.9.26
修复工具调用透传丢失 tool_calls、日志重复计数改为行尾 ×N、词元统计只剩两天。
1. 工具调用透传损坏(严重)
现象
用户 2026-10-02 反馈:「所有模型都返回 finish_reason=tool_calls 却不携带 tool_calls 数组」。
实测确认:
finish_reason : tool_calls
message : {"role": "assistant", "content": ""}
tool_calls : null ← 没有
危险之处在于它不报错:模型「说要调工具」,但调用内容一个字都没传下去,
Agent 侧的 function calling 链路整条断掉,而空 content 看起来像一条正常回复,
排查时极易误判成「模型不支持工具」。
根因:非流式路径只挑了 content
网关把上游流式帧重组成 assistant message 时:
delta = choices[0].get("delta", {})
if "content" in delta and delta["content"]:
collected_content += delta["content"] # ← 只挑了 content而 tool calling 恰恰不走 content,它走 delta["tool_calls"]。
于是那一半消息被静默丢弃,最终拼出的 message 只剩 role/content。
修复
新增 accumulate_delta(),把整块 delta 增量合并进 assistant 消息:
| 字段 | 处理 |
|---|---|
content / reasoning_content |
字符串累加 |
tool_calls |
按 index 定位同一条,再拼接 arguments |
role |
只在首帧携带,取到就写、取不到不动 |
{"tz" → :"Asia/ → Shanghai"}
每片都 append 一条新 tool_call 会变成一串残缺调用,必须按 index 归位。
组装时不再只塞 content,而是把累积结果整体放进 message(真·透传)。
2. 日志重复计数改为行尾 ×N
原先数数是另起一行,等于又多打了一条几乎重复的记录,与「防刷屏」初衷相悖:
改前: [pool] msg
[pool] (上一条重复 4 次) ← 多一行
改后: [pool] msg ×4 ← 挂在行尾
三处一并改:lprint 通用日志、access log 周期汇报(main + web_proxy)。
3. 词元统计只剩最近两天
根因
明细日志是滑动窗口,上限 3000 条,而实测网关每天产生约 2000 条调用:
按天分布: 10-01: 2025 条, 10-02: 975 条 ← 不到两天写满
最密时段: 00 时 418 条 / 21 时 358 条
窗口写满后最老记录被直接丢掉,页面永远只剩一两天。
为什么不能简单调大窗口
明细每请求都要整文件读+写一次,3000 条已是 1.28MB 一轮 I/O,
放大到几万条会把网关拖垮。
修复:明细保近期、历史转聚合
滑出窗口的记录按「天 x 模型 x 账号 x variant」折算成极小一行存
token_stats_rollup.json;趋势图与汇总把明细与 rollup 一起算,
请求明细仍只列真实调用记录。
每请求的成本从此与「累积了多久」无关。
伴随修正的四处计数缺陷(均由新测试暴露)
- 各维度桶的
records一律+1→ 改为按_rec_count()累加
(聚合行代表 N 次调用,算 1 会把历史量算少); requests/sessions误把 rollup 聚合行混进去,变成一堆没有 id 的
req-0假记录 → 改为只遍历真实明细;- 分流子集(国内/国际版)同源问题一并修。
关于「积分统计页最近更新停在 9-24」
这一项网关侧无法修复,如实说明:
official_usage_cache.json 的 collectedAt 停在 09-23 04:44、
rangeEnd 停在 2026-09-24。它是官方闭源底层
(autobuddy-engine)的采集产物 —— 引擎对该环节只字不提(静默跳过),
直连 :57890/api/credits/stats 也不触发重写。
Web 代理层已做的日程校正(_reconcile_official_usage)不受影响,
顶层 daily 仍是新鲜的(最新 2026-10-02,今日消耗 586.64)。
测试
新增 2 个测试文件共 37 项:
_test_tool_call_passthrough.py 16 项 逐片拼接、按 index 归位、真透传
_test_token_stats_history.py 21 项 历史折算、records 计数、文件损坏容错
_test_log_dedup.py 增补第 6 组断言:强制「行尾 ×N」写法,
禁止再出现独立成行的重复计数。
全量 17 个测试文件 ✅ 全绿
五模块 AST ✅ 通过
升级说明
重建容器即可,/data 数据不受影响。
- 新增可选环境变量
AB_TOKEN_DETAIL_MAX(默认 3000,明细窗口上限); - 新增数据文件
/data/.autobuddy/token_stats_rollup.json(历史聚合,
首次写入时自动生成,无需手工初始化); - 无数据库结构变更;
- 行为变化:非流式 chat 响应在模型要求调工具时会带上
tool_calls,
Agent 的 function calling 链路恢复。
v0.9.25
AutoBuddy v0.9.25
一致性自检页补上「额度用尽」的显示档位,不再与「未检测」混淆。
背景
v0.9.24 已把额度耗尽正确判为 quota_exhausted。但界面上仍看不到——
它显示为「没检测出结果」。
先说定位结论:判定是对的
实测(对额度耗尽的账号发一次探测请求):
HTTP 429
{"error":{"data":{"code":14018,"msg":"Credits exhausted. ..."}}}
code = 14018
semantic = quota_exhausted
verdict = quota_exhausted
state = quota_exhausted
对照旧代码:14018 不在语义表里 → 429 属 TRANSIENT_STATUS
→ transient → 健康映射 "transient": "unknown"。
所以界面显示的正是「没测出结果」 —— 与你看到的现象完全吻合。
这不是定位错误,是判定修好后显示层还差一档。
修复:补 audit 页的显示档位
检测接口 /api/account-health/probe 现在返回正确:
{
"verdict": "quota_exhausted",
"state": "quota_exhausted",
"message": "额度已用尽",
"userMessage": "额度已用尽,已自动停用",
"action": "充值或等待额度重置",
"code": 14018
}但一致性自检页(audit)的状态着色只认三档:
if (st === "invalid") cls += " wb-audit-bad";
else if (st === "restricted") cls += " wb-audit-warn";
else if (st === "valid") cls += " wb-audit-ok";quota_exhausted 一档不落 → 掉进「默认无着色」→
与「未检测」在视觉上无法区分。
现补上:
else if (st === "quota_exhausted") cls += " wb-audit-warn";配色与 restricted 同为琥珀:两者都不是凭据坏了,
用红色会让人跑去重新登录,而重登对这两种情况毫无帮助。
一处说明:「检测账号」按钮无需改动
那条路径(/api/account-health/probe 的渲染,2654 行)本来就是三分支:
var tone = state === "valid" ? "ok"
: (state === "invalid" ? "error" : "warn");quota_exhausted 落在琥珀 warn 档,已经是对的。
本次只补 audit 页。
验证
- 容器实测接口返回:
state=quota_exhausted、
userMessage=额度已用尽,已自动停用、code=14018 - 对照正常账号:
HTTP 400 code=11102(模型不存在)→
verdict=available→state=valid,确认未误伤
15 项单测 ✅ 全绿
五模块 AST ✅ 通过
注入 JS node --check ✅ 通过
升级说明
重建容器即可,/data 数据不受影响。
- 无新增或删除环境变量;
- 无数据库结构变更;
- 纯前端显示修复,无判定逻辑变更。
v0.9.24
AutoBuddy v0.9.24
额度耗尽的账号由巡检自动停用,不再只提醒用户手动删除。
问题
额度用尽的账号一直留在轮询里持续失败,系统本该自己把它停掉,
却只能反复提醒用户去手动删除。
根因:额度耗尽根本不在自动禁用的触发条件里
账号级自动禁用只认两类:
if verdict not in ("auth_failed", "restricted"):
continue而额度耗尽的上游响应是:
HTTP 402 {"msg":"Insufficient Balance"} code=ACCOUNT_QUOTA
HTTP 429 {"data":{"code":14018,"msg":"Credits exhausted..."}}
两条都掉进 transient:
| 响应 | 归类路径 | 结果 |
|---|---|---|
| HTTP 402 | 不在 UNAVAILABLE_STATUS{400,401,403,404,422,500,502,503,504},也不在 TRANSIENT_STATUS{408,425,429} → 走兜底 |
transient |
| HTTP 429 | 明确属于 TRANSIENT_STATUS |
transient |
而 transient 的语义就是「临时状态,不参与自动禁用」——
它本是为「网络抖动、上游 5xx」设计的一次性状态。
额度耗尽是持续状态(不充值 / 不重置不会自己好),
把它当一次性抖动,是这次故障的根源。
修复
新增 quota_exhausted 这一 verdict,并纳入自动禁用:
if verdict not in ("auth_failed", "restricted", "quota_exhausted"):
continue判定覆盖三条路,任一命中即可:
1. 语义表 —— 14018 → quota_exhausted
实测证据(2026-09-28 Mac dsh 会话日志):
429 {"data":{"code":14018,"msg":"Credits exhausted. Please visit
the link below to purchase add-on packs..."}}
2. HTTP 402 —— 按状态码判定
402 Payment Required 语义唯一,不需要依赖错误码。
3. 文字兜底 —— QUOTA_EXHAUSTED_HINTS
用于响应体取不到错误码的情形,覆盖
insufficient balance / credits exhausted / 额度不足 等。
⚠️ 一处刻意的取舍
没有把 10001 写进语义表。
同一个码在签到接口上是「今天已签到」
(workbuddy2api-panel 的 alreadyCheckinMarkers 实测),
含义冲突 —— 写成额度耗尽会把正常的重复签到误判成没钱。
因此 402 的额度耗尽改由状态码判定,判据更可靠、不会误伤。
界面文案
额度耗尽与「账号被拦截」区分开 —— 后者只能等,前者可以充值:
| 字段 | 文案 |
|---|---|
| label | 额度已用尽 |
| action | 充值或等待额度重置 |
| userMessage | 额度已用尽,已自动停用 |
自愈
无需额外改动:自动启用只放开 verdict == "available" 的账号,
额度耗尽不等于 available,不会被误放回;
充值 / 重置后探测恢复正常,巡检会自己把它放回来。
测试
_test_model_health.py 新增 7 项(231 → 238):
[ok] 14018(Credits exhausted)归类为额度用尽
[ok] HTTP 402 归类为额度用尽(不依赖错误码,避免 10001 语义冲突)
[ok] 无错误码但文案命中,同样归类为额度用尽
[ok] 额度用尽**不是** transient(transient 不参与自动禁用)
[ok] 额度用尽的账号被自动停用
[ok] 额度用尽写入账号策略且来源为 auto
[ok] 同轮正常账号不被误停
15 项单测 ✅ 全绿
五模块 AST ✅ 通过
升级说明
重建容器即可,/data 数据不受影响。
- 无新增或删除环境变量;
- 无数据库结构变更;
- 行为变化:巡检发现账号额度耗尽时,会自动将其停用(来源
auto),
而不是继续让它参与轮询。充值后巡检自动放回。
相关:v0.9.23 已修的两项
- 新加的账号不被调用(能力矩阵收窄排除未知账号)
- 巡检探测账号池外的账号(现与日常调用同源)
v0.9.23
AutoBuddy v0.9.23
修复新加的账号永远不被调用,以及巡检探测账号池外的账号。
修复一:新账号一次都没被调用
现象
新加进账号池的账号,token 有效、确实在 enabledAccountIds 里,
但一次都没被调用过。
selection_logs.json 实测(09-29 23:43 ~ 09-30 00:36,200 条选号记录):
palmesewooters371@gmail.com 28 次
尘星途 28 次
Mr.Chen 27 次
13035639603 26 次
18981122864 26 次
18011628363 25 次
13558746529 24 次
13666135560 16 次
----------------------------------------
15573657770 0 次 ← 新账号
13111877992 0 次
15228718439 0 次
15198254808 0 次
根因:收窄逻辑把「未知」当成了「不支持」
选号时有一层「正向能力矩阵」收窄:
known_yes = [a for a in candidates
if capability_state(_account_id(a), model) == "yes"]
if known_yes:
candidates = known_yes # ← 只留"明确支持"的capability_state() 对从未探测过的账号返回 None,
于是新账号和"明确不支持"一样被排除。
配合巡检的冷启动缺口,形成死循环:
新账号 0 调用
↓
巡检 onlyUsedModels=true → used_models 为空
↓
只探 BASE_MODELS 基模型清单
↓
清单里没有自动发现的新模型(如 hy4-preview-f)
↓
能力矩阵永远缺这个键 → state 永远是 None
↓
收窄时被排除
↓
又是 0 调用 ← 回到起点
对比数据印证了机制:
| 账号 | hy4-preview-f 键 |
结果 |
|---|---|---|
| 6 个老账号 | yes |
进候选,24~28 次 |
| 4 个新账号 | 键不存在 | 出局,0 次 |
为什么 13666135560 没键也能被调用?
它 261 次调用全是 deepseek-v4.1-flash(不是 hy4-preview-f)。
收窄是按当前请求的模型查键的 —— 这恰好反证:
不是账号被禁,而是「该账号 × 该模型」这个组合没被探过。
修复
只排除明确不支持,未知保留参与:
not_no = [a for a in candidates
if capability_state(_account_id(a), model) != "no"]
if not_no:
candidates = not_no两处收窄点均已修正:主选号 _pick_account、换号重试 _retry_candidates。
真不支持的账号仍会被 _learn_model_unavailable 即时学习成 no,
下一轮自然出局 —— 不牺牲原有选号质量。
保留 if not_no: 守卫:全部明确 no 时不覆盖候选集,
让上游返回真实错误,而不在网关层编一个「无账号」。
修复二:巡检探测账号池外的账号
现象
账号已经移出账号池,巡检还在探它、还在报错。
实测:一轮巡检探了 15 个账号,远多于池内数量;
model_health_last.json 里出现了已移出池的账号。
根因:两套账号集合
| 路径 | 账号来源 |
|---|---|
日常调用 /v1/chat/completions |
_enabled_accounts()(白名单 + 停用策略) |
| 模型巡检 | _load_accounts()(两份账号文件全量合并) |
巡检那条完全没过池白名单。
修复
raw = model_health.run_round(
accounts=_enabled_accounts(_load_accounts(), _load_pool_config()),
...
)与日常调用同源。这符合项目既有设计原则 ——
build_account_store() 的注释写着:
账号来源与账号池其它功能保持同一个出处,
避免「账号页看得到、每日任务里没有」
界面手动「只探这几个账号」(overrides.accountIds)时,
仍由 model_health.select_targets 按该清单收窄,不受影响。
测试
- 新增
_test_pool_selection.py(11 项):锁定「不得排除未知账号」契约,
防止日后又改回state == "yes";同时校验巡检的池过滤。 - 修正
_test_model_health.py:该测试把run_model_health_check
切片到隔离命名空间执行,巡检新增的两个依赖需补桩
(_load_pool_config/_enabled_accounts)。
生产代码没有问题,是测试桩缺失。
15 项单测 ✅ 全绿
四模块 AST ✅ 通过
注入 JS node --check ✅ 通过
升级说明
重建容器即可,/data 数据不受影响。
- 无新增或删除环境变量;
- 无数据库结构变更;
- 行为变化:
- 新账号加入池后可立即参与轮转(原先需先被巡检探到该模型);
- 巡检只探池内账号,探测次数下降,不再报池外账号的错。
遗留事项(非本次范围)
- 国际版账号额度耗尽时会被正常选中但调用失败,建议移出账号池
(本次未改:这是账号状态问题,不是选号逻辑问题)。 - 上游 429 的响应体未落盘,无法区分
6004(模型级限流)与
14018(额度耗尽)。如需精确诊断,可另加日志。
v0.9.22
AutoBuddy v0.9.22
修复每日任务页顶部四项数据框在深色主题下不变色。
问题
深色主题下,每日任务页顶部那块「今日积分 / 签到积分 / 任务积分 / 总积分」
的四项数据框仍是一块白底,和整体暗色页面格格不入。
根因:背景色被写死
.wb-dl-credit-card 里:
background: rgba(255,255,255,.7); /* 硬编码白色 */讽刺的是,同一处代码的注释里明明记着官方规格是 bg-card/70
(跟随主题变量)—— 实现与注释自相矛盾。
实测证据
1280px 视口、深色主题下取 computed style:
| 项目 | 取值 | 判定 |
|---|---|---|
--card |
oklch(27.9% .041 260.031) |
✅ 主题变量正常 |
body 背景 |
oklch(0.208 0.042 265.755) |
✅ 已变暗 |
| 本框背景 | rgba(255,255,255,0.7) |
❌ 没跟上 |
| 文字色 | oklch(0.929 0.013 255.508) |
✅ 跟随了 |
| 边框色 | oklch(1 0 0 / 0.1) |
✅ 跟随了 |
也就是说:文字和边框都正常跟随主题,唯独背景是写死的 ——
所以现象精确地表现为「深色页面里嵌一块白底卡片」,而不是整体都不对。
修复
background: color-mix(in oklab, var(--card, #ffffff) 70%, transparent);与官方积分统计页的 bg-card/70 完全对齐。
⚠️ 一个容易踩的坑
--card 是 oklch 格式(oklch(27.9% .041 260.031)),不是 hex。
所以不能用项目里常见的那种老写法:
color-mix(in srgb, var(--card, #0f172a) 92%, transparent) /* ❌ 兜底是 hex */srgb 空间 + hex 兜底在 oklch 变量下语义不对。这里必须指定
in oklab,且变量缺失时退回中性半透明色,而不是又变回固定白。
顺带做的检查
全量复查了页面内其它硬编码浅色,确认都不是同一类问题:
| 位置 | 内容 | 判定 |
|---|---|---|
.wb-modal-btn |
蓝色按钮 #3b82f6 + 白字 |
按钮主色,非背景 |
| toast | color: #ffffff |
深色浮层上的前景白字 |
这两处保持原样。
验证
四模块 AST ✅ 通过
注入 JS node --check ✅ 通过
15 项单元测试 ✅ 全绿
版本一致性自检 ✅ 通过
部署后实测 深色主题下背景跟随 --card 变化
升级说明
重建容器即可,/data 数据不受影响。
- 无新增或删除环境变量;
- 无数据库结构变更;
- 本次为纯前端样式调整,无后端逻辑变更。
v0.9.21
AutoBuddy v0.9.21
修正 v0.9.20 日志去重的两个缺陷:重复次数被悄悄吞掉、去重字典无上限。
背景
v0.9.20 上线后我做了一次部署后对照实测,发现去重虽然生效,
但有两个我自己引入的缺陷。
缺陷 1:重复次数被悄悄吞掉(观测断档)
原设计
「内容变化时,先汇报上一条被吞了多少次」:
repeat = _ACCESS_DEDUP_COUNT.get(key, 0)
if repeat > 0:
print(f"(上一条重复 {repeat} 次) {key}")为什么永远不会触发
为了抗交错轮询(A、B 交替),去重是按内容各自独立记录的:
c3 累积的次数 → 记在 c3 名下
d4 出现时 → 查的是 d4 的计数(= 0)
所以那行交代永远不会打印。
实测确认
| 场景 | 结果 | 判定 |
|---|---|---|
| 发 3 个不同内容 | 出现 3 行 | ✅ 没被静音 |
| 再发 4 次相同内容 | 0 行 | ✅ 去重生效 |
| 换成新内容 | 只出现新行,无交代行 | ❌ 观测断档 |
修复
改为按时间周期汇报:默认每 60s 把各条内容累积的待报次数
统一打出并清零。
INFO: (重复 N 次) <内容>
可用环境变量 ACCESS_DEDUP_FLUSH_SEC 调整周期(测试时可设成几秒)。
这样既保留可观测性,又不破坏「按内容各自去重」的正确性。
缺陷 2:去重字典无上限(内存泄漏)
_ACCESS_DEDUP_LAST 每遇到一种新内容就加一个键,从不清理。
access log 的内容种类理论上无界 —— URL 里带动态 id 时尤甚 ——
长时间运行就是一条缓慢的内存泄漏。
现已加上限 _ACCESS_DEDUP_MAX_KEYS = 500,超出时淘汰最早写入的键
(dict 保持插入顺序,取前 N 个即可)。
回归测试补强
_test_log_dedup.py 新增两条校验:
- 必须存在
_flush_access_dedup(否则重复次数被悄悄吞掉) - 必须存在容量上限(否则内存泄漏)
这两条都是实测踩出来的,不是凭空加的。
验证
四模块 AST ✅ 通过
15 项单元测试 ✅ 全绿(含新增两条校验)
版本一致性自检 ✅ 通过
部署后对照实测 ✅ 去重生效 + 不同内容不误杀
升级说明
重建容器即可,/data 数据不受影响。
- 新增可选环境变量
ACCESS_DEDUP_FLUSH_SEC(默认 60,不设即用默认值); - 无数据库结构变更;
- 本次为日志输出层调整,不影响任何接口行为。