diff --git a/README.md b/README.md index 0b63be6..5a827fc 100644 --- a/README.md +++ b/README.md @@ -171,12 +171,14 @@ plugins: 重置统计入口只在完整模式可用,需要当前完整模式会话和显式确认。 -表格偏好、Token 显示单位和时间范围保存在插件数据库中。`token_display_mode` 支持 `full`、`k`、`m`、`B`,普通模式和完整模式共用该偏好。自定义时间按浏览器本地时区选择,再转换为 UTC RFC3339 时间戳请求。 +表格偏好、Token 显示单位和时间范围保存在插件数据库中。`token_display_mode` 支持 `full`、`k`、`m`、`B`,普通模式和完整模式共用该偏好。自定义时间按浏览器本地时区选择,再转换为 UTC RFC3339 时间戳请求。页面打开或刷新时,如果距离上次成功打开已满 15 分钟,保存的时间范围会自动恢复为浏览器本地当天;15 分钟内重新打开则保留原选择。该打开时间与日期范围一样由同一插件数据库共享。 ### 模型价格与费用估算 模型价格入口只在完整模式显示。所有价格单位均为每 100 万 Token 的美元价格,支持 Input、Output、Cache Read、Cache Creation、Context Tier、Service Tier 独立价格及其 Context Tier,以及 `input_excludes_cache` 和 `input_includes_cache` 两种计费方式。所有价格为 0 的模型按免费模型处理。 +支持在模型的“峰谷定价”折叠区配置时区、星期和时间段(最多 32 个),按请求原始时间选价。未命中时使用基础或 Service Tier 价格;Context Tier 优先于时间价格。跨午夜归属起始日,时间区间包含开始、不包含结束。修改配置会重新估算历史费用。详见 [峰谷定价说明](docs/issue-78-time-of-use-pricing-plan.md)。 + 价格可手工维护,也可从 models.dev 同步。同步先读取 CLIProxyAPI `/v1/models` 当前返回的模型,再根据提供商优先级、忽略后缀和显式模型映射匹配 models.dev。 手工价格优先,不会被同步覆盖。价格簿使用 revision 防止并发覆盖。费用根据逐请求记录和匹配的价格规则计算;缺价请求会显示在价格覆盖率和缺价提示中,不会作为零成本混入已知费用。 @@ -468,7 +470,7 @@ Without an explicit `data_path`, the plugin resolves the database in this order: Both modes support preset or custom date-time ranges, source filtering, trend granularity and zoom, model drill-down, full, k, m, or B token units (`B = 1,000,000,000`), currency units, table columns and sorting, manual refresh, 15-second automatic refresh, and preset/custom table page sizes. Statistics reset is available only in full mode and requires the active session plus explicit confirmation. -Table preferences, the selected token display unit, and the selected range are stored in the plugin database. `token_display_mode` accepts `full`, `k`, `m`, or `B`, and normal and full modes share the preference. Custom browser-local times are converted to UTC RFC3339 timestamps for requests. +Table preferences, the selected token display unit, and the selected range are stored in the plugin database. `token_display_mode` accepts `full`, `k`, `m`, or `B`, and normal and full modes share the preference. Custom browser-local times are converted to UTC RFC3339 timestamps for requests. On open or reload, if at least 15 minutes have elapsed since the last successful open, the saved range resets to the browser's local today; reopening within 15 minutes keeps the saved range. The open timestamp, like the selected range, is shared by clients using the same plugin database. ### Model Pricing and Cost Estimation diff --git a/docs/issue-78-time-of-use-pricing-plan.md b/docs/issue-78-time-of-use-pricing-plan.md new file mode 100644 index 0000000..6189f63 --- /dev/null +++ b/docs/issue-78-time-of-use-pricing-plan.md @@ -0,0 +1,63 @@ +# Issue #78:模型峰谷定价 + +对应 https://github.com/AITNR/cap-token-usage-tracker/issues/78 。 + +## 实现范围 + +完整模式的模型价格编辑器新增峰谷定价折叠区。模型可配置 IANA 时区、时段名称、ISO 星期编号(1=星期一,7=星期日,留空为每天)、开始和结束时间,以及四项 USD/百万 token 单价。 + +- 每个模型最多 32 个时段;名称唯一,时段不允许重叠。 +- 时间严格使用 HH:mm,起止相同被拒绝,不支持 24:00。 +- 区间左闭右开;23:00–08:00 表示跨午夜,并归属起始日。例如星期日的该时段延续到星期一 08:00。 +- 默认 UTC;禁止依赖机器的 Local 时区。内嵌 Go 时区数据,支持 Windows 和精简容器。 +- 夏令时重复小时按当地钟表时间匹配,两次均适用;跳过的小时没有对应请求时刻。 +- 按保存的请求时间选价,沿用上游缺少 RequestedAt 时的接收时间兜底,无法据此保证与供应商账单完全一致。 + +## 价格优先级 + +先匹配模型和 Service Tier,再应用时间价格,最后应用 Context Tier。时间价格覆盖四项单价;Context Tier 超阈值时覆盖时间价格。这一版不支持时间段内嵌上下文阶梯。 + +未命中使用基础/Service Tier 价格。请求费用提示和 CSV 增加计费时段、计费时区;当 Context Tier 实际覆盖时间价格时,不将时间段标为最终计费依据。 + +## 示例 + +以下仅为测试示例,不代表 DeepSeek 当前官方价格: + +```json +{ + "prices": { + "example-model": { + "input": 4, + "output": 8, + "cache_read": 1, + "cache_creation": 0, + "time_zone": "Asia/Shanghai", + "time_tiers": [ + { + "name": "谷时", + "start": "23:00", + "end": "08:00", + "input": 1, + "output": 2, + "cache_read": 0.25, + "cache_creation": 0 + } + ] + } + } +} +``` + +## 持久化、同步与兼容性 + +扩展现有价格账本 JSON,无请求记录迁移。没有时间字段的旧配置继续有效。保存增加价格账本 revision,历史请求查询与成本汇总按当前配置重新估算。 + +沿用手动价格整条保护:编辑时间计划后该模型成为手动价格,models.dev 同步不会更新其基础价格。对于仍标记 models.dev 的条目,同步更新基础价格时保留已有时间计划。没有新增同步计数字段,手动条目继续计入 SkippedManual。 + +旧程序不认识这些 JSON 字段;降级后重新保存价格可能丢失时间计划,降级前应备份数据库。 + +## 验证 + +Go 测试覆盖分钟边界、跨周午夜、星期限制、夏令时重复小时、非法配置、深拷贝、价格优先级、同步保留、历史查询缓存失效和重启持久化。浏览器回归覆盖时段输入、表单序列化回填、重叠拒绝和删除。 + +仓库没有 npm test;浏览器测试由 Go 调用 Playwright。Windows 上设置 CHROME_PATH 和 REQUIRE_BROWSER_TESTS=1 后执行 go test ./...。依赖使用 npm ci 安装,不下载浏览器。 diff --git a/docs/issue-80-startup-cost-elimination-plan.md b/docs/issue-80-startup-cost-elimination-plan.md new file mode 100644 index 0000000..30e78f5 --- /dev/null +++ b/docs/issue-80-startup-cost-elimination-plan.md @@ -0,0 +1,178 @@ +# Issue #80 启动成本消除实施计划 + +- Issue: https://github.com/AITNR/cap-token-usage-tracker/issues/80 +- 基础方案:`docs/issue-80-startup-readiness-fix-plan.md`(生命周期 readiness 与异步化设计) +- 目标:让插件启动不再随历史聚合数据和逐请求记录规模线性增长,同时保持现有 API 统计语义。 +- 文档性质:实施规格与验收清单;实现时以本文为约束,不将本文内容当作对外部宿主契约的证明。 +- 当前代码基线:工作区 `persistence.go` 的 schema v10/API Key catalog 改动;实施者开始前必须重新检查 git 状态和实际代码,保留所有既有改动。 + +## 1. 目标与架构决策 + +当前稳定 schema 已通过 API Key catalog 消除 `reload()` 对 `requests` 桶的扫描,但 `reload()` 仍将保留期内的全部聚合记录读入 `a.data`。本计划将全量内存恢复改为**热窗口驻留 + actor 内按需范围读取**,并修复 catalog 的损坏恢复策略。 + +本计划只承诺降低插件自身的数据库恢复成本;不承诺某个硬编码启动时限。CPA 是否等待注册完成、是否重试 usage RPC、是否允许 register 返回时 capability 尚未 ready,均须通过目标 CPA/SDK 版本验证。未经验证不得依赖异步注册或 usage 503 重试。 + +架构边界: + +1. Actor 仍是 store 状态与数据库访问的唯一串行入口。查询、flush、prune、reset 和范围读通过 actor 命令协调;不得从新 goroutine 绕过 actor 并发读写 `a.data` 或同一个 `bolt.DB`。 +2. bbolt 聚合桶名当前为 `hours`,但其 key 实际是 `RequestedAt.UTC().Truncate(time.Minute).Unix()`。外层桶是**分钟聚合桶**,不是小时桶。所有范围边界、热窗口和性能估算都必须按分钟 key 处理;保留现有 bucket 名称以避免无关 schema 迁移。 +3. 热窗口是内存优化,不是数据可用性边界。查询范围超出热窗口必须由同一 actor 读取冷范围,并与内存中未 flush 的 dirty 聚合合并,结果不得不完整。 +4. catalog、models index 均是可重建派生数据;不得因其缺失/损坏而静默丢数据,也不得在 `register()` 同步路径无条件触发全量重建。 + +## 2. 当前基线事实 + +启动路径为 `openStoreWithCrypto()` -> `storeActor.initialize()` -> `reload()`。稳定 schema v10 下: + +- `reload()` 从 `meta` 恢复配置、价格、偏好和 API Key catalog; +- `reload()` 遍历 `hours` 下所有保留分钟桶,反序列化维度与 counters 并填充 `a.data`; +- `reload()` 不再扫描 `requests`; +- schema 升级或 catalog 缺失时,`buildAPIKeyCatalog()` 仍需一次全量 backfill; +- `flush()` 维护 catalog 与聚合、请求记录的事务一致性;prune 处理过期记录; +- `observedModels()` 当前通过 `a.data` 枚举模型,热窗口化后若不另建索引会改变其保留期语义。 + +这些事实实施前须用当前代码再次确认。不要依赖文档中的固定行号。 + +## 3. 实施阶段 + +### 阶段 0:基线、隔离与测量 + +1. 检查 `git status`、当前分支和已有未提交改动;不得覆盖用户或先前任务变更。 +2. 保存当前测试基线,运行 `go test ./... -count=1` 和 `go vet ./...`。 +3. 建立可重复的合成 bbolt fixture:多个分钟、多个 dimension、多个 API Key、请求明细;至少包含热窗口内外数据。 +4. 记录现状 open 耗时、`reload()` 聚合解码条数、峰值内存。时间数据仅作为基准,不设环境无关的绝对毫秒承诺。 + +验收:fixture 可复用、测试可确定性运行,基准能证明初始化扫描记录数。 + +### 阶段 1:修复 catalog 派生索引可靠性 + +1. 合并 `initialize()` 中 catalog load/build 两分支的重复 save;仅在构建、修复或确有变更时写入。 +2. 对 catalog 缺失、JSON 损坏、引用计数非法、引用 generation 无效分别定义行为。 +3. Catalog 是派生索引:损坏时不得直接把统计主库判为不可用;也不得在同步注册路径自动 full-scan 修复。 +4. 本阶段采用显式 degraded/rebuild-required 状态或维护入口。若完整 readiness 状态目前尚未实现,应让主统计和逐请求查询继续可用,同时禁用依赖不可信 catalog 的 API Key label/ciphertext 管理操作,并记录不含敏感数据的错误。禁止用空 catalog 覆盖损坏 catalog。 +5. 提供 actor 串行执行的显式 catalog rebuild 操作;rebuild 在 bbolt transaction 中从 hours/requests 重建并校验,成功后原子替换,失败时保留旧索引与错误状态。 +6. 增加 catalog format/version 或 generation 标记,使“有效空 catalog”和“缺失/损坏 catalog”可区分。 +7. 维持现有写入语义:aggregate/request 与 catalog 在同一 bbolt transaction 更新;transaction 成功后再更新 actor 内存副本。 + +测试:valid empty catalog、missing、非法 JSON、非法计数、backfill 成功/失败、写事务回滚、prune、reset、restore、API Key generation 切换、label 引用关系。 + +### 阶段 2:按需访问 observed models + +1. 保持 `ObservedModels()` 的现有语义:返回保留数据范围内出现过的模型,不允许悄悄缩成热窗口模型。 +2. 增加独立 `observed_models` 派生索引,在成功持久化新聚合时增量维护;索引更新与聚合写入同一事务。 +3. 过期分钟桶 prune 时,只有当模型的全部保留聚合引用都已移除后才删除模型。可以维护模型级计数,也可通过明确可控的重建策略;不得每小时扫描全量请求记录。 +4. 旧库模型索引缺失时,允许一次 rebuild,但要与 catalog 一样避免阻塞宿主注册;应由显式维护入口或异步 readiness 阶段协调。过渡期 `ObservedModels()` 可在索引不可用时执行明确可观察的兼容扫描,不能返回不完整集合。 + +测试:旧模型超出热窗口但仍在 retention 内时仍可观察;模型所有聚合过期后消失;多 provider/model 组合、reset、restore 和索引 rebuild。 + +### 阶段 3:定义 actor 内聚合范围数据源 + +实现范围读取 helper/接口,供聚合查询统一使用。接口可以采用 `aggregateSource`,但必须满足: + +- 输入范围使用 UTC Unix minute key,边界严格遵守现有左闭右开语义; +- bbolt cursor 从 `encodeInt64(start)` seek,到 `end` 前停止;处理空桶、非法 key、嵌套 bucket 的行为与现有校验一致; +- 在 actor command 内读取,避免和 flush/reset/restore 并发;一次查询内的数据视图一致; +- 查询合并内存 `a.dirty`/`a.data` 的未 flush 写入,避免漏掉近期记录或重复聚合; +- 冷数据读取后默认不常驻内存;若增加 LRU,容量必须有界,且 reset/restore/reconfigure/prune 能正确失效; +- 当前 `hours` 外桶是分钟粒度,不能按 24/168/720 个桶估算。24h、7d、30d 分别最多覆盖约 1,440、10,080、43,200 个分钟桶(受起止分钟、空桶影响)。 + +优先保留基于 map 的 source 作为测试 oracle;bbolt source 与 map source 对同一 fixture 必须逐字段等价。 + +### 阶段 4:迁移查询路径 + +逐项让以下查询从范围 source 读取,不能只改首页主 stats: + +1. 完整 stats、summary、groups; +2. initial stats; +3. trends; +4. 分页 groups; +5. custom 秒级精确查询:仍以 requests 明细按精确时间重建,不得把分钟聚合误用于秒级边界; +6. costs:保持现有按 request detail 的时间范围语义,避免错误复用分钟聚合导致价格/费用改变; +7. CSV/导出及 Full Mode 使用的相同后端查询路径; +8. `retention` 全量范围明确允许全量范围读,结果必须完整。它可能仍然慢,应作为独立性能问题测量,不得以截断热窗口数据冒充完整结果。 + +UI/API schema 与统计口径保持不变。若某范围确实需要异步/分块响应,须另行设计并明确响应契约,本阶段不静默返回部分结果。 + +### 阶段 5:热窗口化 reload 与写入协调 + +1. 配置 `hot_window` 可暂不公开为用户配置;优先选择常量或内部默认值,以减少配置兼容面。窗口以时间长度表达,映射为分钟范围。 +2. `reload()` 只把 `[now-hotWindow, now]` 的分钟聚合载入 `a.data`;冷数据留在 bbolt。 +3. 初始化/恢复期间需确保窗口下界之前的数据仍可由范围 source 查询。 +4. 新 usage 正常进入 actor 内存;即便 RequestedAt 落在热窗口之前或未来,必须遵循现有接受/保留边界,并保证查询结果不丢不重。迟到事件不得因只缓存 hot window 而无法 flush 或 catalog 计数错误。 +5. `persistedAggregates` 只跟踪内存中热窗口内实际加载并持久化的 keys;冷范围查询不得错误修改 dirty/persisted 状态。 +6. prune 需删除数据库中过期分钟桶,并同步删除内存中对应 key、model 索引引用、API Key catalog 引用;冷数据不在内存时仍必须正确完成。 + +### 阶段 6:异步 readiness(可选安全网,契约门控) + +只有在检查目标 CLIProxyAPI/SDK 版本后确认 register 和 RPC 契约,才可实现以下内容: + +- `register()` 快速返回与 `starting/ready/failed` readiness; +- 所有依赖 store 的 endpoint 行为; +- usage during startup 的严格拒绝、重试或持久化队列策略; +- shutdown/reconfigure/restore 的取消与 handover。 + +若 usage RPC 失败不保证重试,禁止仅返回 503 并声称无数据丢失;若实现队列,必须有界/持久、保持顺序、可观测、在 shutdown 时不丢且有回放幂等方案。若本阶段宿主契约无法确认,则不实施异步注册,并在交付说明标明;前述同步路径仍须通过范围加载降低成本。 + +## 4. 必须覆盖的状态与操作 + +实现者须逐一盘点并处理: + +- store open / reopen / reconfigure / shutdown; +- flush、sync_on_record、定时 flush、retention prune; +- backup / restore / staged migration / rollback; +- reset; +- API Key rotation、labels、ciphertext catalog; +- ObservedModels; +- stats、groups、initial、trends、requests、costs、export、Full Mode; +- 空库、旧 schema、非法索引、部分索引、数据库读写错误。 + +不得因范围加载而改变公开统计结果、失败计数、维度过滤、source 过滤、exact custom 范围、价格账本、保留期或 API Key 脱敏语义。 + +## 5. 测试与验证 + +### 正确性 + +1. 对合成数据库保留旧全量 map 结果作为 oracle,比较 `24h`、`7d`、`30d`、custom、retention 的 stats/initial/trend/groups 所有字段。 +2. 分钟边界:start/end 落在分钟中间、刚好边界、跨日/月/epoch 前后;验证左闭右开。 +3. 写入协调:dirty 未 flush、sync_on_record、迟到 usage、flush 同时查询、prune 边界。 +4. catalog/models 索引:缺失、损坏、重建失败与成功、恢复、reset、rotation、过期清理。 +5. exact custom 秒级查询和成本查询必须继续读取 requests,并与当前实现逐字段一致。 +6. 并发与生命周期:关闭、重配置、restore 与查询冲突时无死锁、数据竞争、重复计数或句柄泄漏。 + +### 性能 + +合成 fixtures 覆盖小库、十万级与百万级聚合/请求记录;报告: + +- open/reload 耗时; +- `reload()` 解码的聚合记录数量,应与热窗口记录量相当而不是 retention 总量; +- 稳定 schema reopen 不解码 requests;旧库索引 rebuild 单独测量; +- 首次 24h/7d/30d/retention 查询耗时; +- 峰值 RSS; +- catalog/model index rebuild 的耗时与失败行为。 + +不设跨设备的绝对毫秒门槛。核心验收是稳定启动路径的扫描数量受热窗口约束,且完整范围查询结果不变。 + +### 命令 + +```powershell +go test ./internal/plugin -count=1 +go test ./... -count=1 +go vet ./... +git diff --check +``` + +若执行 race test 需要 CGO 且环境没有可用 C 编译器,应明确报告环境限制,不得把未运行写成通过。 + +## 6. 实施顺序与交付 + +建议依次完成阶段 0–5,再依据宿主契约决定阶段 6。按需范围读取和热窗口改造涉及 `persistence.go`、`aggregate.go`、`cost.go` 与测试,实施期间避免并行代理修改同一文件。 + +实现者完成后报告: + +- 变更文件和各阶段完成情况; +- 性能前后测量及记录数证据; +- 全量语义一致性测试结果; +- 未完成阶段及原因; +- CPA/SDK 契约实际验证来源; +- 环境限制和剩余风险。 + +验收的最低标准:启动不再反序列化 retention 范围全部聚合记录;稳定 schema 不扫描 requests;所有公开查询仍返回完整且与原实现等价的结果;索引损坏不会静默破坏主数据或同步触发无界扫描;所有生命周期路径测试通过。 diff --git a/docs/issue-80-startup-readiness-fix-plan.md b/docs/issue-80-startup-readiness-fix-plan.md new file mode 100644 index 0000000..e3bfa59 --- /dev/null +++ b/docs/issue-80-startup-readiness-fix-plan.md @@ -0,0 +1,158 @@ +# Issue #80 修复方案:降低大 bbolt 数据库的启动阻塞 + +- Issue: +- 文档性质:基于当前工作区代码的修复设计与已实施核心修复记录。存储层 catalog/backfill、稳定 reopen 和周期 prune 优化已落地;异步 readiness 仍未实现。 +- 证据边界:Issue #80 报告了插件 `v2.0.8`、CLIProxyAPI `v8.0.4`、Linux amd64、约 1.8 GiB 数据库及约 8 分钟启动现象。这些是报告环境,不是本地复现实测结果。下文的函数、控制流和遍历结论来自当前工作区;宿主 RPC 的异步注册契约仍需在目标 CPA 版本上验证。 + +## 1. 已确认的调用链与扫描事实 + +当前注册路径是同步的: + +```text +pluginRuntime.register() + -> applyConfig() + -> openStoreWithCrypto() + -> storeActor.initialize() + -> bbolt Update:清理、迁移、generation 状态 + -> storeActor.reload() +``` + +`internal/plugin/lifecycle.go` 中的 `register()` 在 `lifecycleMu` 保护下调用 `applyConfig()`;数据路径需要打开新 store 时,`openStoreWithCrypto()` 会在返回前执行 `actor.initialize()`。因此,`initialize()` 的耗时处于本插件注册请求的同步路径。Issue #80 进一步报告 CPA 会等待该注册完成后才进入 ready;这是待在实际 CPA/SDK 版本上回归确认的宿主行为。 + +### 1.1 启动时 `requestsBucket` 的已确认成本 + +`internal/plugin/persistence.go` 的 `initialize()` 会先执行一次写事务: + +1. `pruneRequestsBucket()` 用按时间和序号排序的 request key 游标删除过期前缀;它在遇到首个未过期 key 后 `break`,因此不是必然读取全部保留 request value,也不会在该函数中 JSON 解码 `RequestDetail`。过期记录很多时,删除成本仍可能显著。 +2. `migrateAPIKeyCryptoSchema()` 在 `version >= 8` 时调用 `validateAPIKeyGenerationReferences(hours, requests, generations)`。该校验遍历 `requests` 并 JSON 解码每条 `RequestDetail`,同时遍历小时聚合维度。即使没有新的 schema 迁移,当前代码路径仍会执行此校验。 +3. 写事务完成后 `reload()` 再次调用同一个 `validateAPIKeyGenerationReferences()`,再次全量遍历并解码当前保留的 request records。 +4. `reload()` 之后独立执行 `requests.ForEach`,第三次遍历并 JSON 解码每条 request,用于重建 `a.apiKeyCiphertexts`,并把 request 引用补入 `retainedHashes`。 + +因此,对 schema 已处于 `>= 8` 的常规启动,清理过期前缀之后,当前代码至少存在 **三次** 对保留 `requestsBucket` 的全量 JSON 解码遍历:迁移函数内 generation 引用校验、`reload()` 内重复校验和 `reload()` 的 ciphertext/引用重建。这个结论不依赖对单条记录大小或记录数量的估算。 + +不能仅凭该结论断言 Issue 中约 8 分钟全部由这三次遍历造成:数据库页缓存、磁盘性能、bbolt 写入/删除、其他迁移和聚合桶数量同样会影响总耗时,应通过阶段计时和基准测试量化。 + +### 1.2 周期 prune 的已确认成本 + +`reload()` 把 `lastPruneAt` 重置为零值。actor 的下一次 flush tick 会计算 `shouldPrune := lastPruneAt.IsZero() || now.Sub(lastPruneAt) >= time.Hour`,所以初始化后的**首个 scheduled flush**会进入 prune 分支;具体等待时长等于实际 `FlushInterval`,不能在本方案中假设为固定秒数。 + +该分支会: + +1. 调用 `pruneHoursBucket()`;当前实现对所有顶层小时 bucket 执行 `ForEach`,收集过期 bucket 后删除。 +2. 调用 `pruneRequestsBucket()`;它只扫描并删除过期 request key 前缀。 +3. 调用 `retainedAPIKeyState(hours, requests)`;该函数遍历全部保留小时聚合维度,并遍历、JSON 解码全部保留 request records,重建保留的 API Key 引用集合与 ciphertext map。 +4. 根据该集合删除失效 label,并把新的 ciphertext/label 状态发布回 actor。 + +因此,除启动时的重复扫描外,首次 flush 以及其后满足一小时间隔的 prune 都会对保留 request records 再做一次全量解码遍历。 + +## 2. 必须保留的语义与约束 + +优化不能简单删掉 request 扫描: + +- API Key 引用由 `APIKeyGeneration` 和 `APIKeyHash` 共同组成;generation 记录在 `meta` 中,label 也存于 `meta`。 +- usage 记录路径会把启用 crypto 时的 API Key 转为 ciphertext,并把 ciphertext 随 `RequestDetail` 写入 `requests`。当前启动与 prune 正是从 request records 重建 `a.apiKeyCiphertexts`。 +- 当前 label 保留逻辑使用小时聚合和逐请求记录引用的并集。只从 `hours` 推断保留引用会改变现有语义,除非先用测试证明两者在所有兼容版本、迁移和异常恢复场景中严格等价。 +- generation 引用校验是损坏/不兼容数据的保护。若从普通启动移除全库校验,必须保留 schema 升级、备份 restore 和显式完整性检查中的完整校验路径。 +- `reconfigure`、backup、restore、store lease 和 `sync_on_record` 的现有行为均依赖 actor 的同步持久化边界,不能为了加速启动而发布半初始化状态或伪造写入成功。 + +## 3. 推荐修复:先做同步存储层去重与索引化 + +先减少明确存在的 I/O 和解码,而不是先改变宿主生命周期。这样可降低常规启动与周期 prune 成本,同时不假定 CPA 接受异步 `register()`。 + +### 3.1 消除同一次启动中的重复 generation 校验 + +将 API Key generation 引用校验拆为两个明确语义: + +- **迁移/导入完整校验**:当数据库从需要重写 API Key 引用的旧 schema 升级、执行 restore 后验证,或管理员显式请求完整性检查时,保留一次覆盖 `hours` 和 `requests` 的完整校验。 +- **常规 reopen**:已处于当前 schema 的本地数据库不得在 `migrateAPIKeyCryptoSchema()` 与紧随其后的 `reload()` 各进行一次完整校验。实现可以将本次初始化已经完成的验证结果传给 reload,或让 migration 函数在没有 schema 工作时不再做验证;两者择一,但必须保证常规 reopen 的完整 request 校验至多一次,最好结合 3.2 变为零次。 + +不要使用静默忽略错误的方式替代校验。若选择跳过常规 reopen 的完整校验,必须把完整校验暴露为可诊断、可运行且可测试的维护/restore 路径,并记录为什么该数据库被视为已完成迁移。 + +### 3.2 持久化 API Key catalog,替代常规 request 全表重建 + +新增 versioned `meta` catalog,例如以 `generation + hash` 为 key,保存当前行为所需的最小状态:ciphertext、是否仍被小时聚合引用、是否仍被 request 明细引用,以及迁移版本或完整性状态。名称和编码在实现阶段确定。 + +写入与删除必须与原始 bucket 更新在同一 bbolt 写事务中: + +- flush 写入小时聚合和 pending requests 时,更新对应引用及最新 ciphertext。 +- prune 删除过期小时 bucket 或 request record 时,基于即将删除的 key/value 更新相应来源的计数或引用状态。只解码被删除的过期记录;不得重新扫描全部未过期 records。 +- 当两个来源都不再引用某个 ref 时,再清理 catalog 中的 ciphertext,并按与当前 `retainedAPIKeyState()` 相同的并集规则清理 `apiKeyLabelsKey`。 + +对已有数据库,不能假设从最新少量 request 倒序就能找到所有仍保留的 API Key:长期未使用但仍在保留期内的 key 是合法情况。首次引入 catalog 时应采用以下两种明确策略之一: + +1. **同步一次性 backfill**:完整扫描当前保留 hours 与 requests,原子写入 catalog;首次升级仍可能慢,但之后的正常重启与 prune 不再扫描全部 request records。必须提供阶段日志和测试。 +2. **受控后台 backfill**:仅在确认 CPA 对 readiness、usage 和失败状态的契约后使用。backfill 完成前不得根据不完整 catalog 清理 label/ciphertext,也不得把数据称为 ready。 + +推荐先实现策略 1,因为它不改变注册/错误语义,风险最小。 + +### 3.3 避免初始化后立即重复 prune + +`initialize()` 已按当前 cutoff 清理 hours 和 requests。成功完成 reload 后,可将 `lastPruneAt` 设为该初始化使用的时间,而不是保留零值,避免首个 scheduled flush 无条件重复一次 prune。 + +该改动的前提是初始化成功执行了与 flush 等价的 retention/catalog 清理;如果初始化失败、配置在中间变更或 catalog backfill 未完成,则必须保留零值或显式标记为需要 prune。 + +### 3.4 对 hours prune 的谨慎优化 + +`pruneHoursBucket()` 当前遍历全部顶层 hour buckets。若 hour key 的编码和 bbolt 排序已由单元测试证明为按时间单调递增,可改为 cursor 从最早 hour 走到 cutoff 后立即停止,以避免检查保留期内的小时 bucket。 + +此优化在实施前必须加入包含负/正时间边界的排序测试;没有该证明时,不应仅凭 `encodeInt64` 的名称假设可安全 early break。 + +## 4. 阶段日志与可验证的性能目标 + +在修改前后都应对以下阶段采集结构化日志/metrics:`open_db`、`prune_hours`、`prune_requests`、`migration`、`validate_api_key_refs`、`load_hours`、`load_requests`、`catalog_backfill` 和总初始化耗时。至少记录 duration、bucket/record count、是否为首次 catalog backfill、schema version 与结果;不得记录明文 API Key、ciphertext、请求正文或凭据。 + +不要承诺固定的 200ms 或 50ms 启动时间:当前仓库没有可复现的 1.8 GiB 基准数据库、宿主计时和存储硬件数据。应采用下列可验收目标: + +1. catalog 已存在且数据库 schema 已稳定的普通 reopen,对未过期 `requestsBucket` 的完整 JSON 解码遍历次数为 **0**;允许仅扫描过期 key 前缀和实际写入/删除的 records。 +2. 同一合成 fixture 上,记录修复前后 `register()` 返回时间与各阶段耗时,报告 median 和 p95;不将未测得的绝对时延写入 release 承诺。 +3. 首次 catalog backfill 单独计时,且完成后第二次 reopen 满足目标 1。 +4. 加入数据库大小、request 数量、hours 维度数、保留期和冷/热缓存状态,以便结果可解释。 + +## 5. 异步 readiness:后续增强,非本期前提 + +Issue #80 建议让 `register()` 快速返回、后台加载数据库、管理接口返回 503。该方向有价值,但在本仓库中尚不能证明 CPA 的 plugin RPC 协议允许注册成功后再异步发布依赖 store 的能力,也不能证明宿主会如何重试 usage 或处理 503。 + +在实施前必须针对目标 CPA/SDK 版本验证: + +- `register()` 返回成功后,宿主是否允许插件仍处于 starting; +- usage 回调和管理路由的并发/重试/超时契约; +- reconfigure、shutdown、restore 与后台打开同一 bbolt 路径时的句柄和 lease 规则; +- 管理 API 的路由注册时点,以及 503 是否会被 dashboard/宿主正确呈现。 + +只有通过这些验证后,才考虑带 generation 和取消机制的 `starting -> ready | failed` 状态机。初始化期间 usage 的策略必须明确为有界队列或拒绝并返回可识别错误;绝不能在尚未持久化时返回成功。即使采用异步 readiness,3.1 至 3.3 的存储层优化仍然需要保留,因为后台全表扫描会继续消耗 I/O 并延迟 ready。 + +## 6. 实施顺序 + +1. 为现有路径加入阶段计时和扫描计数,建立小库、合成大库与升级 fixture 的基线。 +2. 编写 catalog 编码、事务更新与完整 backfill;先保持 actor 生命周期同步。 +3. 调整 normal reopen 的 generation 校验策略,保留升级、restore 和显式完整性检查的全量验证。 +4. 用 catalog 替换 `reload()` 与周期 prune 中对全部未过期 request records 的重建扫描。 +5. 仅在排序测试通过后优化 `pruneHoursBucket()`,并让成功初始化抑制首次无条件周期 prune。 +6. 完成兼容/性能验证后,再单独设计和评审异步 readiness。 + +## 7. 测试与验收 + +### 功能与兼容性 + +- 旧 schema 升级、当前 schema reopen、空库、损坏 generation 引用、缺失/损坏 catalog、backup/restore、reconfigure、shutdown 与 store handover。 +- 多 generation、相同 hash 的不同 generation、label 存在/过期、ciphertext 仅来自历史 request、小时与 request 引用不一致的 fixture。 +- request 或 hour prune 后 catalog、label、ciphertext 与当前 `retainedAPIKeyState()` 的结果完全一致。 +- `sync_on_record`、Query、RequestDetails、成本计算、导出和 dashboard 的既有结果不回归。 + +### 扫描与性能 + +- 对预填充大量未过期 request 的普通 reopen,使用测试 hook、受控 wrapper 或阶段计数断言:不调用全量 request `ForEach`/全量 RequestDetail JSON 解码。 +- 对首次 catalog backfill,断言完整扫描只发生一次;关闭再打开后不重复。 +- 对周期 flush,断言无写入但到 prune 时不会扫描所有未过期 request records;只处理过期前缀和必要 catalog 更新。 +- 跑完整 Go 测试套件,并在可用的 CPA 集成环境中记录 Issue 复现步骤的启动阶段日志。 + +### 已执行验证与发布前验收标准 + +- 修复后普通 reopen 的 request 全量解码遍历为零,首次 backfill/完整性检查例外且明确可观察。 +- API Key labels、ciphertexts 和 generation 引用的保留语义与当前实现等价。 +- 没有遗留 bbolt 句柄、lease、数据竞争、伪成功 usage 或 schema 兼容性破坏。 +- 已执行 `go test ./internal/plugin -count=1`、针对性 catalog/prune/API Key 测试和 `go test ./... -count=1`;未验证的 CPA 异步行为不作为已实现事实发布。 + +## 8. 已实施范围与剩余工作 + +本次实际修改了 `internal/plugin/persistence.go` 与 `internal/plugin/persistence_test.go`:新增 schema v10 的 `api_key_catalog` 元数据,旧 schema/缺失 catalog 首次打开时完整 backfill;稳定 reopen 从 catalog 恢复 API Key ciphertext,不再对保留 `requestsBucket` 全量 JSON 解码;周期 prune 只解码即将删除的过期 request,并增量维护 hours/request 引用计数;保留 restore 路径的完整校验。未修改宿主 RPC 或异步 readiness。 diff --git a/internal/plugin/cost.go b/internal/plugin/cost.go index 744a5cb..adc987f 100644 --- a/internal/plugin/cost.go +++ b/internal/plugin/cost.go @@ -13,6 +13,8 @@ import ( // EstimatedCost is calculated from one persisted request using the current model price. type EstimatedCost struct { + PriceTimeTier string `json:"price_time_tier,omitempty"` + PriceTimeZone string `json:"price_time_zone,omitempty"` Priced bool `json:"priced"` Source string `json:"source,omitempty"` AccountingMode string `json:"accounting_mode,omitempty"` @@ -119,6 +121,19 @@ type modelPriceResolver struct { // newModelPriceResolver builds the normalized fallback index once per price-book snapshot. func newModelPriceResolver(prices map[string]ModelPrice, settings PriceSyncSettings) modelPriceResolver { + prices = cloneModelPrices(prices) + for name, price := range prices { + if len(price.TimeTiers) > 0 { + zone := price.TimeZone + if zone == "" { + zone = "UTC" + } + if zone != "Local" { + price.timeLocation, _ = time.LoadLocation(zone) + } + prices[name] = price + } + } resolver := modelPriceResolver{exact: prices} normalizedSettings, err := normalizePriceSyncSettings(settings) if err != nil { @@ -438,16 +453,25 @@ func estimateRequestCostWithResolver(request RequestDetail, resolver modelPriceR contextTiers = schedule.ContextTiers priceServiceTier = serviceTier } + timeTier, timeZone := "", "" + if tier, ok := selectTimePriceTier(price, request.Time); ok { + rates = tier.TokenRates + timeTier = tier.Name + timeZone = price.TimeZone + } var selectedThreshold uint64 for _, tier := range contextTiers { if contextTokens > tier.Threshold && tier.Threshold >= selectedThreshold { rates = tier.tokenRates() + timeTier, timeZone = "", "" selectedThreshold = tier.Threshold } } result := EstimatedCost{ Priced: true, + PriceTimeTier: timeTier, + PriceTimeZone: timeZone, Source: price.Source, AccountingMode: mode, PriceServiceTier: priceServiceTier, diff --git a/internal/plugin/dashboard.go b/internal/plugin/dashboard.go index 0a346d2..2f4ad46 100644 --- a/internal/plugin/dashboard.go +++ b/internal/plugin/dashboard.go @@ -248,7 +248,7 @@ html{background:#151412;color-scheme:dark} html:not([data-theme]){background:#faf9f5;color-scheme:light} html[data-theme='white']{background:#fff;color-scheme:light} html[data-theme='dark']{background:#151412;color-scheme:dark} - +.time-pricing{grid-column:1/-1;padding:10px;border-top:1px solid var(--border-color)}.time-pricing summary{cursor:pointer}.time-pricing label{display:flex;flex-direction:column;gap:4px;min-width:0}.time-tier-row{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;padding:12px 0;border-bottom:1px solid var(--border-color)}.time-pricing input{width:100%;box-sizing:border-box}.time-pricing p{margin:8px 0}.time-pricing>.mini-button{margin-top:8px}@media(max-width:650px){.time-tier-row{grid-template-columns:repeat(2,minmax(100px,1fr))}}