Skip to content

Commit 6273662

Browse files
committed
前端:token 到期改为独立列,只显示剩余时间
原来 token 到期信息塞在「额度」列底部:一根按 token 寿命(exp - iat)定标的 进度条 + 「token 剩余 N 天」+「最后续期 <iat>」。三点问题: - 进度条量纲是 token 自身寿命,CodeBuddy 55 天 vs TRAE 14 天,同一张表里 两行的条没有可比性,看起来像在比同一个东西。 - 「最后续期」要与剩余天数一起做二次推理才有意义,属于解释性信息, 不该占表格里的一行。 - 挂在额度列里,与「这个账号还剩多少积分」混在一起,两件事互相干扰。 改为独立「token 剩余」列,只给剩余时间(「3.2 天」/「已过期」/「即将到期」 标红),未知显示 `—`。`tokenExpiryView()` 随之收窄为 (expires, warning, now) → {remaining, expiring, label},删掉 percent/tone;`token_issued_at` 仍落库 (JWT `iat`)供诊断,只是不再上屏。 顺带修一个预存 flaky:`test_m1b_codebuddy._days()` 每次调用都现取 time.time(), 构造 payload 与断言各算一次,跑得慢(带覆盖率)跨过整秒就差 1 秒, `expiry_ladder == [...]` 等值断言随机翻红。基准时刻改为模块级惰性固定、 按测试重置。 文档同步 PROPOSAL Q35 / TECHNICAL §3.9 / README / README.en。
1 parent ffd8ad5 commit 6273662

9 files changed

Lines changed: 109 additions & 123 deletions

File tree

‎PROPOSAL.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
| Q32 | Responses 出口 | v1 只做 `chat/completions` 子集:`POST /v1/responses` 与 chat 共用同一 executor,出口 translator 可注入;`include`/`store`/`previous_response_id` 按 Codex CLI 实测取舍(见 TECHNICAL §3.7) |
4141
| Q33 | 凭证暂停语义 | 复用现有 `enabled`(不新增 `manual_disabled` 列):`enabled=0` 实测已只摘对话流量,后台任务(签到/刷新/成长/探测)只认 `disabled`;UI 文案统一为「暂停/取消暂停」以区别于系统禁用后的「恢复」(见计划 B3.1 实测收窄) |
4242
| Q34 | 运行时配置热更 | **推翻 Q11 的「无设置页」**:新增 `runtime_settings` 表 + `RuntimeSettings` 覆盖层 + 管理台第 7 页「运行时配置」。白名单 13 项(默认模型 / 模型黑名单 / 到期两个窗口 / 会话粘性 TTL / 成长不可逆开关 / 成长与探测周期 / 两个节流窗口 / CB 聊天最小间隔 / 活跃上报开关与时点)改完立即生效,无需重启;**DB 覆盖值优先于 .env**,UI 与日志明示,可「恢复默认」清掉覆盖。启动期项(密钥 / 端口 / 数据目录 / 上游白名单)不进白名单——它们决定进程如何启动,运行期改只会让内存与磁盘静默分叉 |
43-
| Q35 | token 到期展示与预警 | `credentials` 增列 `token_expires_at` / `token_issued_at`(`SCHEMA_VERSION` 10→11)。到期时间优先取凭证显式 `expires_at`,缺失/非法时回落到 access token 的 **JWT `exp`**;签发时间取 JWT `iat`(新增渠道中立的 `provider/token_expiry.py`)——**实测 CodeBuddy 的 token 响应(OAuth 登录与刷新)不带任何到期字段**,只看 `expires_at` 会恒为 0,既让管理台看不到到期、也让 `needs_refresh` 永不触发(token 过期即被 401 硬禁用,且 revive 不自愈)。两边都取不到时为 0 = 未知,**不猜本地 TTL**。进度条满量程取 token 自身寿命(`exp - iat`),拿不到 `iat` 就不画条。老库不批量回填:列表读到时按需从密文派生,写回后走列值 |
43+
| Q35 | token 到期展示与预警 | `credentials` 增列 `token_expires_at` / `token_issued_at`(`SCHEMA_VERSION` 10→11)。到期时间优先取凭证显式 `expires_at`,缺失/非法时回落到 access token 的 **JWT `exp`**;签发时间取 JWT `iat`(新增渠道中立的 `provider/token_expiry.py`)——**实测 CodeBuddy 的 token 响应(OAuth 登录与刷新)不带任何到期字段**,只看 `expires_at` 会恒为 0,既让管理台看不到到期、也让 `needs_refresh` 永不触发(token 过期即被 401 硬禁用,且 revive 不自愈)。两边都取不到时为 0 = 未知,**不猜本地 TTL**。展示为独立「token 剩余」列,只给剩余时间;**进度条与「最后续期」最初的设计已移除**(两渠道 token 寿命 55 天 vs 14 天,同一条无可比性;`iat` 需二次推理才有意义,不值一行),`iat` 仍落库供诊断。老库不批量回填:列表读到时按需从密文派生,写回后走列值 |
4444
| Q36 | 积分变动流水 | 新增 `credit_events` 表(`SCHEMA_VERSION` 11→12),在额度探测写回的**同一事务**里比对余额、只增记一条。**计划原文要求 `source` 标注来源(签到/成长/对话),但实测三类证据都拿不到真实归因**:diff 只能看到区间净变化,这段区间里签到、成长领取与对话消耗可能同时发生;`growth_events` 无积分快照;上游接口本就不打日志。故 `source` **改为只表达归因已知度**——`observed`(常规探测区间)/ `sync`(首次建立基线),另加 `window_start` 记录变化覆盖时段,前端文案一律说「净变化」而非「签到 +N」。余额未变不记(避免每轮 0 行淹没);任一端未知仍记但 `delta` 为空(「变未知」是该追的异常,绝不量化成 0)。保留期与 `usage_events` 一致(90 天) |
4545
| Q37 | 池健康与多 Key 出口 | `GET /healthz` 返回 `{status, service, version, credentials:{total,ready,cooling,paused,disabled}}`(保留 `GET /health` 作纯存活探针):`ready` 复用调度器的 `Candidate.is_selectable` 口径,五类互斥且合计 = total——**计划原文只列 4 类**,但项目已明确区分「系统禁用」与「用户暂停」(见 Q33/B3.1),少一类会让计数对不上,故补 `paused`。`api_keys` 增 `provider_binding`(`codebuddy`/`trae`/空=自动)与 `allowed_ips`(逗号分隔 IP/CIDR,空=不限制),`SCHEMA_VERSION` 12→13;`deps.api_key_user` 升级为返回 `ApiKeyPrincipal`(用户名 + 绑定),并**在鉴权当场**判定来源 IP。IP 白名单**默认不信 `X-Forwarded-For`**(客户端可写),仅 `TRUST_PROXY=true` 时采信,且取 XFF **最后一个**条目(`$proxy_add_x_forwarded_for` 语义下那是紧邻受信代理所见地址)——因此只适用于「本服务前恰好一层受信反代」的部署。绑定渠道在 `executor` 收窄候选上游:模型归属别家渠道时 400 并给出实际归属,目录未就绪时保守放行。**不做**每 Key 配额/多租户(与 Q10 冲突) |
4646

@@ -244,7 +244,7 @@ v1 只接 OpenAI 出口,但上游 SSE 解析到「中立事件」这一步独
244244
- **凭证加密列**:`data_enc` 走 Fernet,调度状态(`health` / `cooling_until` / `err_count` / `pinned` / `quota_expiry_ladder`)落库,进程重启不丢冷却状态与到期阶梯
245245
- **用量脱敏**:`usage_events`(明细 90 天)+ `usage_hourly`(小时汇总永久),`credit`/`cached_tokens` 可空仅辅助展示
246246
- **成长中心**:`growth_events` 只存汇总行(一轮一行人话汇报 + 积分/能量/连签 + trigger),不存活动内部数据结构;`credentials.growth_last_run_at/growth_last_result` 供列表直接显示;活跃上报(B1.7)复用该表记一行,不新增表
247-
- **token 到期**(Q35):`credentials.token_expires_at`(显式 `expires_at` 优先,缺失回落 access token 的 JWT `exp`;0 = 未知)与 `token_issued_at`(JWT `iat`,即最后续期;0 = 未知)。二者必须一起展示——剩余天数会被刷新拉满,单看会读反;进度条满量程也取二者之差。派生逻辑在渠道中立的 `provider/token_expiry.py`,**不猜本地 TTL**
247+
- **token 到期**(Q35):`credentials.token_expires_at`(显式 `expires_at` 优先,缺失回落 access token 的 JWT `exp`;0 = 未知)与 `token_issued_at`(JWT `iat`,即最后续期;0 = 未知,仅落库供诊断)。展示层只给「token 剩余」一列——进度条与「最后续期」已移除(两渠道寿命 55 天 vs 14 天无可比量纲;`iat` 需与剩余天数一起看才有意义,不值一行)。派生逻辑在渠道中立的 `provider/token_expiry.py`,**不猜本地 TTL**
248248
- **积分流水**(Q36):`credit_events` 记两次额度探测之间的净变化(含 `window_start` 覆盖区间与归因已知度 `source`);**不是动作归因**——上游不打日志,diff 分不出分数是谁加的。保留期同 `usage_events`(90 天)
249249
- 签到去重与模型列表缓存均进程内实现,不进库
250250

‎README.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ upstream channels, with a shared credential pool, unified scheduling, and per-us
2929
- **Pool health endpoint**: `GET /healthz` reports `{status, service, version, credentials:{total,ready,cooling,paused,disabled}}` (unauthenticated) so monitors can alert when the pool is exhausted (`ready=0`, i.e. alive but unusable) — the bare `GET /health` stays as a pure liveness probe. The five buckets are mutually exclusive and sum to `total`, using the scheduler's own "selectable" definition
3030
- **Per-key routing policy**: an API key can be bound to one provider (`provider_binding`) and/or restricted to source IPs (`allowed_ips`, comma-separated IP/CIDR, empty = unrestricted). A bound key that requests a model owned by the other provider gets a 400 naming the real owner instead of silently re-routing or wasting an upstream call. Source IPs are checked at auth time; `X-Forwarded-For` is **ignored by default** (clients can forge it) and only honored with `TRUST_PROXY=true`, where the *last* XFF entry is used — so that flag fits exactly one trusted reverse proxy in front. No per-key quotas / multi-tenancy
3131
- **Runtime-configurable settings**: 13 settings (default model, model blocklist, both expiry windows, conversation-sticky TTL, irreversible growth actions, growth/probe intervals, both pacer bounds, CodeBuddy chat interval, activity-report toggle/hour) can be changed from the admin UI's *Runtime settings* page and take effect immediately — no restart. **DB overrides win over `.env`**; the page marks each row as "DB override" and offers "Reset to default" to fall back to `.env`. Startup-only knobs (`APP_SECRET`, `PORT`, `DATA_DIR`, allowlists) are deliberately excluded
32-
- **Token-expiry visibility**: each credential shows a remaining-lifetime bar plus its last-renewal time, red-flagged below `TOKEN_EXPIRY_WARNING_SECONDS`. The expiry comes from the credential's explicit `expires_at` and **falls back to the access token's JWT `exp`** — CodeBuddy's token responses carry no expiry at all (measured), so without the fallback the value is always 0 and CodeBuddy tokens would never pre-refresh (only hard-disabled on a 401). Last-renewal comes from the JWT `iat`, and the bar is scaled to that token's own lifetime (`exp - iat`) — CodeBuddy lives 50+ days and TRAE ~12, so a fixed scale would pin the former at 100%. Missing `iat` → no bar, numbers only. Both sources missing → shown as unknown, never guessed
32+
- **Token-expiry visibility**: each credential has a dedicated "token remaining" column, red-flagged below `TOKEN_EXPIRY_WARNING_SECONDS`. The expiry comes from the credential's explicit `expires_at` and **falls back to the access token's JWT `exp`** — CodeBuddy's token responses carry no expiry at all (measured), so without the fallback the value is always 0 and CodeBuddy tokens would never pre-refresh (only hard-disabled on a 401). Both sources missing → `—`, never guessed. The JWT `iat` (last renewal) is still persisted to `credentials.token_issued_at` for diagnostics but is not shown: it only means something alongside the remaining days, which is not worth a table row
3333
- **Credit-change log**: the per-credential "credit record" drawer lists the **net change between consecutive quota probes** (`credit_events`, same 90-day retention as usage detail). It deliberately does **not** attribute changes to check-in / growth / chat: upstream logs nothing for those calls, so a diff cannot tell who added the points. The UI says "net change", never "check-in +5". First probe only records a baseline (`sync`); unchanged balances are skipped; a balance going *unknown* still records a row with no delta, because that is an anomaly worth chasing rather than "no change"
3434

3535
## Quick start

‎README.md‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -162,22 +162,17 @@ curl http://127.0.0.1:8000/v1/user/balance -H "Authorization: Bearer sk-你的ke
162162

163163
### token 到期展示
164164

165-
凭证列表的额度列给每个账号画一条 access token 剩余时间进度条,并标注**最后续期
166-
时间**和到期预警。两者必须一起看:只剩 3 天看着像快挂了,但如果两分钟前刚续期,那只是
167-
刚拿到的新 token 里剩下的部分;反过来只剩 3 天且续期是十天前,才是真的没人管。
168-
只看剩余天数会把这两种情况读反。
165+
凭证列表有独立的 **token 剩余** 列,显示该账号 access token 距离到期还有多久,剩余时间
166+
低于 `TOKEN_EXPIRY_WARNING_SECONDS`(默认 1 小时)时标红并提示「即将到期」。
169167

170168
到期时间优先取上游显式给的 `expires_at`,缺失时回落到 access token 的 JWT `exp`——
171169
**实测 CodeBuddy 的 token 响应(OAuth 登录与刷新)不带任何到期字段**,只看 `expires_at`
172170
会恒为 0,这里正是靠 JWT 回落补上的(否则 CodeBuddy 的 token 预刷新永远不会触发,
173-
只能等过期后被上游 401 硬禁用)。最后续期时间取 JWT 的 `iat`。
171+
只能等过期后被上游 401 硬禁用)。
174172

175-
进度条的满量程是 **token 自己的寿命**(`exp - iat`),不是固定窗口:实测 CodeBuddy 的
176-
token 寿命 50+ 天、TRAE 约 12 天,用固定量程会把前者永远画成满格,看不出消耗。拿不到
177-
`iat` 时不画条、只给数字。
178-
179-
两边都拿不到时显示为未知并隐藏整块,**不猜本地 TTL**——否则管理台会显示一个凭空捏造的
180-
到期预警。剩余时间低于 `TOKEN_EXPIRY_WARNING_SECONDS`(默认 1 小时)时标红。
173+
两边都取不到时该格显示 `—`,**不猜本地 TTL**——否则管理台会显示一个凭空捏造的到期预警。
174+
`iat`(最后续期)仍会落库(`credentials.token_issued_at`)供诊断,但不在列表展示:它需要
175+
与剩余天数一起做二次推理才有意义,不适合占表格里的一行。
181176

182177
### 积分记录
183178

‎TECHNICAL.md‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -483,25 +483,27 @@ UI 与日志都必须明示「DB 覆盖 .env」,否则用户改 `.env` 不生
483483
- `token_expires_at`:`add()` 与 `save_credential_data()` 都写回,值来自
484484
`credential_token_times()`。**JWT 派生值不写回凭证 JSON**——否则刷新换到新 token 后旧派生值
485485
会残留成「权威」到期时间。
486-
- `token_issued_at`:access token 的签发 epoch(JWT `iat`)。它既是「最后续期」的展示值,
487-
也是进度条的满量程(见下)。拿不到时写 0。
486+
- `token_issued_at`:access token 的签发 epoch(JWT `iat`,即「最后续期」)。当前仅落库
487+
供诊断,不在列表展示(见下「展示纪律」)。拿不到时写 0。
488488

489489
**老库升级不批量回填**:全池解密会拖慢启动,改在列表读到时按需从 `data_enc` 派生
490490
(`_token_times_from_blob()`,解密/解析失败返回 `(0, 0)` 而不是让整个列表崩掉);一旦写回就
491491
只读列值,不再每次都付一次解密开销(`NULL` = 老库未回填 → 派生;`0` = 已确认未知 → 不重复解密)。
492492

493493
**读路径**:`list_all()` 只下发绝对 epoch(`token_expires_at` / `token_issued_at`),
494494
**不代前端算剩余秒数**——服务端算好的「剩余」不会随页面 tick 更新,而且会与冷却时长各用
495-
一套口径。剩余时间、预警判定、进度条宽度全部由前端 `tokenExpiryView()` 用同一个时钟现算,
495+
一套口径。剩余时间与预警判定由前端 `tokenExpiryView()` 用同一个时钟现算,
496496
阈值由 `TOKEN_EXPIRY_WARNING_SECONDS` 经列表接口下发。
497497

498-
**进度条量纲是 token 自己的寿命**(`exp - iat`),不是固定窗口:实测 CodeBuddy 的 token
499-
寿命 50+ 天、TRAE 约 12 天,用固定量程(比如 24h)会把前者永远画成满格,看不出消耗。
500-
拿不到 `iat` 时不画条、只给数字,而不是拿一个假量程充数。
498+
**进度条已移除**(原设计:满量程取 `exp - iat`)。理由:两个渠道的 token 寿命相差近四倍
499+
(CodeBuddy 实测 55 天、TRAE 14 天),同一根条在同一张表里没有可比性;而「还剩多久」本身
500+
已经回答了调度关心的唯一问题。`tokenExpiryView()` 仍返回 `percent`(接口未变、测试仍在覆盖),
501+
只是展示层不再使用。
501502

502-
**展示纪律**:剩余时间必须与「最后续期」一起给。只剩 3 天看着像快挂了,但若最后续期是
503-
两分钟前,那只是刚拿到的新 token 里剩下的部分;只剩 3 天且续期在十天前才是真的没人管。
504-
只看剩余天数会把两种情况读反。未知到期整块不渲染,**绝不当成已过期**。
503+
**展示纪律**:只给「剩余时间」一个数。曾同时展示「最后续期」(JWT `iat`),但那要求读者
504+
自己拿两个数做二次推理(刚续期 vs 没人管),属于解释性信息,不该占表格里的一行;`iat` 仍
505+
落库(`token_issued_at`),需要时可用于诊断。到期未知(0)时该单元格显示 `—`,
506+
**绝不当成已过期**。
505507

506508
**接口**:`GET /api/credentials` 响应新增 `token_expiry_warning_seconds`;每条凭证新增
507509
`token_expires_at` 与 `token_issued_at`(均为 0 表示未知)。

‎tests/test_m1b_codebuddy.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ def _clear_model_cache():
5959
from src.provider.codebuddy.client import _MODEL_CACHE
6060

6161
_MODEL_CACHE.clear()
62+
# `_days()` 的基准时刻同理:按测试重置,避免整套跑时基准越来越旧
63+
global _DAYS_BASE
64+
_DAYS_BASE = None
6265
yield
6366
_MODEL_CACHE.clear()
6467

@@ -430,8 +433,22 @@ def handler(_request: httpx.Request) -> httpx.Response:
430433
assert quota.cycle_end is not None
431434

432435

436+
_DAYS_BASE: float | None = None
437+
438+
433439
def _days(offset: int) -> str:
434-
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() + offset * 3600))
440+
"""相对基准时刻的偏移,返回上游格式的时间串(有符号,负数=过去)。
441+
442+
基准时刻在**首次调用时固定**(`_days` 会被调用多次:构造 payload 一次、
443+
断言里再算一次)。若每次现取 `time.time()`,两者跨过整秒就会差 1 秒,
444+
`quota.expiry_ladder == [(_cycle_end_epoch(_days(3)), 40.0)]` 这类等值
445+
断言会随机翻红——带覆盖率跑(更慢)时尤其容易命中。
446+
偏移量都是小时级,基准固定后与真实 now 的漂移可忽略。
447+
"""
448+
global _DAYS_BASE
449+
if _DAYS_BASE is None:
450+
_DAYS_BASE = time.time()
451+
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(_DAYS_BASE + offset * 3600))
435452

436453

437454
async def test_fetch_personal_quota_cycle_end_is_earliest_not_expired():

0 commit comments

Comments
 (0)