Skip to content

Commit d8990d0

Browse files
committed
fix(trae): 额度包各自独立到期,补 expiry_ladder 参与到期优先排序
TRAE 的 ide_user_ent_usage 权益包实测各自独立到期(每月登录积分按月、 签到奖励各有到期日),与 CodeBuddy 同构。早期实现按「TRAE 无周期概念」 只填展示用的 quota_packages、expiry_ladder 恒为 None,导致: - 管理台「到期积分」行永不显示(quota_expiring_credits 由 ladder 派生, ladder 为 None → 主/次窗口都是 null → 前端不渲染); - 快过期的积分拿不到优先消耗,白丢。 改为 fetch_quota 同时填 expiry_ladder,口径与 CodeBuddy 一致:只收 「未过期 + 有余额」的包(已过期积分作废、已用完的包不携带积分)。 展示用的 packages 保持更宽(额外含已过期有余额、已用完但仍有效的包)。 同步 PROPOSAL Q31 / TECHNICAL §6 / README 配置表与相关注释;翻转 test_m1a_trae 中「不能填 ladder」的断言并新增过期/空包过滤用例。
1 parent 1e4f853 commit d8990d0

9 files changed

Lines changed: 84 additions & 31 deletions

File tree

‎PROPOSAL.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,11 @@ Provider 承担上游协议私有部分:发请求、解析事件、分类错
182182
- **过滤条件必须是 `end > now`**:上游会把已过期套餐一起返回(`PackageEndTimeRangeBegin` 过滤的是套餐有效期,不是积分周期),不过滤则「最早到期」永远是过去时间、指标恒为 0;已用完的包(剩余 0)同样排除,它不携带积分。
183183
- **两级窗口而非一个**:只比 36h 会出现大量账号指标同为 0(36h 内没有包到期),排序退化成健康度,一周内本该先用掉的积分反而没人管。故主窗口打平后再比更宽的 7 天窗口,同级内仍是积分多者优先;两级都是 0 才轮到健康度。次窗口只在主窗口打平时参与,不会把「36h 内该先烧的」压下去。
184184

185-
窗口 `≤0` 等于关闭整套到期排序(主窗口是总开关,次窗口一并归零,`expiry_windows()` 统一折算),退回纯健康度排序;TRAE 无周期概念,恒为 0 分。
185+
窗口 `≤0` 等于关闭整套到期排序(主窗口是总开关,次窗口一并归零,`expiry_windows()` 统一折算),退回纯健康度排序;无到期信息的渠道(如 CodeBuddy 企业版)恒为 0 分。
186186

187-
**展示与调度指标分开存**(`quota_packages` vs `quota_expiry_ladder`)。管理台要展开「这个账号有哪些额度包、各自何时到期、用了多少」,而 `quota_expiry_ladder` 是选号指标:结构只有 `[到期, 剩余]`,装不下包名,且 TRAE 必须保持 `None`(填了会改变选号行为)。故另存一列 `quota_packages`(`[{"name","total","used","end"}]`,JSON)仅供展示,调度一行不动。口径差异也保留:阶梯只收「未过期 + 有余额」的包,展示明细额外含「已过期但仍有余额」(提醒浪费)与「已用完但仍有效」的包。
187+
**TRAE 也按包独立到期**(2026-09-30 修正):早期实现按「TRAE 无周期概念」只填展示用的 `quota_packages`、`expiry_ladder` 恒为 `None`,导致「到期积分」行永不显示、快过期的积分拿不到优先消耗。实测 TRAE 的 `ide_user_ent_usage` 权益包**各自独立到期**(每月登录积分按月、签到奖励各有到期日,账号常见十几个包),与 CodeBuddy 同构,故 TRAE 也填 `expiry_ladder`(口径一致:只收「未过期 + 有余额」的包),把快过期的积分纳入选号。
188+
189+
**展示与调度指标分开存**(`quota_packages` vs `quota_expiry_ladder`)。管理台要展开「这个账号有哪些额度包、各自何时到期、用了多少」,而 `quota_expiry_ladder` 是选号指标:结构只有 `[到期, 剩余]`,装不下包名。故另存一列 `quota_packages`(`[{"name","total","used","end"}]`,JSON)仅供展示。两个渠道都同时填两列:TRAE 的权益包与 CodeBuddy 的套餐一样各自独立到期(见上),阶梯口径统一为「未过期 + 有余额」。口径差异也保留:阶梯只收「未过期 + 有余额」的包,展示明细额外含「已过期但仍有余额」(提醒浪费)与「已用完但仍有效」的包。
188190

189191
**会话粘性**(调度前置一步)。OpenAI 协议本身无会话概念,客户端「对话」的识别按可靠性分两级(B1.5):
190192

@@ -216,7 +218,7 @@ def health(q) -> HealthScore: # known(0-100) | unknown | exhausted
216218

217219
**为什么必须三态**:CB 允许 bearer-only 手动凭证(无额度信息),探测失败也会发生。把未知当成 0 分,这类凭证在有健康号时永远轮不到——探测失败被误判成「没额度」。`unknown` 排在 known 之后但仍参与调度;`exhausted` 才真正垫底。
218220

219-
**展示层必须标注周期语义**:CB 是「本周期剩余(到期回满)」,TRAE 是「账户剩余(单调递减)」;`unknown` 显示为「未探测到额度」。
221+
**展示层必须标注周期语义**:CB 是「本周期剩余(到期回满)」,TRAE 是「账户剩余(单调递减)」——注意这是**余额聚合口径**,与「额度包各自独立到期」不矛盾:TRAE 的总余额随消耗单调递减(不像 CB 会周期回满),但其中各权益包仍有各自到期日,到期未用完即作废(故纳入到期优先排序);`unknown` 显示为「未探测到额度」。
220222

221223
**credit 不可作为统计核心指标**:两边上游的 SSE 都不保证返回 per-request credit(CB 的 `usage.credit` 是可选字段、样本中基本不出现;TRAE 只有 `token_usage`)。健康度的唯一可靠来源是额度探测接口的 `remaining`;统计页 credit 只做辅助展示,主指标是 token。
222224

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,8 @@ CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端
280280
| `GROWTH_IRREVERSIBLE_ACTIONS` | `true` | 是否允许成长中心的不可逆动作:抽奖、连登兑换、开 Buddy 盲盒、消耗补登卡。`false` 时仍会领取旅行礼物与任务奖励 |
281281
| `ACTIVITY_REPORT_ENABLED` | `false` | 活跃上报(仅 CodeBuddy):每天为账号补发一条对话事件续连登。**默认关闭**——官方条款禁止脚本篡改活动数据(处罚为取消资格并追回礼品),上游改版即失效,不作为可靠性功能(见上文「活跃上报」) |
282282
| `ACTIVITY_REPORT_HOUR` | `10` | 活跃上报的本地(北京)时间整点窗口;仅在 `ACTIVITY_REPORT_ENABLED=true` 时生效 |
283-
| `QUOTA_EXPIRY_WINDOW_SECONDS` | `129600` | 主到期排序窗口:把距到期 ≤ 该秒数的积分加总,多的账号先用(避免积分过期浪费);`≤0` 关闭整套到期排序(次窗口一并失效),退回纯健康度排序 |
284-
| `QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS` | `604800` | 次到期排序窗口:主窗口打平(常见的是都为 0)时才比较,`7 天`覆盖 CodeBuddy 一个完整的小包到期周期;`≤0` 关闭该级 |
283+
| `QUOTA_EXPIRY_WINDOW_SECONDS` | `129600` | 主到期排序窗口:把距到期 ≤ 该秒数的积分加总,多的账号先用(避免积分过期浪费);CodeBuddy 与 TRAE 都按包独立到期、都参与该排序;`≤0` 关闭整套到期排序(次窗口一并失效),退回纯健康度排序 |
284+
| `QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS` | `604800` | 次到期排序窗口:主窗口打平(常见的是都为 0)时才比较,`7 天`覆盖一个完整的小包到期周期;`≤0` 关闭该级 |
285285
| `CONVERSATION_STICKY_SECONDS` | `3600` | 会话粘性 TTL:优先按请求体显式会话标识(`conversation_id`/`conversationId`/`prompt_cache_key`,metadata 或顶层),无则回落消息前缀指纹,多轮请求固定用同一凭证(手动 pin 的凭证优先,粘性让位);带 `user_id` 时不派生前缀兜底键(避免并行对话误钉同一号);凭证出错仍会轮换,成功后重新粘定;`≤0` 关闭 |
286286
| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,hunyuan-image-*` | 模型列表黑名单(fnmatch,仅影响列表展示,直连指定不受影响);默认值按两边上游实测清单补入内部/不可用模型(`default` 零内容、`hunyuan-image-*` 400 11103),刻意不含 `*-volc` 与 `aquila`/`sagitta`/`seed-code-pro-0430`(实测可正常 chat)(见 TECHNICAL.md §3.5) |
287287
| `ALLOWED_HOSTS` | 空 | Host 白名单,防 DNS rebinding |

‎TECHNICAL.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ class ErrKind(StrEnum):
232232
class Quota:
233233
remaining: float | None = None
234234
total: float | None = None
235-
cycle_end: int | None = None # 最早到期 epoch(CB 多包各自独立);TRAE None
235+
cycle_end: int | None = None # 最早到期 epoch;无到期信息为 None
236236
expiry_ladder: list[tuple[int, float]] | None = None # [(到期 epoch, 该包剩余积分)]
237237
packages: list[dict] | None = None # [{"name","total","used","end"}],仅展示
238238
probed_at: int | None = None
@@ -672,8 +672,9 @@ class Provider(Protocol):
672672
只作用于本条对话路径:后台任务只检查 disabled,暂停期间照常运行
673673
e. 到期积分排序(两级字典序):quota_expiry_ladder 中「距到期 ≤ 主窗口」
674674
(QUOTA_EXPIRY_WINDOW_SECONDS,默认 36h)的积分加总,多的先用;打平再比
675-
次窗口(默认 7 天);无周期信息(TRAE/企业版)计 0;主窗口 ≤0 时次窗口
676-
一并失效(expiry_windows() 统一折算)
675+
次窗口(默认 7 天);渠道无到期信息(如 CB 企业版)计 0;主窗口 ≤0 时次窗口
676+
一并失效(expiry_windows() 统一折算)。CodeBuddy 与 TRAE 都按包独立到期,
677+
均落阶梯参与此排序
677678
f. 两级到期积分都相同时按 health 三态取最高分;同分按 credential_id 稳定
678679
4. executor:解密凭证 → provider.stream_chat()
679680
- 上游 HTTP ≥400 → classify → scheduler.note_error → tried 加入 → 回到 3(最多 3 次)
@@ -713,6 +714,8 @@ class Scheduler:
713714

714715
到期积分只算一处:`expiring_credits()`。选号走 `Candidate.expiry_credits()`(主/次两级窗口各调一次),管理台列表走 `GET /api/credentials` 的 `quota_expiring_credits` 与 `quota_expiring_credits_secondary`(两个窗口值随响应返回 `expiry_window_seconds` / `expiry_secondary_window_seconds`),两处共用同一实现与同一个 `expiry_windows()` 折算(主窗口 ≤0 时两级一起失效),界面数字与选号顺序不会漂移;渠道无到期信息时返回 `null`(不显示),窗口关闭或确实无积分临近过期时返回 `0`(同样不显示)。管理台只在主窗口无数字时才渲染次窗口那一行(次窗口是主窗口的超集,主窗口有值时重复展示没有信息量)。
715716

717+
> **TRAE 也落 `quota_expiry_ladder`**(2026-09-30 修正):早期按「TRAE 无周期概念」只填展示用 `quota_packages`、`expiry_ladder` 恒 `None`,表现为两列数字恒为空。实测 `ide_user_ent_usage` 的权益包各自独立到期(每月登录积分按月、签到奖励各有到期日),与 CodeBuddy 同构,故两列同时填、口径统一为「未过期 + 有余额」。`quota_cycle_end`(单值「最早到期」)TRAE 仍为 `NULL`:TRAE 各包未必共享一个重置点,而阶梯已表达「哪些包何时到期」,无需再挑一个单值。
718+
716719
### 6.1 模型级冷却(B1.1)
717720

718721
`credential_model_cooldowns(credential_id, model, cooling_until, hits, reason)` 按 **(凭证, 模型)** 独立建表——账号级 `cooling_until` 放不下「同账号其他模型仍可用」这层语义。

‎src/db/repo.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def _ladder_text(ladder: list[tuple[int, float]] | None) -> str | None:
3636

3737

3838
def _ladder_value(text: str | None) -> list[tuple[int, float]] | None:
39-
"""读回到期阶梯。缺失、迁移前的空值或历史脏数据一律当「无周期概念」,
39+
"""读回到期阶梯。缺失、迁移前的空值或历史脏数据一律当「无到期信息」,
4040
不能让一行坏数据把整个选号流程拖崩。"""
4141
if not text:
4242
return None
@@ -402,8 +402,8 @@ def list_all(
402402
403403
附 `quota_expiring_credits`(主窗口内即将到期的积分,与调度排序第一级同源);
404404
附 `quota_expiring_credits_secondary`(次窗口,主窗口打平时才参与排序的第二级);
405-
附 `quota_expiry_ladder`(套餐到期阶梯,[[epoch, 剩余积分]],仅 CodeBuddy);
406-
渠道无到期信息(TRAE)三者都为 None,展示层据此隐藏。
405+
附 `quota_expiry_ladder`(套餐到期阶梯,[[epoch, 剩余积分]],按包独立到期的渠道);
406+
渠道无到期信息时三者都为 None,展示层据此隐藏。
407407
附 `model_cooldowns`(model → 截止 epoch),只在模型级限流/负缓存时非空。
408408
附 `token_expires_at` / `token_issued_at`(B3.3 token 到期展示):只下发
409409
绝对 epoch,剩余时间与预警阈值判定交给展示层用同一个时钟现算——否则

‎src/db/schema.sql‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ CREATE TABLE IF NOT EXISTS credentials (
4242
err_count INTEGER NOT NULL DEFAULT 0,
4343
quota_remaining REAL,
4444
quota_total REAL,
45-
quota_cycle_end INTEGER, -- 最早到期 epoch;TRAE 为 NULL
46-
quota_expiry_ladder TEXT, -- 到期阶梯 JSON [[epoch, 剩余积分]];TRAE 为 NULL
45+
quota_cycle_end INTEGER, -- 最早到期 epoch;渠道无此信息为 NULL
46+
quota_expiry_ladder TEXT, -- 到期阶梯 JSON [[epoch, 剩余积分]];无到期信息为 NULL
4747
quota_packages TEXT, -- 额度包明细 JSON [{"name","total","used","end"}],仅展示
4848
quota_probed_at INTEGER,
4949
token_expires_at INTEGER, -- access token 到期 epoch;NULL=老库未回填,0=未知

‎src/engine/scheduler.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def expiring_credits(
4242
) -> float:
4343
"""窗口内即将到期的积分:`now < 到期 <= now + window` 的各包剩余之和。
4444
45-
窗口 ≤0 或无阶梯(TRAE 无周期概念)为 0。调度排序与管理台展示共用此口径。
45+
窗口 ≤0 或无阶梯(渠道无到期信息)为 0。调度排序与管理台展示共用此口径。
4646
"""
4747
if not ladder or window_seconds <= 0:
4848
return 0
@@ -118,7 +118,7 @@ class Candidate:
118118
enabled: bool = True
119119
err_count: int = 0
120120
pinned: bool = False
121-
cycle_end: int | None = None # 额度最早到期(epoch);无周期概念的渠道为 None
121+
cycle_end: int | None = None # 额度最早到期(epoch);无到期信息的渠道为 None
122122
expiry_ladder: list[tuple[int, float]] | None = None # [(到期 epoch, 该包剩余积分)]
123123
# (凭证, 模型) 冷却表:model → ModelCooldown。模型级限流只写这里,
124124
# 不写 cooling_until,因此同账号的其他模型仍然可选
@@ -127,7 +127,7 @@ class Candidate:
127127
def expiry_credits(self, now: int, window: int) -> float:
128128
"""窗口内即将到期的积分总量(不含已过期与已用完的包)。
129129
130-
窗口 ≤ 0 或渠道无周期概念时恒为 0,选号退回健康度排序。
130+
窗口 ≤ 0 或渠道无到期信息时恒为 0,选号退回健康度排序。
131131
"""
132132
return expiring_credits(self.expiry_ladder, window, now)
133133

‎src/provider/base.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,13 @@ class Quota:
125125

126126
remaining: float | None = None
127127
total: float | None = None
128-
cycle_end: int | None = None # 最早到期(epoch);TRAE 为 None
128+
cycle_end: int | None = None # 最早到期(epoch);无到期信息的渠道为 None
129129
# 到期阶梯 [(到期 epoch, 该套餐剩余积分)]:选号按"窗口内即将到期积分"排序。
130-
# 只有按包独立到期的渠道(CodeBuddy)有值;TRAE 无周期概念为 None。
130+
# 按包独立到期的渠道(CodeBuddy、TRAE 的权益包)有值;企业版等无此信息为 None。
131131
expiry_ladder: list[tuple[int, float]] | None = None
132132
# 额度明细包:[{"name", "total", "used", "end"}],仅用于展示。
133-
# 与 expiry_ladder 分开存:后者是调度排序指标(TRAE 刻意为 None),
134-
# 塞入纯展示数据会改变选号行为,且其结构装不下名称。
133+
# 与 expiry_ladder 分开存:后者的结构装不下名称,且两者口径不同
134+
# (明细额外含「已过期但仍有余额」与「已用完但仍有效」的包)。
135135
packages: list[dict[str, Any]] | None = None
136136
probed_at: int | None = None
137137
probe_failed: bool = False

‎src/provider/trae/client.py‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -391,8 +391,12 @@ async def fetch_quota(self, credential: TraeCredential) -> Quota:
391391
392392
同时保留每个权益包的名称/额度/已用/到期,供管理台展示奖励积分明细
393393
(它们各自独立到期,汇总数字看不出是哪些包、什么时候过期)。
394-
注意只填充展示用的 packages,不填 expiry_ladder:后者是选号排序
395-
指标,TRAE 按设计无周期概念(保持 None 不变)。
394+
395+
`expiry_ladder` 也一并填充:实测 TRAE 的权益包**确实各自独立到期**
396+
(每月登录积分按月、签到奖励各有到期日),不是「无周期概念」。只填
397+
展示用的 packages 会让「到期积分」行恒为空,快过期的积分拿不到优先
398+
消耗——这正是调度该避免的白丢。口径与 CodeBuddy 一致:阶梯只收
399+
「未过期 + 有余额」的包(已过期积分作废、已用完的包不携带积分)。
396400
"""
397401
data = await self._post_json(
398402
f"{self.ug_host}/trae/api/v2/pay/ide_user_ent_usage", {},
@@ -401,8 +405,10 @@ async def fetch_quota(self, credential: TraeCredential) -> Quota:
401405
packs = data.get("user_entitlement_pack_list")
402406
if not isinstance(packs, list):
403407
raise UpstreamProtocolViolation("quota response missing pack list")
408+
now_epoch = time.time()
404409
limit = 0.0
405410
used = 0.0
411+
ladder: list[tuple[int, float]] = []
406412
packages: list[dict[str, Any]] = []
407413
for pack in packs:
408414
if not isinstance(pack, dict):
@@ -412,16 +418,25 @@ async def fetch_quota(self, credential: TraeCredential) -> Quota:
412418
if not isinstance(pack_limit, (int, float)) or pack_limit <= 0:
413419
continue
414420
pack_used = (pack.get("usage") or {}).get("credits_amount")
421+
used_value = float(pack_used) if isinstance(pack_used, (int, float)) else 0.0
422+
balance = max(0.0, float(pack_limit) - used_value)
423+
end = _pack_end(pack)
415424
limit += float(pack_limit)
416-
used += float(pack_used) if isinstance(pack_used, (int, float)) else 0.0
425+
used += used_value
417426
packages.append({
418427
"name": _pack_name(pack),
419428
"total": float(pack_limit),
420-
"used": float(pack_used) if isinstance(pack_used, (int, float)) else 0.0,
421-
"end": _pack_end(pack),
429+
"used": used_value,
430+
"end": end,
422431
})
432+
# 到期阶梯(选号指标):上游会把已过期/已用完的包一起返回,
433+
# 不排除则「最早到期」永远指向过去、指标恒为 0。
434+
if end is None or end <= now_epoch or balance <= 0:
435+
continue
436+
ladder.append((end, balance))
423437
return Quota(remaining=max(0.0, limit - used), total=limit,
424-
packages=packages or None, probed_at=int(time.time()))
438+
expiry_ladder=ladder, packages=packages or None,
439+
probed_at=int(now_epoch))
425440

426441
async def fetch_checkin_status(self, credential: TraeCredential,
427442
*, device_id: str = "") -> dict[str, Any]:

0 commit comments

Comments
 (0)