diff --git a/.github/workflows/prices.yml b/.github/workflows/prices.yml new file mode 100644 index 0000000..9c81f0d --- /dev/null +++ b/.github/workflows/prices.yml @@ -0,0 +1,84 @@ +name: prices + +# Keeps the reference price table and the holiday calendar current WITHOUT +# touching the code by hand: +# +# - the generator rewrites src/host/prices.js and src/host/holidays.js from +# the opencode Zen page, the DeepSeek pricing page and holiday-cn; +# - if anything changed, a branch is opened and a pull request is created. +# Nothing is merged automatically: a price change is a claim about money and +# deserves a human diff, exactly like a code change. +# +# Weekly rather than daily because both sources are list prices that move on the +# scale of weeks, and because a quiet week should produce no pull request at all +# (the "no change" path exits before pushing). + +on: + schedule: + # Mondays 01:17 UTC = 09:17 Beijing. + - cron: '17 1 * * 1' + workflow_dispatch: {} + +permissions: + contents: write + pull-requests: write + +concurrency: + group: prices + cancel-in-progress: false + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci + + - name: Regenerate the price table and holiday calendar + run: node scripts/gen-prices.mjs + + - name: Rebuild and run the gates + # A generator change that breaks a price has to fail here, not in a + # user's dashboard. + run: | + npm run build + npm test + + - name: Open a pull request when the data moved + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if git diff --quiet -- src/host/prices.js src/host/holidays.js; then + echo "price table and holiday calendar are unchanged" + exit 0 + fi + branch="automation/prices" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git push origin --delete "$branch" 2>/dev/null || true + git checkout -b "$branch" + git add src/host/prices.js src/host/holidays.js + git commit -m "chore(prices): refresh reference prices and holiday calendar" + git push -u origin "$branch" + gh pr create \ + --title "chore(prices): refresh reference prices and holiday calendar" \ + --body "Automated refresh of \`src/host/prices.js\` and \`src/host/holidays.js\`. + + Sources: opencode Zen (\`https://opencode.ai/v2/docs/console/models/\`), + DeepSeek official pricing (\`https://api-docs.deepseek.com/quick_start/pricing/\`), + holiday-cn (\`https://github.com/NateScarlet/holiday-cn\`). + + Review the diff as a price change: a moved number is a change to every + reference cost the plugin reports until the next refresh. The build and + the data gates (\`tests/price-data.test.js\`) already ran on this branch." \ + --base main --head "$branch" --label automation diff --git a/.gitignore b/.gitignore index b791200..4cb3b40 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,13 @@ node_modules/ # build output lib/ +# …but scripts/lib is source: the `lib/` rule above matches every directory +# named lib, so without this the holiday-span helper stays untracked and CI +# cannot import it. +!/scripts/lib/ + +# generator snapshot cache (scripts/gen-prices.mjs --offline) +.cache/ # logs *.log @@ -14,3 +21,6 @@ npm-debug.log* # editor .idea/ .vscode/ + +# packaged tarballs (npm pack) +*.tgz diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d9c87..b689925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,63 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 83 → **87**。新增 `tests/client-dock.test.js`:dock 注册契约(slot 名 / id / order)、 不再注册旧 `stats` 行 ID、`dsh.client.inject` 必须声明 slot 归属模块、pill 的币种符号 必须来自宿主动态解析(防止 USD 视图印出 ¥)。 +## [0.5.0] - 2026-10-31 + +基于 0.4.0(输入区成本 pill)发布;`0.4.1`–`0.4.8` 以及本地标注为 `0.4.0` 的定价改造都是 +开发迭代(未单独发布),逐条明细在文末保留。 + +### Added + +- **参考价目表与逐级匹配**:内置 opencode Zen + DeepSeek 价目(`src/host/prices.js`,由 + `scripts/gen-prices.mjs` 生成),查询走 `resolvePrice` 的唯一阶梯:用户自定义价 → 用户确认的匹配 → + 按名称精确 → 变体(`-free` / `-fast` / `-1017`)→ 模糊;四态由宿主给出,客户端不再二次解释。 +- **峰谷计价**:北京时间 `09:00–12:00`、`14:00–18:00` 为峰时,排除中国大陆法定节假日, + 节假表随用量数据年份顺延;未公布年份显式标注而不是静默丢表。 +- **折叠结果落盘缓存**(`src/host/fold-cache.js`):热启动 **0.08 s**(此前每次重启都要重读 + 404 MB / 1.35 GB 解压,仅解压就 12.5 s)。签名 = 折叠规则版本 + 节假表版本,缓存损坏一律降级全量扫。 +- **图表悬浮卡也显示参考成本**:每日/每小时趋势图(柱状与折线)与年度热力图的提示卡,现在 + 在输入/输出/合计之外给出该时段的参考成本(取自宿主桶里的 `cost`,已按展示币种换算); + 三处卡片抽成同一个 `TipRows`,避免"给一处加字段、另几处漏"。读屏走同一份信息: + `barAria` / `hmTitle` 一并带上 `{cost}`。 +- **悬浮卡里只有「有图例的那几行」带色块**:色块的语义是「这一行对应图例里的哪个颜色」, + 所以 `输入` / `输出` / `总 Token` 有,而 `参考成本` / `调用次数` 没有——它们各留一个 + 10px 无底色的占位(`.tsd-tooltip-dot` 本身不设背景),文字仍与上方对齐。 +- **原生 typert Remote 通道**(`src/host/typert.js`):注册一份原生贡献并提供接收方服务, + `connection.fetch` 的 `/api/usage/*` 常驻兜底;实际通道随每次响应返回(`channel`)。 +- **仪表盘**:按模型明细(可排序)、定价匹配弹窗(四态可改可撤)、年度热力图、币种切换、 + 每日/每小时趋势、未定价调用计数。 +- `prices.yml` 定时重生成价目,`release.yml` 校验版本并创建 Release。 +- README 演示图更新为 0.5.0 仪表盘截图;新增 `screenshots.json`,插件市场按仓库里的声明取图。 + +### Changed + +- **与上游 0.4.0 合并**:输入区成本 pill(`conversation.composer.dock`,id `usage-cost`)原样保留, + 与重写后的 `apply` / 主题变量共存;`tests/client-dock.test.js` 继续守它的注册契约。 +- **全量统计改为第一次查询才启动**(`startBackfillOnDemand`):此前在激活时开扫,缓存加载 + (暖 0.78 s / 冷 ~12 s)总在用户打开面板之前完成,加载态永远看不见。代价是"读请求触发写", + 权衡与边界写在 `docs/native-transport.md` §3。 +- **加载态如实反映进度**:统计未完成期间整段骨架屏 + 确定进度条(宽度 = `done/total`)+ + `正在统计 n/N 个会话…`,不再先给半份会往上爬的数字;尊重 `prefers-reduced-motion`。 +- 设置写入全部走 `.volatile()` 字段,重启后自定义价与已确认匹配不再丢失。 + +### Fixed + +- 参考成本为 0 的模型被显示成「未定价」(表格曾自行重算宿主已给出的判定)。 +- 加载骨架比真实卡片少一格(写死 5 格,实际 6 张)。 +- 统计未完成时落到「暂无数据」。 +- **`logSink(...).warn is not a function` 导致进入用量统计即崩溃**(`logSink` 返回的是函数)。 +- `backfill` 计数出现 `{status: done, total: 258, done: 0}`("扫完了"其实读作"一条没扫")。 +- 部分会话列表触发的 `prune()` 误删 257/258 条缓存,导致每次启动重折全量。 +- 法定节假日被按峰时计价、`-free` 行被按付费孪生计价、变体名被判「未匹配」。 +- 节假日历生成物把维护者本机路径写进了 `coverage.basis`(随包发布、且每周 CI 重生成会来回翻转): + 现在只保留 `session store` / `calendar default` 两个与环境无关的值,并加门。 + +### Tests + +`npm test` 224 项(宿主行为 + 客户端槽位/加载态/定价接线门);`tests/dialog.test.cjs` 75 项(构建产物); +`tests/client-dock.test.js`(0.4.0 输入区 pill)与 `tests/holiday-years.test.js` 的生成物路径门一并保留; +`npm run verify:typert`(官方 loader 校验 4 个端点)与 `npm run verify:remote`(7 项)为原生契约门。 + ## [0.3.0] - 2026-10-19 @@ -197,3 +254,325 @@ Token 用量统计插件的重要版本:内部架构重构(host 拆分为模 - 无已知安全问题;价格等配置仅经本地 loopback RPC/官方 settings 传输。 [0.2.0]: https://github.com/bennett-dsh/dsh-plugin-usage/compare/v0.1.4...v0.2.0 + +--- + +## 附录:0.5.0 之前的本地迭代(未单独发布) + +这一节是开发过程记录,不属于任何一次发布说明。 + +##### 迭代明细(本地 0.4.x 迭代,均未单独发布) + + +#### 0.4.8 · 2026-10-31 + +##### Fixed +- **按需启动的全量扫描补上 rejection 兜底**(`startBackfillOnDemand`):`void runBackfill(...)` + 此前没有 `.catch`,而 `runBackfill` 自己的 `try` 要在 `ctx.get('sessionPersistence')` 之后才开始 —— + 上下文探测一旦抛错,就变成未处理的 rejection,被宿主判为致命(与 0.4.7 修的 `logSink` 崩溃同一类, + 且同样由"打开用量统计"这一读请求触发)。现在 `.catch` 里落 `status='done'` 并记一行日志。 + 门:`tests/lazy-backfill.test.js` 新增「`ctx.get` 抛错也不能让读请求弄死进程」(变异验证: + 去掉 `.catch` 立即变红)。 + +##### Docs +- `docs/native-transport.md`:新增「为什么一次查询会启动一轮全量扫描(读请求带写副作用)」—— + 把这条**明知偏离原生**的权衡连代价与边界写清楚(幂等 / 只写派生物缓存 / 后台不可能弄死宿主 / + 接线门防回退),免得上游 review 当成性能回退;开头改为基线小表(rc.3 → rc.4)。 +- `README.md`:新增「装进本机 profile 时的一条硬规矩」——用官方插件管理器、同版本 `file:` 依赖 + 会被 pnpm 判为已满足(`ambiguous-install`),日常替换请 `pnpm install --force`,**版本号只在 + 值得发版时动**;装完必须核对产物而不是只看 `applied`。 +- 三个接线门文件补了抬头声明:它们是**源码级接线门**(守"两处是否仍指向同一源头"), + 与行为门分工不同。 + +#### 0.4.7 · 2026-10-31 + +##### Fixed +- **进入「用量统计」直接崩溃(`fatal load failure: TypeError: logSink(...).warn is not a function`)**。 + `logSink(ctx)` 返回的是**函数** `(message, level?)`,而两处把它当 logger 对象用了: + `persist()` 里的 `const logger = logSink(ctx)` + `logger.warn(...)`,以及按需启动回填时传的 + `log: (m) => logSink(ctx).warn(m)`。后者在 0.4.6 把回填搬到查询路径后立刻暴露:扫描收尾写日志 + →抛错→ 进入 `catch` 又用同一个坏 sink 写日志 → 异常逃出 `runBackfill` → 宿主把未处理的 rejection + 当致命错误,进程退出。 + 现在两处都按函数契约调用;并且 `scan.js` 的所有日志经过 `safeLog()`,**日志坏了最多丢一行, + 绝不会再弄死宿主**。 +- 门(`tests/lazy-backfill.test.js` 新增 2 条):日志 sink 抛异常时扫描仍落到 `done`; + `logSink` 只能以函数形式调用(禁止 `.warn/.error`、禁止别名成 `logger` 对象)。 + 变异验证:把 `.warn` 写法加回去立即变红。 + +#### 0.4.6 · 2026-10-31 + +##### Changed +- **全量统计改为按需启动:第一次查询才开扫**(`startBackfillOnDemand`,`src/host/scan.js`; + 接线在 `handlers.set('query')`)。此前在 `apply` 里就开扫,暖缓存 0.78 s(冷缓存 ~12 s)的 + "缓存加载"窗口发生在用户打开面板之前 —— 代码里的加载态永远看不见,重启多少次都一样。 + 现在面板打开的那一刻就是统计开始的那一刻:骨架 + `正在统计 n/N 个会话…` + 确定进度条, + 暖冷缓存都可见。`state.backfill` 增加 `started` 闩(`status` 会被调用方先置 running, + 不能拿它当"只跑一次"的守卫)。 +- 客户端轮询节奏改为**先快后慢** 600 ms → 1.5 s → 3 s:固定 3 s 会让 0.8 s 就结束的暖缓存 + 窗口"躺 3 秒再跳一格",比没有进度更糟。 + +##### Tests +- 新增 `tests/lazy-backfill.test.js`(4 条):首次调用置 running 且幂等;无持久化时立即 done + (面板不会永远转);空库跑完落到 done 且 `done === total`;**接线门**——`query` 处理器必须调用 + `startBackfillOnDemand`,且 `apply` 不得再直接 `runBackfill`(变异验证:删掉调用点即变红)。 + +#### 0.4.5 · 2026-10-31 + +##### Changed +- **从 DSH 启动到统计算完,整段都是加载态**(`src/client/index.js` 的仪表盘渲染): + 此前只在"数据为空"时给骨架,半份结果会先显示出来再一格一格往上爬 —— 既像"你只有这么多 + 用量",也正是「数字一点点往上爬」那类抱怨的来源。现在只要 `backfill.status === 'running'` + 就渲染骨架屏,数据区(`!computing`)与空态(`!computing`)都等算完才出现。 + 统计期间仍保留:顶部确定进度条(宽度 = done/total)、头部「正在统计 n/N 个会话…」、 + 旋转的刷新图标;屏幕阅读器只播报头部这一处(骨架屏的 live 区域在统计期间置 `aria-live="off"`, + 避免重复朗读)。 + 门:`tests/client-slots.test.js` 第 2 条同步加强(断言骨架覆盖整段 computing、数据区必须带 + `!computing`;变异验证:把条件退回只覆盖 `!ready` 立即变红)。 + +#### 0.4.4 · 2026-10-31 + +##### Added +- **「统计没算完」本身就是一个加载态**。此前只有"请求在飞"(busy)才有反馈, + 而全量统计(258 个会话)远比一次请求长:冷启动那几秒页面看起来是静止的, + 数据为空时还会显示「暂无数据」—— 把"还在算"说成了"没有用量"。 + 现在 `backfill.status === 'running'` 即 `computing`: + - 顶部进度条改**确定进度**,宽度 = 宿主返回的 `done/total`(知道分母就不装忙); + - 头部副标题换成 `正在统计 {done}/{total} 个会话…`(同位置同行高,算完不跳版); + - 刷新图标在统计期间也保持旋转; + - `computing && isEmpty` 时显示骨架屏,**不再**落到「暂无数据」; + - `role="status"` 让屏幕阅读器也能读到进度。 + 门:`tests/client-slots.test.js` 第 2 条(变异验证:去掉 `computing && isEmpty` 即变红)。 + +#### 0.4.3 · 2026-10-31 + +##### Fixed +- **骨架屏比真实卡片少一格**(`LoadingState`):占位块写死 `[0,1,2,3,4]` 五张,而「模型占比」 + 让统计卡变成 **6 张** —— 加载态少一格,数据到达时整行重排。 + 现在张数从 `SUMMARY_CARD_SLOTS` 派生,`SummaryCards` 与骨架屏共用同一份 key 顺序; + 新增门 `tests/client-slots.test.js` 逐字比对两边的 key 列表,并禁止骨架再出现写死的索引区间 + (变异验证:把骨架改回 `[0..4]` 该门立即变红)。 + +#### 0.4.2 · 2026-10-30 + +##### Changed (基线抬到 rc.4,无行为变化) +- 桌面版实例当日升到 **0.2.1-rc.4**,按「原生会动:先对账再抬基线」复核两个契约门: + `verify:typert` 报 `OK: the official loader accepts the contribution (4 endpoints, namespace dsh-plugin-usage)`, + `verify:remote` 7/7 全绿 → `dsh.verifiedAgainst` 由 `0.2.1-rc.3` 抬到 `0.2.1-rc.4`; + `engines.dsh` 保持 `>=0.2.1-rc.3 <0.3.0`(rc.3 也验证过,不缩小可安装范围)。 +- 卫生门里「基线必须出现在下限字符串里」这条过窄判据改成**区间判定**(基线必须落在 `engines.dsh` + 区间内,允许比下限新)—— 否则"验证了更新的构建"这个正确动作会被自己的门判红。 +- 为什么要有 0.4.2:0.4.1 在 22:06 已装进桌面版,之后又改了 manifest 基线;同版本号 + 同 `file:` 路径时 + pnpm 判定"无需新增"(返回 `ambiguous-install`),只有换版本号才能让内容真正替换。 + +#### 0.4.1 · 2026-10-30 + +##### Changed (基线抬到 rc.4) +- 桌面版实例当日升到 **0.2.1-rc.4**,按「原生会动:先对账再抬基线」复核两个契约门: + `verify:typert` 报 `OK: the official loader accepts the contribution (4 endpoints, namespace dsh-plugin-usage)`, + `verify:remote` 7/7 全绿 → `dsh.verifiedAgainst` 由 `0.2.1-rc.3` 抬到 `0.2.1-rc.4`; + `engines.dsh` 保持 `>=0.2.1-rc.3 <0.3.0`(rc.3 也验证过,不缩小可安装范围)。 + 同时把卫生门里「基线必须出现在下限字符串里」这条过窄判据改成**区间判定**(基线必须在 + `engines.dsh` 区间内,允许比下限新),否则"验证了更新的构建"这个正确动作会被自己的门判红。 + +##### Fixed +- **参考成本为 0 的模型被显示成「未定价」**(`src/client/index.js` 的 `ModelTable`):表格在 `useMemo` + 里自己重算了一次 `unpriced: (tokens > 0) && !(cost > 0)`,把「用户明确把单价填成 0」也判成未定价 —— + 历史记录里因此永远看不到 ¥0.00。现在判定只取宿主结论(`unpriced: r.unpriced === true`), + `¥0.00` 与「未定价」的区别回到唯一来源:查得到价 vs 查不到价。门:`tests/native-hygiene.test.js`。 + +##### Changed +- **加载态做诚实**(`src/client/index.js`): + - 补 `@media (prefers-reduced-motion: reduce)`:关掉骨架屏呼吸、刷新按钮旋转与入场淡入 + (官方客户端同样写法:`plugin-manager.client.js` / `settings-general.client.js`); + - 删掉刷新时整块内容 `opacity: .55` —— 每次轮询闪一下,且暗示"数字在重算";改为顶部 **2px 细进度条** + (绝对定位、不推版面、`aria-hidden`),只在「已有数据、正在刷新」时出现; + - 骨架屏补 `role="status"` + `aria-live="polite"` 与可读文案(`loading` 双语 key), + 屏幕阅读器不再只知道 `aria-busy`。 +- **原生对齐收口**(对照共享库经验第〇条 `exp-native-first`): + - `package.json` 声明 `dsh.verifiedAgainst: "0.2.1-rc.3"`,并把 `engines.dsh` 收紧到 + `>=0.2.1-rc.3 <0.3.0` —— 之前写的是 `>=0.2.0-rc.2`,那是一个**从未验证过**的下限。 + - `docs/native-transport.md` 新增 §3:折叠缓存这处**已知自建旁路**的取证(官方 `dsh-storage` / + `dsh-storage-json` / `dsh-storage-domain`,以及官方 `dsh-session-projection-cache` 的同类先例)、 + 三条可复核的保留理由(派生物需可整目录删;`open()` 急加载而缓存 258 条已 12 MB 且随历史线性增长; + 零运行时依赖)、以及迁移路径。同时删掉该文件 §4 表格里重复的一行。 + - `runBackfill` 的兜底 `catch` 不再静默:失败原因经 `log` 落到宿主日志。 +- **新增原生卫生门 `tests/native-hygiene.test.js`(4 条)**:`verifiedAgainst` 必须存在且与 `engines.dsh` + 下限一致;按模型明细的 `unpriced` 只能取宿主结论(禁止客户端重新派生 `!(cost > 0)`);`src/host/**` 的 + 磁盘写只允许出现在 `fold-cache.js` 与 `scan.js` 的 `last-pass.json`(新写盘必须先写进 §3 的反证); + `settings.update` 的命名空间必须等于 `cordis.patch.yml` 里的行 id。 + +##### Added +- **折叠结果落盘缓存,重启不再全量重扫**(`src/host/fold-cache.js`):索引只在内存里,所以此前每次重启 + 都把全部历史重新读一遍 —— 本机实测 258 个会话、404 MB、解压后 1.35 GB / 57 万行,**仅读取和解压就 + 12.5 s**。现在按会话落成 `/cache/dsh-plugin-usage/fold/.json`,命中条件是后端给的 + `revision` 与条目一致;一个会话一个文件,所以一次扫描只重写变化的会话。 + 实测(258 会话 / 30,186 条记录,日志全程不打开):**热启动 0.08 s**,缓存写回 0.46 s。 + 正确性约束:签名 = `FOLD_CACHE_VERSION` + 节假日历 `capturedAt`(折叠规则或节假表一变即失效); + 记录的 `session` 必须与文件名一致,否则整条当未命中(防止串账);解析失败、签名过期、记录形状不对 + 一律降级为全量扫,绝不抛错。 + 门(`tests/fold-cache.test.js`,10 条):冷折叠与热启动逐字段相等、`open()` 在热启动时必须不被调用、 + revision 变化必重扫、串账记录被拒、缓存损坏/签名过期降级、`cache:false` 保持旧行为、孤儿会话被清理。 + 变异验证:把命中分支改 `if (false)`、或不写条目,对应用例立即变红。 + +##### Changed +- **节假日历的年份范围跟着用量数据走,并向后顺延一年**(`scripts/lib/holiday-years.mjs`): + 原来是 `[今年-1, 今年, 今年+1]` 的固定窗口,与「全部」里早期的会话无关 —— 2024 年的用量根本 + 取不到 2024 的节假日。现在从会话目录里最早/最晚的会话文件取年份:往前留一年余量(文件 + mtime 是**最后**写入时间,最早那次可能更早)、往后**必多备一年**(次年安排 11 月才公布), + 并与「今年」取并集。`--holiday-years` / `--usage-from` / `--usage-to` 可显式覆盖。 + 生成物新增 `coverage: {fromYear, toYear, basis, years}`。 +- **未公布的年份要记下来,不能丢**:原来 `days: []` 的年份直接 `continue`,从表里消失 —— + 与「这一年没有节假日」不可区分,于是该年节假日被按高峰计价且界面上毫无提示。现在记成 + `unpublished: true`,`holidayCoverage()` 会带出去,参考成本的提示里写明覆盖范围与未公布年份。 +- 顺手修掉一条**误报**:原来按「数据是否到 12-31」判断截断,而实际每年最后一个法定假日都在 + 10 月初(国庆),于是 2025、2026 每年都报一条假警告。改成「是否缺当年的国庆(10-01)」。 + +##### Fixed +- **法定节假日被算成高峰价**:折叠时用的判定是 `isPeak(atMs)`(不带日历),而价目显示用的是带日历 + 的那条 —— 同一天(例如 2026-10-01 国庆,周四)账单按高峰计价、界面却标「空闲」。DeepSeek 的规则 + 是「周一至周五 01:00-04:00、06:00-10:00 UTC,**不含中国法定节假日**」,所以窗口内落在节假日的 + 调用被最高多算一倍。 + 修法在根上:`isPeak` 的默认参数改成**真正的日历**(`holidayOf` 移到 `peak.js`,与规则同源), + `pricing.js` 反向复用同一份判定并 re-export,任何调用方漏传参数都不会再静默走「无节假日」。 + 新增两道门:`tests/peak.test.js` 断言默认就认日历,`tests/tier-billing.test.js` 直接钉住 + `peakAt`(折叠实际用的那个函数)。变异验证:把默认改回 `undefined`,两道门同时红。 + +##### Changed +- **真机确认原生通道生效(2026-10-29,work-exp)**:dashboard 的查询/定价请求走 + `POST /api/dsh-plugin-usage/query` → 200,响应里的 `channel` 为 + `{"ok":true,"transport":"remote","reason":"registered 4 endpoints"}`;`/api/usage/*` + 只在自举阶段(面尚未挂载时)出现,随后自动切换。同一轮之前的两次失败分别暴露了 + `ctx.reflect` 取不到、以及激活期一次性探测 `ctx.get('typert')` 的时序问题,都已修掉。 +- **修掉一个"宿主明明有、插件却说没有"的时序缺陷**:`ctx.get('typert')` 只返回**已激活**的服务 + (cordis `Context.get` → `_getImpl` 检查 `impl.fiber.state === 2`),而 typert 注册表在同一 + 组合里可能比本插件晚激活。激活期一次性探测的结果就是 "no typert registry in this composition" + 并永久落到 Fetch。改成 `ctx.inject(['typert'], …)` 声明依赖、就绪时再注册(与 `connection` + 的写法一致)。 +- **通道决策可查**:`registerTypert` 返回 `{ ok, transport, reason }`,随每次 dashboard 响应 + 以 `channel` 字段下发;走 Fetch 时 reason 会写明原因(没有 typert 注册表 / ctx 不能 + provide / provide 抛错 / typert 拒收)。插件 logger 不一定进宿主日志,所以判断不再靠猜。 +- **删掉 `client-source` 端点**(`src/host/client-source.js`):它是动态插件时代"宿主读源码、 + 前端 `new Function` 执行"的遗留,客户端早已改成真正的 bundle,这个端点既没人调用、又把 + 源码文本经 RPC 发出去。换成一个只报端点名的 `contract` 端点 —— 浏览器半靠它在 + `remote.$mount` 时与宿主侧的清单对齐,不再各自维护一份端点列表。 +- **client→host 走原生 typert Remote**(`src/host/typert.js`):注册一份 typert 贡献 + (`package = dsh-plugin-usage`,namespace 与包名相同,参数/结果都是 strict codec)并 + `ctx.reflect.provide` 一个同名接收方服务(gateway 会核对 `typertRemote` 绑定)。浏览器半 + **优先**调 `remote['dsh-plugin-usage']`,失败自动落回 `/api/usage/*` 与 `/usage` 兜底 —— + 两条通道都常驻。注册被拒时会把接收方服务撤回去,不会留下"挂上了但调不到"的半面。 + 旧代码里 `typert.register('usage', endpoints)` 的两参调用是错的形状,已删除。 +- **按原生工程习惯收口(对照 `xshared-lib` 经验库第〇条「原生优先」)**: + - 宿主日志走 `ctx.logger`(`ctx.get('logger')` → `ctx.logger` → `console` 三层兜底), + 不再直接 `console.warn`;挂载失败与端点异常都进宿主日志。 + - **原始错误不再上页面**:`dispatch` 把未处理异常压成稳定码 `internal`,界面按码 + 自己说话(`rpcInternalError`),堆栈只写日志。 + - 设置页导航图标的 DOM 补缺注释补上**官方符号出处**(`settings.section` 只投影 + `{id, order, label}`、`navIcon(id)` 未知 id 回落齿轮),让「官方确实没有」这条 + 反证可核,而不是"我觉得"。 + - 仓库里的门补了两处:`tests/dialog.test.cjs`(驱动构建产物的 70 项 UI 检查)从 + 仓库外挪进 `tests/` 并接进 `npm test`(`pretest` 先构建);新增 + `tests/fold-parity.test.js`,用 `scripts/fold-parity-fixture.mjs` 从官方 + `@deepseek-ai/dsh-token-meter` 录下的 `tokenUsage` 结果对账本插件的 fold。 + +参考成本这块的整理:把「同一个问题有好几处各自回答」的地方收成一处,并按原设计 +补上参考模型的三类来源与 `-free` 的判定规则。删掉的是**第二条政策**和**第二套词表**。 + +##### Fixed + +- **`-fast` / `-1017` 这类变体名被判成「未匹配」**:`deepseek-v4.1-flash-fast`(1,380 次) + 和 `deepseek-v4.1-flash-1017`(471 次)是同一个模型换了后缀,却因为表里没有这一行而 + 按 0 计价 —— 全部历史里 1,865 次未定价基本就是它们。新增第 5 级**变体后缀**: + `-free` / `-exp` / `-fast` / `-latest` / 四位日期,去掉后缀后必须在表里查到基模型, + 命中即按基模型的价估算、标「模糊」。这是封闭的改名规则(后缀表固定 + 基模型必须 + 存在),不是相似度:`deepseek-v4-flash-0731` 没有基模型行,仍然未匹配。 +- **`-free` 一律被算成「模糊」并按付费孪生计价**:孪生规则排在字面命中之前,于是 + 价目表真实发布的 `glm-5.3-flash-free`、`deepseek-v4.1-flash-free` 等 13 行也被 + 判成模糊,促销期的 0 元被换成一个没人付过的付费价。现在**精确优先**:表里真有这 + 一行就是精确、按它自己的价;只有表里没有的变体名才走第 5 级。 +- **「自定义」不是一组参考模型,而是「直接填单价」**:之前把它做成了第三个 id 分组, + 只能选「别的模型」;实际要的是在选择器顶部给一个「自定义」选项,选中后就在这一行 + 填三个数(输入 / 输出 / 缓存读,展示币种、每百万 Token),确定后写进 `prices`, + 按填的价计。金额按当前展示币种换算成美元存储(价目表是美元),负数/非数字会被拒。 + 同一个 key 只留一个答案:手填价顶掉已确认的匹配,确认匹配 / 收回答也会清掉手填价。 +- **未匹配的模型在「定价匹配」里一行都看不见**:待选表按 `r.unpriced` 过滤,而 + propose 的行从来没有这个字段(它是查询那边「某条记录没价」的标记)——于是待选表 + 永远为空,一个都没匹配上的会话反而显示「暂无待匹配的模型」。 +- **价目表的两档价被折成一档之后再发出去**:`propose` 先把行折成「当下这一档」, + 弹窗只能印那一档,另一档凭空消失;而且「当下」是打开弹窗时的钟点,不是会话里的 + 任何时刻。现在宿主把两档一起发(`rates.peak` / `rates.offPeak`)并标明该印哪档, + 另一档挂在同一格的 tooltip 上。 +- **`DEFAULT_PRICES` 那一行 `group/auto-deepseek-v4-1-flash`**:它是 DeepSeek Flash + 旧空闲价的抄件,本身是单档(峰谷对它无效),又排在参考表前面(`how: 'custom'`), + 所以高峰期比参考价低 53%。归一化里补了路由器前缀规则之后,这个 id 由参考表自己 + 回答,这行连同 `ALIAS_PRICES` / `mergePrices` 一起删掉,`prices` 默认改为空。 +- **两套插件内词表**:宿主算出的 `how`(5 个值)在 client 里被再映射成四态,映射 + 写死过一个阶梯里不存在的值。现在 `state`(exact / fuzzy / none / custom)由宿主 + `resolvePrice` 产出,client 只渲染。 +- `query` 的 `pricing.source` 不再写死字符串,改从价目表 `sources` 派生。 + +##### Changed + +- **一处查价**:`resolvePrice` 收下 `matchMap`(`matches`)成为唯一入口, + `index.js` 的 `resolveWithMatches` 包装层删除;store 不再接受 `resolve` / + `costFor` 注入,也不再有「没注入时按精确键查」的第二人格。 +- **一种费率形状**:`ratesOf(price, tier)` 是唯一读价格字段的函数(flat 行 / + DeepSeek 两档行 / 手填的 `inputPerM` 行都归一到 `{input, output, cacheRead, + cacheWrite}`),`tierPriceOf` 删除。价格行以「发布时的样子」流过阶梯,档位只由 + 计费点(`costOf` 的桶、`priceRow` 的展示)决定。 +- 归一化新增两条规则:`.` 与 `-` 等价、去掉路由器加的 `auto-` 前缀。两条都有 + **89 行零冲突**的常驻门(`price-data.test.js`)。 +- **参考模型 id 从一张扁平表变成三组**:`propose` 的 `table.ids` 换成 + `table.groups`(deepseek / zen / custom),每行的 `source` 说明这个价出自哪一类; + 选择器按组显示并带组头,匹配到的行旁边标出来源。 +- 阶梯顺序改为「精确优先」:自定义 → 确认 → 精确 → 别名 → 孪生。 + +#### 0.4.0 · 2026-10-19(本地定价改造,随 0.5.0 发布) + +参考成本的算法版本:从「10 行手抄的价目表 + 精确字符串匹配」换成「按上游定价源 +生成、四级阶梯解析、高峰/空闲分档、未命中的模型可以在界面上确认」。这一版会把 +账算得比 0.3.0 高 —— 因为 0.3.0 少算了,不是这版多算了(见下)。 + +##### Fixed + +- **在售型号根本没进价目表,成本记 0**:旧表 10 行里有 7 行是已下线的型号 id, + 而 `deepseek-flash`、`deepseek/deepseek-v4.1-flash` 这类实际在用的 id 一行都 + 匹配不上。本机 94 个会话实测:宿主的 `costUsage` 投影记 $62.8792,插件记 + $1.8378(2.9%)。价目表改由 `scripts/gen-prices.mjs` 从 opencode Zen 与 + DeepSeek 官方定价生成,生成物随包发布。 +- **拉美价被当成通用价**:旧表 `deepseek-v4.1-flash` 一行 0.14 / 0.56 / **0.04**, + 缓存放大了 13.3 倍;官方在售价是 0.15 / 0.60 / 0.003。 +- **模型多一层前缀就判未定价**:`provider/model` 与裸 `model` 之外没别的匹配, + provider 前缀改了、分隔符换了(`_` / `.`)、大小写不同都算未命中。 +- **弹窗里给未匹配模型选参考模型会报错**(`Cannot convert undefined or null to + object`,行也不变脏):`PricingDialog` 里的 `const [draft, setDraft] = + React.useState({})` 遮蔽了同名的纯函数 `setDraft(prev, key, patch)`,组件内 + `setDraft((prev) => setDraft(prev, ...))` 调的是 setter 自己。 + +##### Added + +- **价目表生成**:`npm run prices`(`scripts/gen-prices.mjs`)抓 opencode Zen + 与 DeepSeek 官方定价,输出 `src/host/prices.js`(89 个型号 + 每个型号的三档价 + 与上下文加成)和 `src/host/holidays.js`(节假日历)。CI 每周跑一次,上游有变化 + 才开 PR。生成物是 `.js` 而不是 `.json`:`tsconfig.json` 的 `rootDir` 是 `src`, + 发布只带 `lib/`,`.json` 到不了用户手里。 +- **四级解析阶梯**(`src/host/pricing.js`):自定义 → 别名 → 孪生(去 provider + 前缀、统一分隔符与大小写;`-free` 免费型号一律归到这里,不参与精确)→ 精确 + 字面命中。**任何一级都不做相似度打分**:字面命中自动采用,0.62–0.90 那一段 + 只提示、不自动确认。 +- **高峰/空闲分档**:DeepSeek 两个在售型号按官方两档价计价,时段在**扫描时**记进 + Token 分桶(`rec.tokens.peak` / `rec.tokens.off`),切换币种不改写分桶。 + 规则按北京时间:周一至周五(不含中国法定节假日)9:00-12:00、14:00-18:00 为高峰。 +- **「定价匹配」弹窗**:参考成本卡右上角的按钮打开。会话内未匹配的模型列在最前, + 可从价目表里挑参考模型;已匹配的单独一张表可逐行清掉;历史匹配默认折叠。四种 + 状态(精确 / 模糊 / 未匹配 / 自定义)常显。**不改动就不落盘**:草稿只活在弹窗里, + 点「确定」才 commit;没改动时「确定」就是关窗。 +- **自定义价优先级最高**:设置里的 `prices`,以及弹窗里确认过的 `matchMap`, + 都排在自动匹配之前。 +- **`unmatched` 与 `pricing` 元信息**:`usage/query` 响应带未命中列表、价目表来源 + 与抓取日期、覆盖率,以及每行的 `how` / `target`;新增 `usage/pricing` 端点 + (`propose` 只读、`commit` 写入)。 + +##### Changed + +- 成本卡脚注拆成三行:常驻的口径说明、高峰/空闲规则,以及只在有未定价调用时出现 + 的提示。原来所有卡片共用一条 warn 色脚注,与事实不符。 diff --git a/README.md b/README.md index ef8cf1d..9689b74 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ DeepSeek Harness 的 **Token 用量统计**插件:按天 / 自定义范围查 - 模型筛选、按模型明细(请求数、输入/输出、缓存、成本、占比) - KPI 网格里的「模型占比」卡:按总 Token 显示前 4 个模型与「其它」 - 参考成本可在 ¥ / $ 之间切换(固定汇率 1 USD = 7 CNY,宿主侧折算) -- 可配置的每百万 Token 价格表(内置 DeepSeek 常见模型默认价) +- 参考价目表由脚本从 opencode Zen 与 DeepSeek 官方定价生成,高峰/空闲按北京时间分档 +- 参考成本卡右上角的按钮打开「定价匹配」:未匹配的模型可直接挑参考模型,已确认的可以清掉;不改动就不落盘 +- 可配置的每百万 Token 价格表(自定义价格优先于自动匹配) - 年度热力图;深/浅色自适应,只用 DSH 主题变量,无第三方图表库 - **输入区成本 pill**:composer 旁显示「今日 ¥x.xx」(今日无调用时不占位),title 带调用次数与 Token;币种跟随设置页的 ¥/$ 选择 @@ -31,9 +33,39 @@ DeepSeek Harness 的 **Token 用量统计**插件:按天 / 自定义范围查 ## 配置 设置 → 插件 → `dsh-plugin-usage` 可改 `currency` 和 `prices`(每百万 Token 价格),写入 profile patch,重启保留。 -`currency` 是默认显示币种;仪表盘上参考成本卡里的 ¥ / $ 切换只影响当前界面(同浏览器记住选择)。 +`currency` 是默认显示币种;仪表盘参考成本卡里的 ¥ / $ 切换只影响当前界面(同浏览器记住选择)。 -价格优先匹配 `provider/model`,其次裸 `model`;未配置的模型标为「未定价」,成本记 0。 +`prices` 默认**为空**,只用来放参考价目表给不了的 id(某条中转、某个私有 id)。 +给一个价目表本来就能查到的 id 写行会盖住参考价 —— 这正是 0.4.0 之前那份手抄默认表 +的问题(一行缓存价是官方 13.3 倍,七行是已下线的型号)。所以默认表不再预置任何行。 + +价格按下面的阶梯解析,**任何一级都不做相似度打分**: + +1. 自定义(设置里的 `prices`,键可以是 `provider/model` 或裸 model) +2. 仪表盘「定价匹配」里确认过的模型(`matchMap`,可以指向价目表 id,也可以指向自定义价) +3. 精确字面命中(大小写、`provider/model` 前缀、`_`/`.`/`-` 分隔符、路由器加的 + `auto-` 前缀都归一再比) +4. 别名(`deepseek-chat`、`deepseek-reasoner`、`deepseek-v4-flash` 等已停用名的去向) +5. 变体后缀(`-free` / `-exp` / `-fast` / `-latest` / 四位日期,如 `-1017`): + 去掉后缀后**能在表里查到基模型**才算命中,按基模型的价估算,标「模糊」 + +**精确优先**:价目表里真有这一行就是「精确」,推断不能盖住事实。所以 `-free` 结尾的 +id 只要表里有这一行就是精确、按它自己的价(促销期就是 0,和宿主实际扣费一致);只有 +表里没有的变体名才落到第 5 级,标成「模糊」。 + +第 5 级是**封闭的改名规则,不是相似度**:后缀表固定,且基模型必须真实存在。 +`deepseek-v4.1-flash-fast`、`deepseek-v4.1-flash-1017` → `deepseek-v4.1-flash`(模糊); +`deepseek-v4-flash-0731` 没有基模型行,仍然「未匹配」,不会被塞到最像的那一行。 + +参考模型候选按来源分两组:**DeepSeek 官方**、**opencode Zen**。列表顶部另有一个 +**自定义** 选项 —— 它不是一组参考模型,而是「直接填单价」:选中后在行内填三个数 +(输入 / 输出 / 缓存读,单位就是表格的展示币种、每百万 Token),确定后写进 `prices`, +这一行的参考价就按你填的算,两档(高峰/空闲)同价。匹配到的行会在旁边标出来源。 + +五级都不中的模型在「定价匹配」里标为「未匹配」(排在待选表最前面),成本记 0; +选好参考模型点「确定」后写进 `matchMap`。DeepSeek 的两个型号按高峰/空闲两档计价: +**北京时间周一至周五(不含中国法定节假日)9:00-12:00、14:00-18:00 为高峰时段; +其余时段,包括周末及中国法定节假日全天均为空闲时段。** ## 数据来源与口径 @@ -42,32 +74,92 @@ DeepSeek Harness 的 **Token 用量统计**插件:按天 / 自定义范围查 折叠 DSH 会话日志中已持久化的用量事件,不自己记日志: - 读取走官方接口:活跃会话 `snapshotEvents()`,历史会话 `sessionPersistence.open/read`,按 `revision` 跳过未变日志 +- 折叠结果落盘缓存(`~/.dsh/cache/dsh-plugin-usage/fold/<会话>.json`,可用 `DSH_HOME` 改根目录): + 一个会话一个文件,命中条件是该会话的 `revision` 与缓存里的一致,所以追加过的日志必然未命中重扫。 + 签名里带 `FOLD_CACHE_VERSION` 与节假日历的 `capturedAt` —— 折叠规则或节假表一变,旧条目自动失效 + (缓存的 `tokens.peak/off` 只能由产生它的那份日历解释)。缓存坏了/读不了就当未命中,退回全量扫。 + 实测:258 个会话、30k 条记录,热启动 0.08 s;冷启动仅读取+解压就要 12.5 s - 计费对齐官方 `tokenUsage` 投影:同一 `(turn, step)` 替换;`llm/retry-started` 后的重试累加;`assistant/attempt` 的 stream 用量也计入 - fork 会话按 `inheritedEventCount` 跳过父会话前缀,不重复计费 -- 费用 = Σ(各 Token 分桶 × 对应单价) / 1e6 +- 费用 = Σ(各 Token 分桶 × 对应单价) / 1e6;DeepSeek 的分桶在扫描时就按当时的时段 + 记成高峰/空闲,切换币种不改写分桶 - 价目表以**美元 / 百万 Token** 为基准(与 DeepSeek 官方价一致);显示 `¥` 时按固定 7:1 折算,价目表本身不改写 +- 价目表与节假日历由 `scripts/gen-prices.mjs` 生成(`npm run prices`),CI 每周跑一次, + 上游有变化才提 PR;生成物是 `.js` 而不是 `.json`,因为发布只带 `lib/` +- 法定节假日取自 [NateScarlet/holiday-cn](https://github.com/NateScarlet/holiday-cn); + 年份范围由**用量数据的日期跨度**决定并向后再顺延一年(`scripts/lib/holiday-years.mjs`): + 取会话目录里最早/最晚的会话文件年份,往前留一年余量、往后多备一年,所以旧会话与来年 + 一月都落在覆盖范围内。`--holiday-years 2024-2027` / `--usage-from` / `--usage-to` 可显式覆盖。 +- 还未公布的年份(国务院一般 11 月公布次年安排)会以 `unpublished: true` 记进表里,不丢掉; + 「参考成本」的提示里会写明覆盖到哪一年、哪年未公布 —— 那种年份只能按周末规则判定, + 窗口内撞上节假日的调用会偏高(不会偏低)。 ## 兼容性 -- DSH `>=0.2.0-rc.2 <0.3.0`(`engines.dsh`),Node ≥ 22 +- DSH `>=0.2.1-rc.3 <0.3.0`(`engines.dsh`;在 `0.2.1-rc.4` 上验证过两个原生契约门),Node ≥ 22 +- 输入区成本 pill 依赖 0.2.1 的 `conversation.composer.dock`:0.2.0 没有这个 slot,注入失败只告警,不影响设置页 - 更早的宿主保留 `Session.events` / `readFrom` 回退路径 ## 开发 ```sh npm install -npm run build # tsc → lib/,再生成 lib/client/index.js -npm test # node --test +npm run build # tsc → lib/,再生成 lib/client/index.js +npm test # 先构建(pretest),再跑单测 + 驱动构建产物的 UI 门 +npm run verify:typert # 用本机 DSH 里的官方 loader 校验 typert 贡献(缺 asar 会明确 SKIPPED) +npm run verify:remote # 用本机真实的 cordis + typert 注册表跑通原生通道(同上) ``` +`npm test` 有三层,外加一个真机契约门,缺一层就会漏掉一类问题: + +| 层 | 覆盖什么 | 失败会长什么样 | +|---|---|---| +| `tests/*.test.js` | 纯函数与宿主行为(fold / pricing / rpc / typert …) | 断言 | +| `tests/dialog.test.cjs` | **构建产物**里的定价弹窗:真实 bundle + 假宿主,75 项 | `CHECK(S) FAILED` 并列出名字 | +| `tests/fold-parity.test.js` | 事件 fold 与官方 `tokenUsage` 投影的**对账** | 四个计数不相等 | +| `npm run verify:remote` | 真实 cordis + 真实 typert 注册表:面注册、接收方服务、以及"激活期一次性探测会失败"这个回归 | `CHECK(S) FAILED` | + +对账用的 fixture 是**录**下来的,不是手写:`scripts/fold-parity-fixture.mjs` 从本机 asar 里取出 +官方 `@deepseek-ai/dsh-token-meter` 的 `usage-projection.js`、原样执行、把结果写进 +`tests/fixtures/token-usage-parity.json`。DSH 升级后重跑它,别手改数字。 + CI 跑 install → build → test → bundle id 契约 → tarball 内容检查;推 `v*` tag 会校验版本、构建测试并创建 GitHub Release。 ``` -src/host/ 宿主:scan / fold / query / settings / aggregate / rpc +src/host/ 宿主:scan / fold / fold-cache / query / state / aggregate / prices / pricing / peak / holidays / settings / rpc / typert src/client/ 设置页「用量统计」仪表盘(纯 JS,样式内联) -scripts/ 构建客户端 bundle 入口 +scripts/ 构建入口 + 价目与节假表生成(gen-prices / holiday-years)+ fold 对账录制 + 两个原生契约校验 +docs/ 与原生 DSH 的距离:L2 补缺的取证(含"读请求触发写"这条权衡)、升级路径与已对齐清单 ``` +浏览器半 → 宿主半的通道:优先走原生 typert Remote(`src/host/typert.js` 注册面、客户端 +`remote.$mount` 挂载),取不到时自动落回 `connection.fetch` 的 `/api/usage/*`。两条都常驻, +所以宿主有没有那个服务都不会让页面瞎掉;实际走了哪条由 dashboard 响应里的 `channel` 字段写明。 + +### 装进本机 profile 时的一条硬规矩 + +用官方插件管理器装(`plugin_manager install_bundle` + `file:` tarball)。但 pnpm 对 +**同版本号、同路径**的 `file:` 依赖有一条让人踩坑的判定链,实测(2026-10-31,pnpm 11.7/11.28): + +| 尝试 | 结果 | +|---|---| +| 同版本、换掉 tarball 内容后再 `install_bundle` | `ambiguous-install`:只有清理没有新增,什么都没装 | +| `pnpm install --force` | `Already up to date`(或仍装旧内容)——它按**锁文件里的 integrity** 从 store 取,不看文件本身变了 | +| 删掉 `node_modules/` 再 `pnpm install` | 依旧 `Already up to date`,目录**不会**被补回来 | + +所以同版本替换**没有**可靠办法,别去试。唯一稳的路径是:**升版本号 → `install_bundle` → 重启**。 + +> ⚠️ **不要在 DSH 运行时手工覆盖 `node_modules/`。** 2026-10-31 02:00 实测:把 tarball 直接 +> 铺进正在运行的 profile 后约 0.8 秒,宿主进程以 `0xC0000005`(STATUS_ACCESS_VIOLATION)退出, +> 前端整块掉线。崩溃日志里没有本插件的栈帧(这不是 JS 异常),但时间点与覆盖完全吻合—— +> 运行中的宿主正在读这些文件,替换它们是不可接受的操作。**先退出 DSH,再改文件;或干脆走管理器 + 重启。** + +版本号只在"值得发版"时动:曾经一天之内 0.4.1→0.4.7 七个补丁,其中三个纯粹是为了绕过上面这条, +等于把工具约束写进了产品版本线。 + +装完必须**核对产物**,不能只看管理器回 `applied`:读 `package.json` 的版本、 +`grep` 一两个本次新增的标识(如 `TipRows`),再决定要不要重启——**只改客户端就刷新页面,改了宿主半才需要重启**。 + > 开发期也可用 `--patch` 从源码加载:`dsh web --patch cordis.yml`(路径见 `cordis.yml`)。 ## License diff --git a/docs/native-transport.md b/docs/native-transport.md new file mode 100644 index 0000000..612cae1 --- /dev/null +++ b/docs/native-transport.md @@ -0,0 +1,188 @@ +# 与原生 DSH 的距离:L2 补缺的取证与升级路径 + +对照 `xshared-lib` 经验库第〇条(原生优先:先取证原生,能引用不重写;补缺要有 +「官方确实没有」的反证;原生结论要可核)。本文件把**仍然自研**的地方、以及**明知偏离原生、 +但权衡后保留**的地方,连证据一起记下来 —— 省得下次重推一遍,也免得把"我觉得"当成反证。 + +取证对象:本机 `E:\FXS\APP\DSH NEXT\resources\app.asar`。引用的符号与行号都来自该分发物里的文件,不是转述。 + +| 项 | 值 | +|---|---| +| 首次取证 | `0.2.1-rc.3` | +| 当前基线 | `0.2.1-rc.4`(实例升级后按第〇条第 5 点重跑过两个契约门:`verify:typert` → 4 端点 OK;`verify:remote` → 7/7) | +| `engines.dsh` | `>=0.2.1-rc.3 <0.3.0` —— rc.3 也验证过,不因抬基线而缩小可安装范围 | + +--- + +## 1. settings 导航图标:用 DOM 打标 + CSS mask(`src/client/index.js` 的 `installSettingsNavIcon`) + +**为什么自研**:官方没有 icon 插口。 + +- 官方设置页把 `settings.section` 的条目投影成三个字段 —— `id` / `order` / `label`: + `@deepseek-ai/dsh-client-ui-settings-general` 的 nav rows `getSnapshot` 里 + `ctx.slots.entries("settings.section").map((e) => ({ id: e.options.id ?? "", order: + e.options.order ?? 0, label: resolveSlotLabel(e.options.label) ?? "" }))`。 +- 图标由该包自己的 `navIcon(id)` 按 id 白名单决定,未列出的 id 回落到设置齿轮 —— + 同包 `function navIcon(id)`,注释原文 "unknown ids fall back to the settings gear"。 + +**所以**:第三方分区只能拿齿轮。当前实现给"自己那一行"加 `data-tsd-nav` 属性,再用 +`::before` + mask 画字形:不改 DOM 结构、不碰 React 管的 ``(替换会让 React 卸载 +时抛 `NotFoundError`)、认不出行就静默退化。 + +**升级路径**:官方一旦给 `settings.section` 的 options 加上 `icon` 字段,这个模块整体 +删除(那时它是可引用的 L0,不该再留补缺)。 + +--- + +## 2. client → host:原生 typert Remote(已接)+ `connection.fetch` 兜底 + +**现状**:`src/host/typert.js` 在宿主有 typert 注册表时,注册一份**原生贡献**并提供一个**接收方 +服务**;`src/client/index.js` 的 `hostCall` **先**走 `remote['dsh-plugin-usage']`,失败再退回 +`connection.fetch` 的 `/api/usage/*` 与 legacy `/usage` 通道。两条通道都常驻,不是二选一。 + +**原生要求逐条(都从分发物里取证,不是转述)**: + +1. 贡献形状:`package` / `face: 'host'` / `schemas[]` / `model.{services,events,objects}` / + `invocations[]`(`@deepseek-ai/dsh-typert-loader` `validateTypertManifest`)。 +2. 每条 invocation 要 `id/service/namespace/method`;参数 `source` 只允许 `json`/`lookup`, + **参数与结果都必须是 strict codec**:`mode === 'strict'` + 非空 `typeSymbol` + `create()`; + 结果还要给 `encode`(`requireStrictCodec`;没有 encode 时 gateway 会跳过自己的结果处理)。 +3. `namespace` **必须是包名**:client 侧服务键 `remote.` + (`dsh-api-gateway` client 的 `remoteServiceKey`)。写成端点名会挂载成功但永远调不到。 +4. **接收方服务**:gateway 按 `descriptor.service` 在接收方 ctx 上解析服务,并逐字段核对 + `typertRemote` 绑定(`service` = 实例本身 / `serviceKey` = 包名 / `namespace` = 包名)。 + 所以 `registerTypert` 先 `ctx.reflect.provide('dsh-plugin-usage', receiver)`,再注册面; + 注册被拒时**把接收方服务撤回去**并留在 Fetch 通道上 —— 半个挂上的面比没有更糟。 + +**为什么不是构建期 `exports["./typert"]`**:那条路径要官方 loader 扫描到包,同时仍要接收方 +服务;运行期 `ctx.typert.register()` 是官方公开入口(typert-registry README:"any other owner +calls ctx.typert.register(contribution) directly"),不需要新增运行时依赖,且归属落在本插件 +自己的 fiber 上(随卸载自动注销)。 + +**两条落地时的实测坑(别再踩)**: + +1. 提供服务用 **`ctx.provide(name, value)`**(cordis `Context.provide`,"Register a service + implementation owned by the current fiber… returns a disposer")。`ctx.reflect` 是 `Service` + 构造器内部的门面,插件 ctx 上取不到 —— 用它会让整条路径静默失效。 +2. 客户端取 Remote 用 **`ctx.get('remote.<包名>')`**(gateway 按 `remoteServiceKey(namespace)` + 建的服务键);`ctx.remote[包名]` 是旧形状,且属性访问在严格代理上**会抛**,只能作为受 + 保护的次选。 +3. **不要在激活期一次性探测 `ctx.get('typert')`**:cordis 的 `get` 只返回 + **提供方 fiber 已激活**的服务(`Context.get` → `_getImpl` 里 `if (strict && impl.fiber.state + !== 2) return`),而同一组合里的兄弟 bundle 可能比我们晚激活。实机后果是:宿主编排里 + 明明有 typert 注册表,插件却报 "no typert registry in this composition" 并落到 Fetch。 + 正确写法是**声明依赖**,在依赖就绪时再注册: + ```js + ctx.inject(['typert'], (typertCtx) => { Object.assign(channel, registerTypert(typertCtx, handlers, log)) }) + ``` + 与 `connection` 的写法一致(`registerRPC` 里两处并列),缺这个服务时回调不触发、Fetch 照样工作。 + +**通道决策可查**:`registerTypert` 返回 `{ ok, transport, reason }`,随每次 dashboard 响应 +以 `channel` 字段下发(插件 logger 不一定进宿主日志)。所以"走了哪条、为什么"不需要猜。 + +**真机实测(2026-10-29,work-exp,DSH NEXT 0.2.1-rc.3)**:dashboard 打开后网络里出现 +`POST /api/dsh-plugin-usage/query` → **200**,响应 `channel` 为 +`{"ok":true,"transport":"remote","reason":"registered 4 endpoints"}`;页面数据完整渲染 +(991.43M token / 2,562 次调用)。`/api/usage/*` 只在自举窗口(`$mount` 尚未落地的头一两个 +请求)出现,之后自动走 Remote —— 这就是"两条通道都常驻"的设计意图,不是残留。 + +**门**: +- `npm run verify:typert` 直接从本机 asar 取出官方 `validateTypertManifest` 跑一遍 + (缺 asar 时明确 SKIPPED,不假装通过); +- `npm run verify:remote` 更狠一步:把**真实的 cordis + 真实的 typert 注册表**从 asar 里取出来, + 在进程里跑一遍本插件的注册路径,并**反向**验证"激活期一次性探测 `ctx.get('typert')` 必然失败"。 + 这一条不需要重启真机就能证明通道成立(2026-10-29 实测:面落在 `dsh-plugin-usage/query`, + namespace/method 正确,接收方服务带 `typertRemote` 绑定); +- `tests/typert.test.js` 覆盖 loader 不查但 gateway 要的规则:namespace = 包名、接收方绑定、 + wire 名合法性、id 唯一性、以及"注册被拒要把接收方撤回去"; +- `tests/rpc.test.js` 断言两条通道都挂上,客户端侧 `tests/dialog.test.cjs` 断言优先 Remote、 + 失败回落 Fetch。 + + +--- + +## 3. 折叠缓存与诊断文件:一处**已知的**自建旁路,以及它为什么还留着 + +**现状**:`src/host/fold-cache.js` 把每个会话折叠后的记录写到 +`/cache/dsh-plugin-usage/fold/.json`(临时文件 + rename 原子写), +`src/host/scan.js` 在同一目录写一份 `last-pass.json`(一轮扫描的耗时摘要,纯诊断)。 + +**官方确实有对应通道**(这条要写清楚,否则"留着"就成了"我觉得"): + +- `@deepseek-ai/dsh-storage` —— 服务键 `storage`,`var Storage = class extends Service { constructor(ctx) { super(ctx, "storage") } }`, + 后端注册在 `backend`,数据面挂在 `forms`,`get domain()` 取 domain 形态; +- `@deepseek-ai/dsh-storage-json` —— `config: { root: !!js dshHomePath('storages') }`; +- `@deepseek-ai/dsh-storage-domain` —— `async open(spec)`(`lib/index.js`),返回带表/键/校验的 domain,并 `provide("storageDomain", facility)`; +- **官方自己就是这么存同类东西的**:`@deepseek-ai/dsh-session-projection-cache` 的注释原文 + "per-record layout — one version-stamped checkpoint document per session",数据落在 `session_projcache` domain 上。 + 也就是说"按会话存一份带版本号的检查点"这件事,官方已经有先例,而且走的是 storageDomain。 + +**所以按第〇条第 3 点,这是一处自建旁路。保留它的理由(三条,都不是"更好用"):** + +1. **这份数据是派生物,必须能整目录删掉重算**。`FOLD_CACHE_VERSION` 变化、节假日表更新、会话日志回滚, + 都是"作废重算";而 storageDomain 把数据当 **durable**:`open(spec)` 会 `await unit.loadAll()` 后 + **逐条**过 `valueSchema.parse`,不合法要 `invalidRecords: 'backup-and-skip'` 搬走并记日志。 + 给一份按设计就该丢弃的缓存背上迁移语义,是把成本放到错的一侧。 +2. **`open()` 是急加载的,而缓存只该懒读**。本机实测(2026-10-30):258 个会话、合计 **12 MB**、 + 最大单条 1.6 MB、中位数 7 KB。今天 12 MB 还能接受,但这是**随历史线性增长**的派生物 —— + 把它变成"每次激活先把全部记录 parse 一遍",等于把刚修掉的启动开销换个位置搬回来 + (对照:404 MB 日志的读取+解压是 12.5 s,热启动靠懒读压到 0.08 s)。 +3. **插件零运行时依赖**(只有 schemastery)。domain 的 `valueSchema` 需要一个校验器;为缓存引入 zod + 或自写一套 schema,只为校验我们自己写出去、自己也认得出的 JSON,不划算。 + 另外缓存按后端 `revision` 整条覆盖、不需要变更事件 —— 而那正是 storageDomain 相对自由命名空间多出来的部分。 + +这三条都是**可复核的事实**(服务实现、实测字节数、依赖清单),不是"我觉得它不好用"。一旦第 2 条的前提变了 +(缓存不再随历史增长,或官方给 domain 加了懒加载/只读视图),这处旁路就应当按下面的路径迁走。 + +**升级路径**(一旦出现"跨实例共享/可见性/诊断要统一"的需求,按这个走,不要再加第三个目录): + +```js +const domain = await ctx.storageDomain.open(defineDomain({ + name: 'dsh_usage_fold', + tables: { sessions: { valueSchema } }, +})) +ctx.effect(() => () => domain.close(), 'usage: fold domain') +``` + +`readEntry/writeEntry` 换成 domain 的读/写,`FOLD_CACHE_VERSION` 变成 `version` 字段(domain 自己会校验)。 + +**可核**:`tests/fold-cache.test.js`(原子写 / 签名 / 不做 prune)把行为钉住; +`last-pass.json` 是**诊断面**不是数据面 —— 删掉它不影响任何统计,`response.scan` 里也有同一份摘要。 + +**为什么一次查询会启动一轮全量扫描(读请求带写副作用)** + +第〇条第 3 点讲"不自建旁路",但这里还有一条更微妙的:**查询不该有副作用**。我们明知故犯, +因为不这么做,加载态根本不可能被看见: + +- 全量统计(含缓存水合)暖缓存 **0.78 s**、冷缓存 **~12 s**(实测,见上); +- 面板是**按需挂载**的:用户点开 设置 → 用量统计 时,进程启动早就过去了。 + +所以若在 `apply` 里开扫,那 0.78 s 永远发生在用户打开面板之前 —— 代码里有加载态、屏幕上一帧都没有 +(这条我们踩过:连着重启十几次都"没有加载动画")。现在改成 `handlers.set('query')` 里调 +`startBackfillOnDemand`:**面板打开的那一刻就是统计开始的那一刻**,骨架屏与 +`正在统计 n/N 个会话…` 的可见时长等于工作本身的时长。 + +代价与边界,写在这里免得上游 review 当成性能回退: + +1. 查询是**幂等**的:`state.backfill.started` 闩住,只有第一次查询真正启动; +2. 写入的只是**派生物缓存**(`cache/dsh-plugin-usage/fold`,可整目录删除),不碰用户数据、不写设置; +3. 后台任务**不可能弄死宿主**:`void runBackfill(...).catch(...)` 兜底,日志经 `safeLog()` + —— 这两条是实际事故的产物(`logSink(...).warn is not a function` 直接把 dsh 打挂过); +4. 门:`tests/lazy-backfill.test.js`(首次启动 / 幂等 / 无持久化立即 done / 接线门 / 坏 logger / + `ctx.get` 抛异常),其中接线门断言 `apply` 里不得再出现 `runBackfill`,防止有人"顺手"改回启动时预热。 + +--- + +## 4. 已经和原生对齐的部分(同样取证过,别再改回去) + +| 项 | 位置 | 官方对照 | +|---|---|---| +| 词典注册/绑定 | `ctx.effect(() => ctx.locale.register(NS, {zh, en}))` + `ctx.locale.bind(NS)` | 官方 `dsh-client-ui-settings-plugin-inventory` 的 `apply`(同形) | +| 设置分区注册 | `ctx.slots.inject('settings.section', () => ctx.slots.register({name, id, order, label, locale}, C))` | 同上(官方也不把 `slots.inject` 包进 `ctx.effect`) | +| 客户端 bundle 契约 | 构建产物 `window.__ModuleLoader__.load({id, factory})` | 任一官方 `*.client.js` | +| 日志 | `ctx.logger`(`ctx.get('logger')` → `ctx.logger` → `console` 兜底) | 官方插件用 `ctx.logger.*` | +| 会话用量 fold | 自己折叠事件(官方 `tokenUsage` 只有会话级总量,没有分模型/分时刻) | 由 `tests/fold-parity.test.js` 拿官方 fold 的录制结果对账 | +| client→host | 原生 typert Remote(贡献 + 接收方服务),Fetch 作兜底 | 见上面 §2,`npm run verify:typert` 用官方校验器验 | +| 错误上页面 | 宿主只发稳定码 `internal`,界面按码出话,堆栈进日志 | 库经验⑪ | +| 配置 | schemastery `Config` + `ctx.settings.update('usage', patch)` | 原生配置路径 | +| patch 行 | `cordis.patch.yml` 里 `id: usage` | 库经验⑧ | diff --git a/image-1.png b/image-1.png index ed15173..d2d866a 100644 Binary files a/image-1.png and b/image-1.png differ diff --git a/image.png b/image.png index aa23b00..a1d427b 100644 Binary files a/image.png and b/image.png differ diff --git a/package-lock.json b/package-lock.json index ee0c571..946521c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-plugin-usage", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-plugin-usage", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@deepseek-ai/schemastery": "~3.18.5" diff --git a/package.json b/package.json index 0ba72d6..505684f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-plugin-usage", "description": "Token usage statistics for DeepSeek Harness — daily/custom-range views, model filters, call counts and cost, inspired by CC Switch statistics.", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "repository": { "type": "git", @@ -27,7 +27,8 @@ "icon.svg", "locale", "image.png", - "image-1.png" + "image-1.png", + "screenshots.json" ], "keywords": [ "dsh", @@ -40,10 +41,11 @@ ], "engines": { "node": ">=22", - "dsh": ">=0.2.0-rc.2 <0.3.0" + "dsh": ">=0.2.1-rc.3 <0.3.0" }, "dsh": { "manifestVersion": 1, + "verifiedAgainst": "0.2.1-rc.4", "bundle": { "patch": "./cordis.patch.yml" }, @@ -71,8 +73,12 @@ }, "scripts": { "build": "tsc -p tsconfig.json && node scripts/build-client.js", - "test": "node --test tests/*.test.js", + "test": "node --test tests/*.test.js tests/*.test.cjs", + "prices": "node scripts/gen-prices.mjs", "watch": "tsc -p tsconfig.json --watch", - "prepublishOnly": "npm run build && npm test" + "prepublishOnly": "npm run build && npm test && npm run verify:typert && npm run verify:remote", + "pretest": "npm run build", + "verify:typert": "node scripts/verify-typert.mjs", + "verify:remote": "node scripts/verify-remote-channel.mjs" } } diff --git a/screenshots.json b/screenshots.json new file mode 100644 index 0000000..7cc2c02 --- /dev/null +++ b/screenshots.json @@ -0,0 +1,4 @@ +[ + "image.png", + "image-1.png" +] diff --git a/scripts/fold-parity-fixture.mjs b/scripts/fold-parity-fixture.mjs new file mode 100644 index 0000000..71dcbd7 --- /dev/null +++ b/scripts/fold-parity-fixture.mjs @@ -0,0 +1,161 @@ +/** + * Record the parity fixture for the plugin's event fold. + * + * The plugin folds durable session events into usage records because the + * official `tokenUsage` projection only exposes per-session totals, while a + * dashboard needs per-model / per-timestamp rows. Claiming "this mirrors the + * official fold" is not evidence (xshared-lib experience ⓪.4), so this script + * runs the OFFICIAL fold over a fixed event script and records its output. + * + * It extracts `@deepseek-ai/dsh-token-meter/lib/types/usage-projection.js` from + * the installed Harness archive and executes its `apply` verbatim; only the + * module's imports are replaced by local shims (zod is a chainable no-op, the + * two helpers are copied from the same distribution). Regenerate with: + * + * node scripts/fold-parity-fixture.mjs [path/to/app.asar] + * + * The recorded numbers belong to the Harness version printed in `provenance`: + * re-run after a Harness upgrade, do not hand-edit. + */ +import { readFileSync, writeFileSync, mkdtempSync, existsSync, openSync, readSync, closeSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const out = join(here, '..', 'tests', 'fixtures', 'token-usage-parity.json') +const archive = process.argv[2] + || process.env.DSH_ASAR + || 'E:\\FXS\\APP\\DSH NEXT\\resources\\app.asar' + +if (!existsSync(archive)) { + console.error('asar not found: ' + archive + '\npass it as argv[1] or set DSH_ASAR') + process.exit(2) +} + +/** Read one entry out of an Electron asar (chromium pickle header). */ +function readAsarEntry(file, wanted, marker) { + const fd = openSync(file, 'r') + try { + const head = Buffer.alloc(16) + readSync(fd, head, 0, 16, 0) + const stringLen = head.readUInt32LE(12) + const json = Buffer.alloc(stringLen) + readSync(fd, json, 0, stringLen, 16) + const header = JSON.parse(json.toString('utf8')) + const entry = wanted.split('/').reduce((node, part) => { + if (!node || !node.files || !node.files[part]) throw new Error('missing ' + part + ' in ' + wanted) + return node.files[part] + }, header) + if (typeof entry.offset !== 'string') throw new Error('not a file: ' + wanted) + const data = Buffer.alloc(entry.size) + const bases = [8 + ((stringLen + 4 + 3) & ~3), 16 + ((stringLen + 3) & ~3)] + const wantsJson = wanted.endsWith('.json') + for (const base of bases) { + readSync(fd, data, 0, entry.size, base + Number(entry.offset)) + const text = data.toString('utf8') + if (wantsJson) { + try { JSON.parse(text); return text } catch { continue } + } + if (text.includes(marker)) { + // The data base can land a few bytes early, leaving the tail of the + // previous entry in front of the file: cut to the first comment. + const start = text.indexOf('/**') + return start > 0 ? text.slice(start) : text + } + } + throw new Error('could not decode ' + wanted) + } finally { + closeSync(fd) + } +} + +const PKG = 'node_modules/@deepseek-ai/dsh-token-meter' +const projectionSrc = readAsarEntry(archive, PKG + '/lib/types/usage-projection.js', 'export const tokenUsageProjectionDefinition') +const llmSrc = readAsarEntry(archive, PKG.replace('dsh-token-meter', 'dsh-llm') + '/lib/index.js', 'function lastAssistantStreamChunk') +const version = JSON.parse(readAsarEntry(archive, PKG + '/package.json', 'dsh-token-meter')).version + +// `lastAssistantStreamChunk` verbatim from the same distribution. +const helper = /function lastAssistantStreamChunk\(stream, type\) \{[\s\S]*?\n\}/.exec(llmSrc) +if (!helper) throw new Error('could not find lastAssistantStreamChunk in @deepseek-ai/dsh-llm') + +// Replace ONLY the imports: the fold body below them runs unmodified. +const body = projectionSrc + .replace(/^import[^\n]*\n/gmu, '') + .replace(/^\.map/m, '') +const shimmed = [ + '// zod stub: schemas are declared but never validated by apply().', + 'const chain = new Proxy(function () {}, { get: () => () => chain, apply: () => chain })', + 'const z = new Proxy({}, { get: () => (...args) => (args.length ? chain : chain) })', + helper[0], + 'const SessionSeq = (n) => n', + 'const foldSurfaceProjection = () => ({})', + body, +].join('\n') + +const dir = mkdtempSync(join(tmpdir(), 'usage-parity-')) +const modPath = join(dir, 'usage-projection.mjs') +writeFileSync(modPath, shimmed) +if (process.env.USAGE_PARITY_KEEP) console.log('shim kept at ' + modPath) +const { tokenUsageProjectionDefinition } = await import(pathToFileURL(modPath).href) +if (!tokenUsageProjectionDefinition || typeof tokenUsageProjectionDefinition.apply !== 'function') { + throw new Error('the official projection did not export apply()') +} + +/* + * The event script. Every branch of the official comment is covered: + * 1. a settlement with `data.usage` adds; + * 2. the same (turn, step) re-reporting a DIFFERENT figure replaces; + * 3. the same (turn, step) re-reporting the SAME figure changes nothing; + * 4. `llm/retry-started` closes the slot, so the retried attempt adds; + * 5. an attempt with no `data.usage` bills from its last stream usage chunk; + * 6. a message with neither reports nothing. + */ +const usage = (inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens) => + ({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }) +const chunkUsage = (u) => ({ type: 'chunk', chunk: { type: 'usage', usage: u } }) +const chunkText = (text) => ({ type: 'chunk', chunk: { type: 'text', text } }) + +const events = [ + { seq: 1, time: 1761300000000, type: 'session', data: {} }, + { seq: 2, time: 1761300001000, type: 'request/context', data: { provider: 'deepseek-account', model: 'deepseek-flash' } }, + // 1. straight settlement + { seq: 3, time: 1761300002000, type: 'assistant/message', data: { turn: 0, step: 0, usage: usage(1000, 200, 5000, 100) } }, + // 2. same step re-reports a bigger cumulative figure -> replace, not add + { seq: 4, time: 1761300003000, type: 'assistant/message', data: { turn: 0, step: 0, usage: usage(1200, 250, 5000, 100) } }, + // 3. identical re-report -> no change + { seq: 5, time: 1761300004000, type: 'assistant/message', data: { turn: 0, step: 0, usage: usage(1200, 250, 5000, 100) } }, + // 5. attempt billing from the stream (no data.usage) + { seq: 6, time: 1761300005000, type: 'assistant/attempt', data: { turn: 1, step: 0, stream: [chunkText('hi'), chunkUsage(usage(300, 40, 0, 0))] } }, + // 4. retry: closes the slot, the retried attempt bills again + { seq: 7, time: 1761300006000, type: 'llm/retry-started', data: { turn: 1, step: 0 } }, + { seq: 8, time: 1761300007000, type: 'assistant/message', data: { turn: 1, step: 0, usage: usage(310, 45, 10, 0) } }, + // 6. nothing to bill + { seq: 9, time: 1761300008000, type: 'assistant/message', data: { turn: 2, step: 0, stream: [chunkText('no usage here')] } }, + // another turn, off a new model + { seq: 10, time: 1761300009000, type: 'request/context', data: { provider: 'coding-plan', model: 'glm-5.3-flash' } }, + { seq: 11, time: 1761300010000, type: 'assistant/message', data: { turn: 2, step: 1, usage: usage(50, 5, 900, 0) } }, +] + +let state = tokenUsageProjectionDefinition.init() +for (const event of events) state = tokenUsageProjectionDefinition.apply(state, event) +const expected = tokenUsageProjectionDefinition.wire.view(state) + +const fixture = { + $comment: + 'Recorded, never hand-edited: expected is the OFFICIAL tokenUsage fold ' + + '(@deepseek-ai/dsh-token-meter ' + version + ') applied to these events by ' + + 'scripts/fold-parity-fixture.mjs. Re-run that script after a Harness upgrade.', + provenance: { + package: '@deepseek-ai/dsh-token-meter', + version, + projection: 'lib/types/usage-projection.js#tokenUsageProjectionDefinition', + recordedAt: new Date().toISOString().slice(0, 10), + generator: 'scripts/fold-parity-fixture.mjs', + }, + events, + expected, +} +writeFileSync(out, JSON.stringify(fixture, null, 2) + '\n') +console.log('official totals:', JSON.stringify(expected)) +console.log('wrote ' + out) diff --git a/scripts/gen-prices.mjs b/scripts/gen-prices.mjs new file mode 100644 index 0000000..5d2dc12 --- /dev/null +++ b/scripts/gen-prices.mjs @@ -0,0 +1,480 @@ +/** + * Generate the reference price table and the holiday calendar from their + * public sources. + * + * node scripts/gen-prices.mjs # fetch all three sources + * node scripts/gen-prices.mjs --offline # rebuild from the cached snapshots + * + * Outputs (both are data modules, NOT JSON: `tsconfig.json` sets + * `rootDir: src` / `outDir: lib` and only `src/**` is copied, so a `.json` file + * would never reach the published package): + * + * src/host/prices.js price table: Zen for everything, DeepSeek official + * (peak + off-peak) overriding the DeepSeek rows + * src/host/holidays.js public-holiday calendar for the peak rule + * + * Why a generator and not a runtime fetch: the price table has to be identical + * on every host with no network, and a stale table has to be visible in review + * rather than appearing live. The weekly workflow reruns this and opens a PR + * when the output changes. + * + * The line that is easy to get wrong and is therefore asserted twice: Zen lists + * DeepSeek at the PEAK rate. The DeepSeek provider block must therefore come + * from the official page, and the Zen row for the same model may only be a + * fallback. + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { beijingYear, holidaySpan, mergeHolidayYear, sessionSpan } from './lib/holiday-years.mjs'; +import { fileURLToPath } from 'node:url'; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); + +const ZEN_URL = 'https://opencode.ai/v2/docs/console/models/'; +const DEEPSEEK_URL = 'https://api-docs.deepseek.com/quick_start/pricing/'; +const HOLIDAY_URL = (year) => `https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/${year}.json`; + +/** Snapshot cache, so `--offline` works and a flaky run is reproducible. */ +const CACHE_DIR = join(ROOT, '.cache'); + +const OFFLINE = process.argv.includes('--offline'); +const TIMEOUT_MS = 45_000; + +/** Session store the usage statistics read: the cheapest bound on their dates. */ +const SESSIONS_ROOT = process.env.DSH_SESSIONS_DIR || join(homedir(), '.dsh', 'sessions'); + +/** Optional explicit overrides: `--holiday-years 2024-2027`, `--usage-from`, `--usage-to`. */ +function argValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 && index + 1 < process.argv.length ? process.argv[index + 1] : undefined; +} + +/** + * Years of holiday data to embed. + * + * Covers every year the usage statistics can contain, plus one year forward — + * an explicit `--holiday-years A-B` wins, otherwise the session store's dates + * decide (a dashboard with 2024 usage must ship 2024 holidays, or that year's + * holidays get billed at the peak rate). + */ +const HOLIDAY_SPAN = (() => { + const explicit = argValue('--holiday-years'); + if (explicit !== undefined) { + const [from, to] = explicit.split('-').map(Number); + if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) { + throw new Error('--holiday-years needs a range like 2024-2027'); + } + return { from, to, usedUsage: false, source: 'argument' }; + } + const fromArg = argValue('--usage-from'); + const toArg = argValue('--usage-to'); + const usage = fromArg !== undefined || toArg !== undefined + ? { fromMs: Date.parse(fromArg || ''), toMs: Date.parse(toArg || '') } + : sessionSpan(SESSIONS_ROOT) || {}; + const span = holidaySpan({ + usageFromMs: Number.isFinite(usage.fromMs) ? usage.fromMs : undefined, + usageToMs: Number.isFinite(usage.toMs) ? usage.toMs : undefined, + }); + // Deliberately NOT the directory it was read from: this string ships inside the + // package, and a maintainer's home path is both a privacy leak and a source of + // churn (the weekly CI run, which has no session store, would rewrite it and + // flip the file back and forth on every regeneration). + return { ...span, source: span.usedUsage ? 'session store' : 'calendar default' }; +})(); + +const HOLIDAY_YEARS = (() => { + const years = []; + for (let year = HOLIDAY_SPAN.from; year <= HOLIDAY_SPAN.to; year += 1) years.push(year); + return years; +})(); + +const warnings = []; +const warn = (message) => { + warnings.push(message); + console.warn('WARN ' + message); +}; + +/* -------------------------------------------------------------------------- * + * Fetch + cache + * -------------------------------------------------------------------------- */ + +async function fetchText(url, cacheName) { + const cachePath = join(CACHE_DIR, cacheName); + if (OFFLINE) { + if (!existsSync(cachePath)) throw new Error(`--offline but ${cacheName} is not cached`); + return { text: readFileSync(cachePath, 'utf8'), fetchedAt: new Date().toISOString(), cached: true }; + } + const res = await fetch(url, { + headers: { 'user-agent': 'dsh-plugin-usage price generator (+https://github.com/bennett-dsh/dsh-plugin-usage)' }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`${url} answered HTTP ${res.status}`); + const text = await res.text(); + writeFileSync(cachePath, text); + return { text, fetchedAt: new Date().toISOString(), cached: false }; +} + +/* -------------------------------------------------------------------------- * + * opencode Zen + * + * Three tables on one page, located by their header cell rather than by + * position: [Model|Model ID|Endpoint|AI SDK], [Model|Input|Output|Cached + * Read|Cached Write], [Model|Deprecation date]. + * -------------------------------------------------------------------------- */ + +function parseTables(html) { + return [...html.matchAll(//gi)].map((m) => { + const rows = []; + for (const tr of m[0].match(/]*>([\s\S]*?)<\/tr>/gi) || []) { + const cells = []; + for (const td of tr.match(/]*>([\s\S]*?)<\/t[dh]>/gi) || []) { + cells.push( + td + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/\s+/g, ' ') + .trim(), + ); + } + if (cells.length) rows.push(cells); + } + return rows; + }); +} + +/** "$1.36 $0.68" → { list: 1.36, value: 0.68 }; "$3.00" → 3; "Free" → 0; "-" → null. */ +function money(text) { + const t = String(text ?? '').trim(); + if (!t || t === '-' || t === '—') return null; + if (/^free$/i.test(t)) return { list: 0, value: 0 }; + const nums = [...t.matchAll(/[\d][\d,]*\.?\d*/g)].map((m) => Number(m[0].replace(/,/g, ''))).filter(Number.isFinite); + if (nums.length === 0) return null; + if (nums.length >= 2) return { list: nums[0], value: nums[1] }; + return { list: nums[0], value: nums[0] }; +} + +/** Trailing "( ... )" qualifier of a Zen row label, plus the text inside it. */ +function splitQualifier(label) { + const m = /^([\s\S]*?)\s*[((]([^))]*)[))]\s*$/.exec(label); + if (!m) return { base: label.trim(), inner: '' }; + return { base: m[1].trim(), inner: m[2].trim() }; +} + +function parseZen(html) { + const tables = parseTables(html); + const registry = tables.find((rows) => rows[0]?.some((c) => /^model id$/i.test(c))); + const pricing = tables.find((rows) => rows[0]?.some((c) => /cached read/i.test(c))); + const deprecation = tables.find((rows) => rows[0]?.some((c) => /deprecation/i.test(c))); + if (registry === undefined) throw new Error('Zen page has no registry table (Model / Model ID)'); + if (pricing === undefined) throw new Error('Zen page has no pricing table (Model / Input / Output / Cached Read / Cached Write)'); + + const idByName = new Map(); + for (const row of registry.slice(1)) { + if (row[0] && row[1]) idByName.set(row[0].trim(), row[1].trim()); + } + + const models = {}; + const surcharges = {}; + const notes = []; + for (const row of pricing.slice(1)) { + const label = row[0]; + if (!label) continue; + const { base, inner } = splitQualifier(label); + const id = idByName.get(base) || idByName.get(label); + if (id === undefined) { + notes.push({ id: null, kind: 'unmatched-row', text: label }); + continue; + } + const price = { + input: money(row[1]), + output: money(row[2]), + cacheRead: money(row[3]), + cacheWrite: money(row[4]), + }; + // Long-context surcharge rows ("(> 200K tokens)") are a different rate for a + // different request size; they are kept aside, never merged into the base. + const over = /^[>≥]/.test(inner); + const under = /^[<≤]/.test(inner); + if (over) { + surcharges[id] = { + input: price.input ? price.input.value : null, + output: price.output ? price.output.value : null, + cacheRead: price.cacheRead ? price.cacheRead.value : null, + tier: inner, + }; + continue; + } + if (models[id] !== undefined) continue; // "≤" row already took the slot + if (price.input?.list !== undefined && price.input.list !== price.input.value) { + notes.push({ id, kind: 'promo', text: `促销价生效(原价 ${price.input.list} → ${price.input.value})` }); + } + models[id] = { + input: price.input ? price.input.value : null, + output: price.output ? price.output.value : null, + cacheRead: price.cacheRead ? price.cacheRead.value : null, + cacheWrite: price.cacheWrite ? price.cacheWrite.value : null, + }; + if (under) models[id].contextTier = inner; + } + + const deprecated = []; + if (deprecation !== undefined) { + for (const row of deprecation.slice(1)) { + if (row[0]) deprecated.push({ name: row[0].trim(), date: (row[1] || '').trim() }); + } + } + + return { models, surcharges, notes, deprecated }; +} + +/* -------------------------------------------------------------------------- * + * DeepSeek official pricing + * + * The page prints one labelled table per generation. The live layout is a + * "MODEL | deepseek-flash | deepseek-v4-pro" header followed by label rows + * ("PRICING", "1M INPUT TOKENS (CACHE HIT)", then OFF-PEAK / PEAK pairs). + * -------------------------------------------------------------------------- */ + +/** + * The live layout is a three-column pricing block whose first column carries + * two pieces of information at once: + * + * ["PRICING (2)", "1M INPUT TOKENS (CACHE HIT)", "OFF-PEAK", "$0.003", "$0.022"] + * ["PEAK", "$0.006", "$0.044"] + * + * so "1M INPUT TOKENS (CACHE HIT)" both names the metric and opens the + * OFF-PEAK tier, while the follow-up row carries only "PEAK". Earlier layouts + * printed the numbers directly in the label row; both are handled. + */ +function parseDeepSeek(html) { + const tables = parseTables(html); + const table = tables.find((rows) => rows[0]?.[0] && /^model$/i.test(rows[0][0]) && rows[0].length >= 2); + if (table === undefined) throw new Error('DeepSeek page has no pricing table'); + + const header = table[0]; + // "deepseek-flash (1)" → "deepseek-flash"; the "(n)" is a footnote marker. + const ids = header.slice(1).map((name) => name.replace(/\s*[((]\d+[))]\s*$/, '').trim()).filter(Boolean); + const result = {}; + + const METRICS = [ + [/INPUT TOKENS \(CACHE HIT\)/, 'cacheRead'], + [/INPUT TOKENS \(CACHE MISS\)/, 'input'], + [/OUTPUT TOKENS/, 'output'], + ]; + const TIER = /^(OFF[\s-]?PEAK|PEAK)\b/; + + let metric = null; + for (const row of table.slice(1)) { + const cells = row.map((c) => String(c || '').trim()); + // Find the metric named anywhere in the row (first column of the live + // layout, but legacy tables put it in the label row's own cell). + let named = null; + let namedAt = -1; + for (let i = 0; i < cells.length; i++) { + for (const [re, name] of METRICS) { + if (re.test(cells[i].toUpperCase())) { + named = name; + namedAt = i; + break; + } + } + if (named !== null) break; + } + if (named !== null) metric = named; + + let tier = null; + let firstValue = 1; + for (let i = 0; i < cells.length; i++) { + const hit = TIER.exec(cells[i].toUpperCase()); + if (hit) { + tier = hit[1].replace(/\s+/g, '').toUpperCase() === 'PEAK' ? 'peak' : 'offPeak'; + firstValue = i + 1; + break; + } + } + + if (metric === null) continue; + if (named !== null && tier === null) { + // Legacy inline layout: the label row itself carries the numbers. + tier = 'peak'; + firstValue = namedAt + 1; + } + if (tier === null) continue; + + for (let i = 0; i < ids.length; i++) { + const cell = cells[firstValue + i]; + if (cell === undefined) break; + const value = money(cell); + if (value === null) continue; + result[ids[i]] = result[ids[i]] || {}; + result[ids[i]][tier] = result[ids[i]][tier] || {}; + result[ids[i]][tier][metric] = value.value; + } + } + return { models: result, header: header.slice(1) }; +} + +/* -------------------------------------------------------------------------- * + * Holidays + * -------------------------------------------------------------------------- */ + +async function collectHolidays() { + const dates = {}; + const years = {}; + for (const year of HOLIDAY_YEARS) { + let payload; + try { + const { text } = await fetchText(HOLIDAY_URL(year), `holiday-${year}.json`); + payload = JSON.parse(text); + } catch (error) { + warn(`节假日数据 ${year} 不可用:${error.message}`); + continue; + } + const days = Array.isArray(payload.days) ? payload.days : []; + const off = days.filter((d) => d && d.isOffDay === true && typeof d.date === 'string'); + if (off.length === 0) { + // The State Council publishes next year's arrangement in November, so an + // empty year is expected rather than broken. It is RECORDED as + // unpublished (not dropped): the UI has to be able to say "2027 is not + // published yet" instead of implying the year has no holidays. + warn(`${year} 节假日安排尚未公布(holiday-cn 无数据),该年按周末规则判定`); + } + mergeHolidayYear({ dates, years }, year, payload); + if (off.length === 0) continue; + const covered = off.map((d) => d.date).sort(); + // A published year always contains 国庆 (Oct 1-7) and never has public + // holidays after early October, so "ends in October" is normal. The real + // truncation signal is a file that stops BEFORE this year's 国庆 — that is a + // half-published year, and its missing days would fall back to the weekend + // rule. (The old check compared against Dec 31 and warned on every year.) + const nationalDay = `${year}-10-01`; + if (covered[covered.length - 1] < nationalDay) { + warn(`${year} 节假日数据只到 ${covered[covered.length - 1]}(缺 ${nationalDay} 起的国庆安排),该段按周末规则判定`); + } + } + return { dates, years }; +} + +/* -------------------------------------------------------------------------- * + * Emit + * -------------------------------------------------------------------------- */ + +const HEADER = '// GENERATED by scripts/gen-prices.mjs — do not edit by hand.\n'; + +/** + * Official DeepSeek ids whose row must also land on Zen's id for the same model. + * + * The two tables name one model differently (`deepseek-flash` vs + * `deepseek-v4.1-flash`), and Zen's single price for it happens to equal + * DeepSeek's PEAK price. Writing the tiered row under both ids is what keeps the + * peak / off-peak split reachable: a session that says either id must get the + * half-price off-peak rate, not Zen's flat peak number. + */ +const DEEPSEEK_ZEN_TWIN = { + 'deepseek-flash': 'deepseek-v4.1-flash', +}; + +function renderPrices({ zen, deepseek, zenFetchedAt, deepseekFetchedAt }) { + // DeepSeek: merge official peak/off-peak onto the model entry. Zen's row, when + // present, is kept as `fallback` so `pricing.js` can still price a model the + // official page dropped. + const models = {}; + for (const [id, price] of Object.entries(zen.models)) { + models[id] = { source: 'zen', ...price }; + } + for (const [id, tiers] of Object.entries(deepseek.models)) { + const entry = { source: 'deepseek' }; + entry.peak = { + input: tiers.peak?.input ?? null, + output: tiers.peak?.output ?? null, + cacheRead: tiers.peak?.cacheRead ?? null, + cacheWrite: null, + }; + entry.offPeak = { + input: tiers.offPeak?.input ?? entry.peak.input, + output: tiers.offPeak?.output ?? entry.peak.output, + cacheRead: tiers.offPeak?.cacheRead ?? entry.peak.cacheRead, + cacheWrite: null, + }; + const twin = DEEPSEEK_ZEN_TWIN[id]; + for (const key of twin === undefined ? [id] : [id, twin]) { + const zenRow = models[key] ? { ...models[key] } : null; + const copy = { ...entry, peak: { ...entry.peak }, offPeak: { ...entry.offPeak } }; + if (zenRow !== null) copy.fallback = zenRow; + models[key] = copy; + } + } + const payload = { + capturedAt: new Date().toISOString(), + unit: 'USD per 1M tokens', + sources: [ + { name: 'opencode Zen', url: ZEN_URL, fetchedAt: zenFetchedAt, models: Object.keys(zen.models).length }, + { name: 'DeepSeek', url: DEEPSEEK_URL, fetchedAt: deepseekFetchedAt, models: Object.keys(deepseek.models).length }, + ], + note: 'Zen 每个模型单一价;DeepSeek 条目来自官网,峰谷两套价,参考成本按成交时刻判定(见 peak.js)。', + models, + surcharges: zen.surcharges, + deprecated: zen.deprecated, + notes: zen.notes, + }; + return HEADER + 'export const REFERENCE_PRICES = ' + JSON.stringify(payload, null, 2) + ';\n'; +} + +function renderHolidays({ dates, years }) { + const payload = { + capturedAt: new Date().toISOString(), + source: 'https://github.com/NateScarlet/holiday-cn', + note: '中国法定节假日(isOffDay=true 的日期)。峰谷判定中这些日期全天按空闲时段计。', + // The span the calendar was fetched for: every year the usage statistics can + // reach, plus one year forward (arrangements appear in November). + coverage: { + fromYear: HOLIDAY_SPAN.from, + toYear: HOLIDAY_SPAN.to, + basis: HOLIDAY_SPAN.source, + years: HOLIDAY_YEARS, + }, + years, + dates, + }; + return HEADER + 'export const PUBLIC_HOLIDAYS = ' + JSON.stringify(payload, null, 2) + ';\n'; +} + +/* -------------------------------------------------------------------------- * + * Main + * -------------------------------------------------------------------------- */ + +async function main() { + const zenFetch = await fetchText(ZEN_URL, 'zen.html'); + const deepseekFetch = await fetchText(DEEPSEEK_URL, 'deepseek.html'); + const zen = parseZen(zenFetch.text); + const deepseek = parseDeepSeek(deepseekFetch.text); + const holidays = await collectHolidays(); + + const modelCount = Object.keys(zen.models).length; + if (modelCount < 80) throw new Error(`Zen table only has ${modelCount} models — page layout probably changed`); + const flash = deepseek.models['deepseek-flash']; + if (!flash || !flash.peak || !flash.offPeak) { + throw new Error('DeepSeek table has no peak/off-peak pair for deepseek-flash — page layout probably changed'); + } + + writeFileSync(join(ROOT, 'src/host/prices.js'), renderPrices({ + zen, + deepseek, + zenFetchedAt: zenFetch.fetchedAt, + deepseekFetchedAt: deepseekFetch.fetchedAt, + })); + writeFileSync(join(ROOT, 'src/host/holidays.js'), renderHolidays(holidays)); + + console.log(`prices.js : ${modelCount} Zen models, ${Object.keys(deepseek.models).length} DeepSeek models with peak/off-peak`); + console.log(`holidays.js: ${Object.keys(holidays.dates).length} dates across ${Object.keys(holidays.years).join(', ') || '(none)'}`); + console.log(`warnings : ${warnings.length}`); +} + +main().catch((error) => { + console.error('FAILED ' + (error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/scripts/lib/holiday-years.mjs b/scripts/lib/holiday-years.mjs new file mode 100644 index 0000000..3714a0a --- /dev/null +++ b/scripts/lib/holiday-years.mjs @@ -0,0 +1,138 @@ +/** + * Which years of Chinese public holidays the plugin ships. + * + * The peak rule needs every year the usage statistics can reach, plus one year + * forward (the State Council publishes the next arrangement in November, so the + * coming year is exactly the one a dashboard must already answer for). Getting + * the span from the data instead of from "today ± 1" is what keeps an old + * session — 2024 usage, say — from being billed at the peak rate on a holiday + * simply because its year was never fetched. + * + * Pure functions only: the generator imports them, tests import them, and the + * filesystem walk is injected so a test can hand in a fake tree. + */ +import { readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' + +/** Beijing wall-clock year of an instant (the calendar is published per Beijing date). */ +export function beijingYear(atMs) { + return new Date(atMs + 8 * 3_600_000).getUTCFullYear() +} + +/** + * The year span to fetch. + * + * @param {{ usageFromMs?: number, usageToMs?: number, nowMs?: number }} input + * `usageFromMs`/`usageToMs` bound the dates the usage statistics can contain + * (absent when no session store was found). + * @returns {{ from: number, to: number, usedUsage: boolean }} inclusive year span. + */ +export function holidaySpan(input = {}) { + const nowMs = Number.isFinite(input.nowMs) ? input.nowMs : Date.now() + const nowYear = beijingYear(nowMs) + const hasUsage = Number.isFinite(input.usageFromMs) && Number.isFinite(input.usageToMs) + if (!hasUsage) return { from: nowYear - 1, to: nowYear + 1, usedUsage: false } + // A session file's mtime is its LAST write, so the oldest session may have + // started up to a year before it: the past side gets a year of slack. + const from = Math.min(beijingYear(input.usageFromMs) - 1, nowYear - 1) + // Forward is exact: cover the latest usage year and always one year beyond it. + const to = Math.max(beijingYear(input.usageToMs), nowYear) + 1 + return { from, to, usedUsage: true } +} + +/** + * Earliest/latest mtime of the session files below `root`. + * + * Session logs are the usage statistics' own source, so their dates are the + * cheapest honest bound on "every date the dashboard can show". + * + * @param {string} root - sessions directory. + * @param {{ readdir?: Function, stat?: Function, isSessionFile?: (name: string) => boolean }} [io] + * @returns {{ fromMs: number, toMs: number, files: number } | null} null when nothing matched. + */ +export function sessionSpan(root, io = {}) { + const readdir = io.readdir ?? readdirSync + const stat = io.stat ?? statSync + const isSessionFile = io.isSessionFile ?? ((name) => name.startsWith('session') && name.includes('.jsonl')) + let fromMs = Infinity + let toMs = -Infinity + let files = 0 + const walk = (dir) => { + let entries + try { + entries = readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const path = join(dir, entry.name) + if (entry.isDirectory && entry.isDirectory()) { + walk(path) + continue + } + if (!entry.isFile || !entry.isFile() || !isSessionFile(entry.name)) continue + let info + try { + info = stat(path) + } catch { + continue + } + if (!info || !Number.isFinite(info.mtimeMs)) continue + files += 1 + if (info.mtimeMs < fromMs) fromMs = info.mtimeMs + if (info.mtimeMs > toMs) toMs = info.mtimeMs + } + } + walk(root) + return files === 0 ? null : { fromMs, toMs, files } +} + +/** + * Fold one year of holiday-cn data into the accumulated calendar. + * + * A year whose `days` array is empty is RECORDED as `unpublished` rather than + * dropped: the runtime must be able to say "2027 is not published yet" instead + * of silently treating it as a year with no holidays (which would bill a + * holiday at the peak rate and never mention why). + * + * @param {{ dates: Record, years: Record }} acc - accumulator. + * @param {number} year - calendar year. + * @param {unknown} payload - parsed `holiday-cn/.json`. + * @returns {{ dates: Record, years: Record }} the same accumulator. + */ +export function mergeHolidayYear(acc, year, payload) { + const days = payload && Array.isArray(payload.days) ? payload.days : [] + const off = days.filter((d) => d && d.isOffDay === true && typeof d.date === 'string') + if (off.length === 0) { + acc.years[year] = { + unpublished: true, + offDays: 0, + first: null, + last: null, + papers: payload && Array.isArray(payload.papers) ? payload.papers : [], + } + return acc + } + for (const day of off) acc.dates[day.date] = day.name || 'holiday' + const covered = off.map((d) => d.date).sort() + acc.years[year] = { + offDays: off.length, + first: covered[0] || null, + last: covered[covered.length - 1] || null, + papers: payload && Array.isArray(payload.papers) ? payload.papers : [], + } + return acc +} + +/** + * Years whose arrangement is not published yet, in ascending order. + * + * @param {Record} years - the generated metadata. + * @returns {number[]} unpublished years. + */ +export function unpublishedYears(years) { + return Object.entries(years || {}) + .filter(([, info]) => info && info.unpublished === true) + .map(([year]) => Number(year)) + .sort((a, b) => a - b) +} diff --git a/scripts/verify-remote-channel.mjs b/scripts/verify-remote-channel.mjs new file mode 100644 index 0000000..4df1385 --- /dev/null +++ b/scripts/verify-remote-channel.mjs @@ -0,0 +1,158 @@ +/** + * Verify the native Remote channel against the REAL cordis and typert registry. + * + * The unit tests fake the registry, which cannot catch the failure this plugin + * actually hit: `ctx.get(name)` only returns a service whose providing fiber is + * ACTIVE (cordis `Context.get` -> `_getImpl`: `if (strict && impl.fiber.state + * !== 2) return`), so a one-shot probe at activation time reports "no typert + * registry" on a host that has one. This script mounts the shipped code beside + * the shipped registry and checks both directions: + * + * A. registry first + `ctx.inject(['typert'])` -> must reach Remote + * B. registry first + a one-shot `ctx.get` -> must fail (the old bug) + * + * node scripts/verify-remote-channel.mjs [path/to/app.asar] + * + * Needs the harness archive (cordis + typert-registry + protocol + zod are read + * out of it). Without one it reports SKIPPED and exits 0; the structural gates + * in tests/typert.test.js still run everywhere. + */ +import { openSync, readSync, closeSync, mkdirSync, writeFileSync, mkdtempSync, existsSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname, posix } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const archive = process.argv[2] || process.env.DSH_ASAR || 'E:\\FXS\\APP\\DSH NEXT\\resources\\app.asar' +if (!existsSync(archive)) { + console.log('SKIPPED: no harness archive at ' + archive) + console.log('(pass it as argv[1] or set DSH_ASAR; tests/typert.test.js still covers the shape)') + process.exit(0) +} + +/** Read one package tree out of an Electron asar. */ +function extract(src, dest, pkgs) { + const fd = openSync(src, 'r') + const head = Buffer.alloc(16) + readSync(fd, head, 0, 16, 0) + const headerLen = head.readUInt32LE(12) + const jsonBuf = Buffer.alloc(headerLen) + readSync(fd, jsonBuf, 0, headerLen, 16) + const header = JSON.parse(jsonBuf.toString('utf8')) + // Data begins after the pickle whose size sits at offset 4 (already aligned). + const bases = [8 + head.readUInt32LE(4), 8 + ((headerLen + 4 + 3) & ~3), 16 + ((headerLen + 3) & ~3)] + const getEntry = (path) => path.split('/').reduce((n, p) => (n && n.files ? n.files[p] : undefined), header) + const readEntry = (entry) => { + const data = Buffer.alloc(entry.size) + for (const base of bases) { + readSync(fd, data, 0, entry.size, base + Number(entry.offset)) + const text = data.toString('utf8') + if (text.includes('\uFFFD')) continue + if (entry.size > 0 && !/^[\s\S]/.test(text)) continue + return data + } + return null + } + try { + for (const pkg of pkgs) { + const root = getEntry('node_modules/' + pkg) + if (!root || !root.files) throw new Error('package not in archive: ' + pkg) + const walk = (directory, rel) => { + for (const [name, entry] of Object.entries(directory.files || {})) { + const childRel = rel === '' ? name : rel + '/' + name + if (entry.files) { walk(entry, childRel); continue } + if (entry.unpacked || typeof entry.offset !== 'string') continue + const data = readEntry(entry) + if (data === null) throw new Error('undecodable: ' + pkg + '/' + childRel) + if (name.endsWith('.json')) JSON.parse(data.toString('utf8')) + const out = join(dest, 'node_modules', ...posix.normalize(pkg + '/' + childRel).split('/')) + mkdirSync(dirname(out), { recursive: true }) + writeFileSync(out, data) + } + } + walk(root, '') + } + } finally { + closeSync(fd) + } +} + +const work = mkdtempSync(join(tmpdir(), 'usage-remote-')) +extract(archive, work, [ + '@deepseek-ai/cordis', + '@deepseek-ai/cosmokit', + '@deepseek-ai/dsh-typert-registry', + '@deepseek-ai/dsh-typert-protocol', + 'zod', +]) + +const mods = (p) => pathToFileURL(join(work, 'node_modules', ...p.split('/'))).href +const cordis = await import(mods('@deepseek-ai/cordis/lib/index.js')) +const registry = await import(mods('@deepseek-ai/dsh-typert-registry/lib/index.js')) +const { registerTypert } = await import(pathToFileURL(join(here, '..', 'src', 'host', 'typert.js')).href) + +const Context = cordis.Context || cordis.default?.Context +const registryPlugin = registry.apply || registry.default +if (typeof Context !== 'function') throw new Error('cordis has no Context export') +if (typeof registryPlugin !== 'function') throw new Error('the typert registry has no apply()') + +const HANDLERS = new Map([ + ['query', (args) => ({ kind: 'query', args })], + ['prices', () => ({ prices: {} })], + ['pricing', (args) => ({ kind: 'pricing', args })], + ['contract', () => ({ package: 'dsh-plugin-usage', names: ['query', 'prices', 'pricing'] })], +]) +const tick = () => new Promise((resolve) => setTimeout(resolve, 250)) + +/** A. the shipped shape: declare the dependency, register when it is live. */ +const fixed = new Context() +fixed.plugin(registryPlugin) +const channel = { ok: false, transport: 'fetch', reason: 'pending' } +const usagePlugin = (ctx) => { + ctx.inject(['typert'], (typertCtx) => { + Object.assign(channel, registerTypert(typertCtx, HANDLERS, () => {})) + }) +} +usagePlugin.inject = [] +fixed.plugin(usagePlugin) +await tick() + +const typert = fixed.get('typert') +const face = typert && typert.local && typeof typert.local.get === 'function' + ? typert.local.get('dsh-plugin-usage/query') + : undefined +const receiver = fixed.get('dsh-plugin-usage') + +let failed = 0 +const check = (name, cond, detail) => { + console.log((cond ? ' ok ' : ' FAIL ') + name + (cond || detail === undefined ? '' : ' [' + detail + ']')) + if (!cond) failed += 1 +} + +check('the declared dependency activates the registration', channel.transport === 'remote', channel.reason) +check('the registry stores the face under /', Boolean(face), face === undefined ? 'not registered' : 'ok') +if (face !== undefined) { + check('the stored invocation names the package as its namespace', face.namespace === 'dsh-plugin-usage', String(face.namespace)) + check('the stored invocation exposes the endpoint as its method', face.method === 'query', String(face.method)) +} +check('the receiver service is provided on the app', Boolean(receiver), receiver === undefined ? 'missing' : 'ok') +check('the receiver carries the typertRemote binding', Boolean(receiver && receiver.typertRemote + && receiver.typertRemote.serviceKey === 'dsh-plugin-usage' + && receiver.typertRemote.namespace === 'dsh-plugin-usage')) + +/** B. the old bug: a one-shot probe while the registry has not activated yet. */ +const broken = new Context() +let probedEarly = true +const earlyPlugin = (ctx) => { + probedEarly = typeof ctx.get === 'function' ? ctx.get('typert') !== undefined : false +} +earlyPlugin.inject = [] +broken.plugin(earlyPlugin) +broken.plugin(registryPlugin) +await tick() +check('a one-shot probe before activation sees nothing (the bug this guards)', + probedEarly === false && broken.get('typert') !== undefined, 'probe=' + String(probedEarly)) + +rmSync(work, { recursive: true, force: true }) +console.log(failed === 0 ? 'ALL CHECKS PASSED' : failed + ' CHECK(S) FAILED') +process.exit(failed === 0 ? 0 : 1) diff --git a/scripts/verify-typert.mjs b/scripts/verify-typert.mjs new file mode 100644 index 0000000..4220420 --- /dev/null +++ b/scripts/verify-typert.mjs @@ -0,0 +1,98 @@ +/** + * Verify the plugin's typert contribution with the OFFICIAL validator. + * + * The rules a contribution must satisfy live in the shipped harness, so this + * script reads them from the installed archive instead of restating them: it + * extracts `@deepseek-ai/dsh-typert-loader/lib/index.js`, keeps the validation + * half (everything before `apply`), and runs `validateTypertManifest` on the + * contribution this plugin would register. + * + * node scripts/verify-typert.mjs [path/to/app.asar] + * + * When no archive is reachable the check reports SKIPPED (exit 0): a laptop + * without the harness installed cannot answer the question, and pretending it + * passed would be worse than saying so. CI runs the structural gate in + * tests/typert.test.js regardless. + */ +import { readFileSync, writeFileSync, mkdtempSync, existsSync, openSync, readSync, closeSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const archive = process.argv[2] || process.env.DSH_ASAR || 'E:\\FXS\\APP\\DSH NEXT\\resources\\app.asar' + +/** Read one entry out of an Electron asar (chromium pickle header). */ +function readAsarEntry(file, wanted, marker) { + const fd = openSync(file, 'r') + try { + const head = Buffer.alloc(16) + readSync(fd, head, 0, 16, 0) + const stringLen = head.readUInt32LE(12) + const json = Buffer.alloc(stringLen) + readSync(fd, json, 0, stringLen, 16) + const header = JSON.parse(json.toString('utf8')) + const entry = wanted.split('/').reduce((node, part) => { + if (!node || !node.files || !node.files[part]) throw new Error('missing ' + part + ' in ' + wanted) + return node.files[part] + }, header) + if (typeof entry.offset !== 'string') throw new Error('not a file: ' + wanted) + const data = Buffer.alloc(entry.size) + for (const base of [8 + ((stringLen + 4 + 3) & ~3), 16 + ((stringLen + 3) & ~3)]) { + readSync(fd, data, 0, entry.size, base + Number(entry.offset)) + const text = data.toString('utf8') + if (text.includes(marker)) { + const start = text.indexOf('/**') + return start > 0 ? text.slice(start) : text + } + } + throw new Error('could not decode ' + wanted) + } finally { + closeSync(fd) + } +} + +if (!existsSync(archive)) { + console.log('SKIPPED: no harness archive at ' + archive) + console.log('(pass it as argv[1] or set DSH_ASAR; the structural gate still runs in npm test)') + process.exit(0) +} + +const LOADER = 'node_modules/@deepseek-ai/dsh-typert-loader/lib/index.js' +let loaderSrc +try { + loaderSrc = readAsarEntry(archive, LOADER, 'function validateTypertManifest') +} catch (error) { + console.log('SKIPPED: this archive has no typert loader (' + String(error.message) + ')') + process.exit(0) +} + +// Keep the validation half only: `apply` needs the imports we are dropping. +const cut = loaderSrc.indexOf('async function apply') +if (cut < 0) throw new Error('could not find the apply() boundary in the official loader') +// `z` is only used to declare the plugin's config schema at module scope — a +// chainable no-op keeps that declaration harmless while the validator (which +// never touches zod) runs for real. +const zodShim = 'const z = new Proxy(function () {}, { get: () => () => z, apply: () => z });\n' +const validation = (zodShim + loaderSrc + .slice(0, cut) + .replace(/^import[^\n]*\n/gmu, '')) + .concat('\nexport { validateTypertManifest };\n') + +const dir = mkdtempSync(join(tmpdir(), 'usage-typert-')) +const modPath = join(dir, 'loader-validation.mjs') +writeFileSync(modPath, validation) +const { validateTypertManifest } = await import(pathToFileURL(modPath).href) + +const { USAGE_SERVICE, typertContribution } = await import(pathToFileURL(join(here, '..', 'src', 'host', 'typert.js')).href) + +const endpoints = ['query', 'prices', 'pricing', 'contract'] +try { + const contribution = typertContribution(endpoints) + validateTypertManifest(USAGE_SERVICE, contribution) + console.log('OK: the official loader accepts the contribution (' + endpoints.length + ' endpoints, namespace ' + USAGE_SERVICE + ')') +} catch (error) { + console.error('FAIL: the official loader rejected the contribution') + console.error(' ' + String((error && error.message) || error)) + process.exit(1) +} diff --git a/src/client/index.js b/src/client/index.js index d064193..0fd06e0 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -6,6 +6,7 @@ const CSS = ` /* ============ 布局 ============ */ .tsd-section { + position: relative; width: 100%; display: flex; flex-direction: column; @@ -13,6 +14,38 @@ const CSS = ` color: var(--dsw-alias-label-primary); animation: tsd-fade-in 0.4s var(--ds-ease-in-out); } +/* 刷新中的那条细进度条:贴在本节顶部,绝对定位所以不推动版面、不碰任何数字。 + 用它而不是把整块内容压暗 —— 已有数字本来就是对的,压暗只让人以为在重算。 */ +.tsd-progress { + position: absolute; + inset: -6px 0 auto 0; + height: 2px; + overflow: hidden; + border-radius: 2px; + background: color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent); + pointer-events: none; +} +.tsd-progress::after { + content: ''; + position: absolute; + inset: 0 auto 0 0; + width: 35%; + border-radius: inherit; + background: var(--dsw-alias-brand-primary); + animation: tsd-indeterminate 1.2s var(--ds-ease-in-out) infinite; +} +/* 知道分母时改成确定进度:宽度就是 done/total,不再来回扫 —— 用户看到的 + "算到哪了"是真的,而不是一个装忙的动画。 */ +.tsd-progress[data-determinate=true]::after { + width: var(--tsd-progress, 0%); + animation: none; + transform: none; + transition: width 240ms var(--ds-ease-in-out); +} +@keyframes tsd-indeterminate { + from { transform: translateX(-40%); } + to { transform: translateX(300%); } +} @keyframes tsd-fade-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } @@ -533,6 +566,49 @@ const CSS = ` text-overflow: ellipsis; white-space: nowrap; } +/* 标题带提示时给一个可发现的记号:虚线底边 + 感叹号按钮(见 HintTip)。 + 悬浮用原生 title,点击展开浮层;两段规则都在这一个组件里,别处不再各画一份。 */ +.tsd-card-label[title] { + cursor: help; + text-decoration: underline dotted; + text-underline-offset: 2px; +} +.tsd-hint { + display: inline-flex; + align-items: center; +} +.tsd-hint-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: help; +} +.tsd-hint-btn:hover, +.tsd-hint-btn-open { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} +.tsd-hint-btn:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} +/* 浮层里两段规则分段读;行的左右内边距由 .tsd-popover 给,这里只补段间距 */ +.tsd-hint-line { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-label-secondary); +} +.tsd-hint-line + .tsd-hint-line { + margin-top: 6px; +} .tsd-card-value { margin-top: 8px; font-size: 20px; @@ -552,8 +628,41 @@ const CSS = ` .tsd-card-note { margin-top: 4px; font-size: 11px; + color: var(--dsw-alias-label-tertiary); + line-height: 1.35; +} +/* 未定价是「少算了钱」,不是普通提示:其余脚注用三级灰,这一行用警示色 */ +.tsd-card-note-warn { color: var(--dsw-alias-state-warn-primary); } +.tsd-card-notes { + display: flex; + flex-direction: column; + gap: 2px; +} +/* 参考成本卡的入口按钮:跟着标题走,不抢数值行的位置 */ +.tsd-card-action { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 5px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} +.tsd-card-action:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} +.tsd-card-action:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} /* ============ 卡片头部(图表 / 表格共用) ============ */ .tsd-card-header { @@ -913,13 +1022,23 @@ const CSS = ` } /* ============ 刷新时的内容态 ============ */ +/* 不再 opacity: .55:那会让每次轮询都闪一下,且暗示"数字正在重算" —— + 事实上数字是上一轮的正确答案。刷新的信号交给顶部那条 2px 进度条与按钮转圈。 */ .tsd-content { display: flex; flex-direction: column; gap: 16px; - transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); } -.tsd-content[data-busy=true] { opacity: 0.55; } + +/* ============ 减少动态效果 ============ */ +/* 官方客户端同样这么写(spinner / skeletonFill 都在 prefers-reduced-motion 下关动画), + 我们这两个无限动画(呼吸、旋转)此前一条都没关。 */ +@media (prefers-reduced-motion: reduce) { + .tsd-section { animation: none; } + .tsd-spinning svg { animation: none; } + .tsd-skeleton, .tsd-skeleton-row { animation: none; } + .tsd-progress::after { animation: none; width: 100%; opacity: 0.55; } +} /* ============ 无障碍 ============ */ .tsd-visually-hidden { @@ -1143,6 +1262,394 @@ const CSS = ` font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary); } +/* ============ 定价匹配弹窗 ============ */ +/* 遮罩铺满视口:设置面板是 overflow:hidden 的裁剪容器,弹窗不能挂在面板内部, + 否则宽屏下左右各被切掉一块。层级压过 popover(40),低于任何系统级浮窗。 */ +.tsd-price-overlay { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.4)); +} +.tsd-price-dialog { + display: flex; + flex-direction: column; + width: min(760px, 100%); + max-height: 100%; + padding: 16px 18px 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 14px; + background: var(--dsw-alias-bg-layer-1); + box-shadow: var(--dsw-shadow-lv3); + color: var(--dsw-alias-label-primary); + font-size: 13px; +} +.tsd-price-head { + display: flex; + align-items: center; + gap: 8px; +} +.tsd-price-title { + margin: 0; + font-size: 15px; + font-weight: 600; +} +.tsd-price-close { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} +.tsd-price-close:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} +.tsd-price-desc { + margin: 6px 0 10px; + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-label-secondary); +} +/* 只有中段滚动:标题与底部按钮必须常驻,否则长表里够不到「确定」 */ +.tsd-price-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} +.tsd-price-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 12px; + margin-bottom: 8px; + font-size: 11px; + color: var(--dsw-alias-label-tertiary); +} +.tsd-price-counts { color: var(--dsw-alias-label-secondary); } +.tsd-price-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.tsd-price-table-sub { + margin-top: 10px; +} +.tsd-price-table th { + position: sticky; + top: 0; + z-index: 1; + padding: 6px 8px; + background: var(--dsw-alias-bg-layer-1); + border-bottom: 1px solid var(--dsw-alias-border-l2); + font-weight: 500; + font-size: 11px; + color: var(--dsw-alias-label-tertiary); + text-align: left; + white-space: nowrap; +} +.tsd-price-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + vertical-align: middle; +} +.tsd-price-row[data-dirty='true'] td { + background: var(--dsw-alias-interactive-bg-hover); +} +.tsd-price-model { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tsd-price-model-name { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + font-weight: 500; +} +.tsd-price-model-provider { + margin-left: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + color: var(--dsw-alias-label-tertiary); +} +/* 匹配目标格:整格是一颗紧凑的「选择」按钮(未匹配时虚线、文案是提示语), + 比每行摊两颗文字按钮安静得多;可搜索的下拉挂在它下面展开。 */ +.tsd-price-target { max-width: 230px; } +.tsd-price-target-btn { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 3px 6px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + background: var(--dsw-alias-bg-layer-2); + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 12px; + cursor: pointer; +} +.tsd-price-target-btn:hover { + border-color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-primary); +} +.tsd-price-target-btn:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} +.tsd-price-target-btn[data-empty='true'] { + border-style: dashed; + color: var(--dsw-alias-label-tertiary); +} +.tsd-price-target-id { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tsd-price-target-chev { flex: none; opacity: 0.7; } +/* 参考模型的来源标签:DeepSeek / Zen / 自定义。10px、无边框、只在格尾占一点点, + 免得把 id 挤掉 —— id 自己已经会 ellipsis。 */ +.tsd-price-src { + margin-left: 4px; + padding: 0 3px; + border-radius: 4px; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-tertiary); + font-size: 10px; + white-space: nowrap; +} +.tsd-price-src[data-source='deepseek'] { color: var(--dsw-static-blue-500); } +.tsd-price-src[data-source='custom'] { color: var(--dsw-static-green-500); } +/* 候选列表里的分组表头:粘在滚动容器顶部,滚到哪都知道自己在看哪一类。 */ +.tsd-price-pick-group { + position: sticky; + top: 0; + padding: 4px 4px 2px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-tertiary); + font-size: 10.5px; + letter-spacing: 0.02em; +} +/* 空组(目前只有「自定义」)给一行说明,别让这一类隐形。 */ +.tsd-price-pick-sep { + margin: 4px 0; + border-top: 1px solid var(--dsw-alias-border-l2); +} +/* 「自定义」选项:顶部固定的一条,带一行说明。 */ +.tsd-price-pick-custom { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + border: 1px dashed var(--dsw-alias-border-l2); +} +.tsd-price-pick-custom-label { font-weight: 500; color: var(--dsw-alias-label-primary); } +.tsd-price-pick-custom-hint { font-size: 11px; color: var(--dsw-alias-label-tertiary); } +/* 手填单价的三格:一行一个字段,输入框占满剩余宽度。 */ +.tsd-price-custom-box { gap: 8px; } +.tsd-price-custom-title { font-weight: 500; } +.tsd-price-custom-field { + display: flex; + align-items: center; + gap: 8px; +} +.tsd-price-custom-field > span { + flex: none; + width: 52px; + color: var(--dsw-alias-label-secondary); +} +.tsd-price-custom-input { + box-sizing: border-box; + flex: 1 1 auto; + min-width: 0; + height: 26px; + padding: 0 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 12px; +} +.tsd-price-custom-hint { color: var(--dsw-alias-label-tertiary); font-size: 11px; } +.tsd-price-stale { + margin-left: 6px; + font-size: 11px; + color: var(--dsw-alias-state-warn-primary); +} +.tsd-price-rate { + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: var(--dsw-alias-label-secondary); +} +/* 操作格:默认空着,只有「自定义 / 有改动」的行才给一颗还原图标。 + 注意别给 td 设 display:flex —— 那会把单元格块级化,整列失去对齐。 */ +.tsd-price-ops { + width: 34px; + text-align: right; + white-space: nowrap; +} +.tsd-price-reset { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + background: none; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} +.tsd-price-reset:hover { + border-color: var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-primary); +} +.tsd-price-reset:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} +.tsd-btn-xs { + height: 24px; + padding: 0 8px; + font-size: 12px; + border-radius: 6px; +} +/* 带图标的按钮:图标与文字同一行,间距由 gap 给,别在文案里塞空格 */ +.tsd-btn-icon-label { + display: inline-flex; + align-items: center; + gap: 6px; +} +/* 参考模型候选是一座浮层(DSH 自己的模型选择器就是这种交互),不再塞进表格里: + 内联面板会把所在行撑高、把下面的行整体推走,看起来像表格坏了。 */ +.tsd-price-pick-box { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 0; + padding: 6px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + background: var(--dsw-alias-bg-layer-1); + box-shadow: var(--dsw-shadow-lv3); +} +/* 四态徽标:精确(蓝) / 模糊(琥珀) / 未匹配(灰) / 自定义(绿),颜色族都用真实 token */ +.tsd-price-badge { + display: inline-block; + padding: 1px 7px; + border-radius: 10px; + font-size: 11px; + line-height: 16px; + white-space: nowrap; + background: var(--dsw-static-neutral-200, rgba(128, 128, 128, 0.14)); + color: var(--dsw-alias-label-secondary); +} +.tsd-price-badge[data-state='exact'] { + background: color-mix(in oklab, var(--dsw-static-deepseek-500) 16%, transparent); + color: var(--dsw-static-deepseek-500); +} +.tsd-price-badge[data-state='fuzzy'] { + background: color-mix(in oklab, var(--dsw-static-amber-500) 18%, transparent); + color: var(--dsw-static-amber-500); +} +.tsd-price-badge[data-state='custom'] { + background: color-mix(in oklab, var(--dsw-static-green-500) 16%, transparent); + color: var(--dsw-static-green-500); +} +/* 未匹配态没有主题色可用:容器那层 background: none 把徽标底色抹掉了,只剩 + 白字压在白底上。这里给它自己的底与字色,四态才都能看见。 */ +.tsd-price-row[data-state='none'] .tsd-price-badge { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} +.tsd-price-pick-input { + box-sizing: border-box; + width: 100%; + height: 28px; + padding: 0 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 12px; +} +/* 候选列表自己滚,且必须自己封顶:useFixedPanel 只给位置和宽度,不给高度, + 89 个 id 摊开有近 2000px,不封顶就是一个超出视口的浮层。 */ +.tsd-price-pick-list { + display: flex; + flex-direction: column; + min-height: 0; + max-height: 260px; + overflow: auto; +} +.tsd-price-pick-item { + padding: 4px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +.tsd-price-pick-item:hover { background: var(--dsw-alias-interactive-bg-hover); } +.tsd-price-pick-item-active { color: var(--dsw-static-deepseek-500); font-weight: 600; } +.tsd-price-history { + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid var(--dsw-alias-border-l2); +} +.tsd-price-history-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 0; + border: none; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 12px; + cursor: pointer; +} +.tsd-price-history-chev { transition: transform 0.15s ease; } +.tsd-price-history-chev-open { transform: rotate(180deg); } +.tsd-price-empty, +.tsd-price-loading { + padding: 18px 0; + font-size: 12px; + color: var(--dsw-alias-label-tertiary); + text-align: center; +} +.tsd-price-foot { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--dsw-alias-border-l2); +} +.tsd-price-hint { + margin-left: auto; + font-size: 11px; + color: var(--dsw-alias-state-warn-primary); +} ` // 0.2+ host transport: POST /api//, args as the JSON body, @@ -1167,43 +1674,147 @@ function callFetch(channel, endpoint, args) { } return res.json().then((body) => { if (body && body.ok) return body.value - throw new Error((body && body.error && body.error.message) || ('RPC ' + endpoint + ' failed')) + throw rpcError(body && body.error, 'RPC ' + endpoint + ' failed') }) }) } +/** + * 把宿主返回的 `{code, message}` 变成可显示的错误。 + * + * 宿主对未处理的异常只发稳定的 `internal`,细节写在宿主日志里;界面上显示本插件的 + * 一句话,而不是原始堆栈(原始错误不上页面)。 + */ +function rpcError(error, fallback) { + const code = error && error.code + const message = code === 'internal' ? fallbackT('rpcInternalError') : ((error && error.message) || fallback) + const wrapped = new Error(message) + if (code) wrapped.code = code + return wrapped +} + // RPC bridge: dynamic-plugin runs provide a `host` global; static (bundle) // installs provide ctx.get('connection'). Keep one UI source for both. +// +// Channel preference, native first: the plugin's own typert Remote namespace +// (`remote['dsh-plugin-usage']`, registered by src/host/typert.js), then the +// Fetch routes on Connection's shared /api channel, then the legacy /usage +// prefix channel. A Remote namespace that exists but fails (host restarted +// mid-flight, receiver gone) must fall through instead of bricking the page. +const USAGE_REMOTE_NAMESPACE = 'dsh-plugin-usage' + +/** + * Descriptors for the client-side mount. + * + * The gateway's browser half wants a `src-json` result (a direct invocation is + * not result-decoded), while the host contribution declares a strict codec. + * Everything else must match the host field for field. + */ +function remoteDescriptors(packageName, names) { + const argsCodec = () => ({ + mode: 'strict', + typeSymbol: packageName + '/typert#EndpointArgs', + create: () => ({ parse: (value) => value }), + }) + return names.map((name) => ({ + id: packageName + '#' + packageName + '/' + name, + service: packageName, + namespace: packageName, + method: name, + invocation: { kind: 'direct' }, + parameters: [{ name: 'args', wire: 'args', source: 'json', codec: argsCodec() }], + result: { mode: 'src-json' }, + })) +} + +/** + * Join the native Remote channel on the browser side. + * + * A face registered at runtime on the host is invisible to the client's `remote` + * proxy until the consumer mounts it. Until the mount lands — or if it never + * does — hostCall keeps using the Fetch routes, so this is an optimization, + * never a hard dependency. + */ +function mountRemoteChannel(scope) { + let remote = null + try { + remote = scope.get('remote') + } catch (e) { + return + } + if (!remote || typeof remote.$mount !== 'function') return + fetchCall('usage/contract', {}).then((contract) => { + const names = contract && Array.isArray(contract.names) ? contract.names : [] + if (names.length === 0) return null + const packageName = contract.package || USAGE_REMOTE_NAMESPACE + return Promise.resolve(remote.$mount({ package: packageName, descriptors: remoteDescriptors(packageName, names) })) + }).catch(() => { + // Stay on the Fetch routes; the host reports which channel it opened. + }) +} + +function remoteCall(scope, method, args) { + const name = String(method) + const slash = name.indexOf('/') + const endpoint = slash === -1 ? name : name.slice(slash + 1) + const camel = endpoint.replace(/-([a-z])/g, (m, c) => c.toUpperCase()) + for (const service of [USAGE_REMOTE_NAMESPACE, 'usage']) { + // `ctx.get('remote.')` is the service key the gateway creates per + // face (dsh-api-gateway client: remoteServiceKey). The `remote[namespace]` + // property is the older shape and throws on the strict proxy, so it is a + // guarded second choice. + let namespace = null + try { + namespace = scope.get('remote.' + service) + } catch (e) { + namespace = null + } + if (!namespace) { + try { + const remote = scope.get('remote') + namespace = remote ? remote[service] : null + } catch (e) { + namespace = null + } + } + if (!namespace) continue + const fn = (typeof namespace[camel] === 'function' && namespace[camel]) + || (typeof namespace[endpoint] === 'function' && namespace[endpoint]) + if (!fn) continue + return Promise.resolve() + .then(() => fn(args)) + .then((res) => { + if (res && res.ok) return res.value + throw rpcError(res && res.error, 'RPC ' + method + ' failed') + }) + } + return null +} + function hostCall(method, args) { - // P0-3: prefer the official `remote` service when it exposes the usage API; - // otherwise fall through to the host/connection fallbacks unchanged. const remoteScope = (typeof ctx !== 'undefined' && ctx && typeof ctx.get === 'function') ? ctx : (typeof _usageCtx !== 'undefined' && _usageCtx && typeof _usageCtx.get === 'function' ? _usageCtx : null) if (remoteScope && typeof remoteScope.get === 'function') { + let pending = null try { - const remote = remoteScope.get('remote') - if (remote && remote.usage) { - const rName = String(method) - const rSlash = rName.indexOf('/') - const rEp = rSlash === -1 ? rName : rName.slice(rSlash + 1) - const rCamel = rEp.replace(/-([a-z])/g, (m, c) => c.toUpperCase()) - const fn = (typeof remote.usage[rCamel] === 'function' && remote.usage[rCamel]) - || (typeof remote.usage[rEp] === 'function' && remote.usage[rEp]) - if (fn) { - return Promise.resolve().then(() => fn(args)).then((res) => { - if (res && res.ok) return res.value - throw new Error((res && res.error && res.error.message) || ('RPC ' + method + ' failed')) - }) - } - } + pending = remoteCall(remoteScope, method, args) } catch (e) { - // remote probing failed — fall through to the existing fallbacks + pending = null + } + if (pending !== null) { + // Fall through to the Fetch routes when the Remote namespace turns out to + // be unreachable — the two channels answer the same question. + return pending.catch(() => fetchCall(method, args)) } } if (typeof host !== 'undefined' && host && typeof host.call === 'function') { return Promise.resolve(host.call(method, args)) } + return fetchCall(method, args) +} + +function fetchCall(method, args) { // Static bundle installs have no ctx/host globals: the entry apply() stores // the real context in _usageCtx (declared by the build wrapper). const scope = (typeof ctx !== 'undefined' && ctx && typeof ctx.get === 'function') @@ -1225,7 +1836,7 @@ function hostCall(method, args) { } return connection.rpc.call('/' + channel.replace(/^\/+/, ''), endpoint, args).then((res) => { if (res && res.ok) return res.value - throw new Error((res && res.error && res.error.message) || ('RPC ' + method + ' failed')) + throw rpcError(res && res.error, 'RPC ' + method + ' failed') }) } // Prefer the shared-channel Fetch route; an older host answers "route @@ -1243,6 +1854,20 @@ function hostCall(method, args) { * 设置域定价:官方 `ctx.configForms.get('usage')`(dsh-client-ui-settings)优先, * 不可用时回退 usage/prices RPC。绑定只做一次,读写共用同一 form。 * ------------------------------------------------------------------------- */ +/** + * 原生日志通道:宿主有 logger 就写它(官方插件都走 ctx.logger),否则退回 console。 + * ctx 是严格代理,未注入的属性能抛,所以整段包在 try 里。 + */ +function logWarn(message, detail) { + try { + const scope = (typeof _usageCtx !== 'undefined' && _usageCtx) ? _usageCtx : null + const logger = scope && typeof scope.get === 'function' ? scope.get('logger') : null + if (logger && typeof logger.warn === 'function') { logger.warn(message, detail); return } + if (scope && scope.logger && typeof scope.logger.warn === 'function') { scope.logger.warn(message, detail); return } + } catch (e) { /* no logger on this composition */ } + console.warn(message, detail) +} + let usageCtx = null let _usagePricesScope = null @@ -1342,6 +1967,55 @@ const ZH = { cost: '参考成本', approxIO: '≈ 输入+输出', unpricedNote: '{count} 次调用未定价', + // 参考成本卡上的常驻脚注:口径(卡片是估算而不是账单)+ 高峰规则(单价按 + // 北京时间分峰谷,不说清楚就等于藏了一条计价条件)。 + costSourceNote: '按 opencode Zen 和 DeepSeek 最新定价口径估算', + peakRuleShort: '北京时间周一至周五 9:00-12:00、14:00-18:00 为高峰,法定节假日除外', + holidayNote: '节假日数据:{range}', + holidayNoteUnpublished: '节假日数据:{range};{years} 尚未公布,该年按周末判定(落在高峰窗口的节假日可能被算高)', + pricingOpen: '拉取最新定价匹配', + pricingTitle: '定价匹配', + pricingDesc: '本会话出现过的模型会按名称逐级匹配到定价表;未匹配的按 0 计价,计入「未定价」。', + pricingPull: '更新匹配表', + pricingHistory: '历史匹配({n})', + pricingHistoryEmpty: '还没有确认过的匹配', + pricingColModel: '模型', + pricingColMatch: '匹配到', + pricingColState: '状态', + pricingColPrice: '单价({unit} / 1M)', + pricingSave: '确定', + pricingClear: '恢复自动匹配', + pricingApplyHint: '改完点「确定」才生效', + pricingHowAlias: '按别名', + // 模糊态的徽标:别名、孪生、变体后缀(-fast / -1017 / -free)都落在这一态, + // 所以文案说「推测」而不是某一种推断的名字;具体依据看徽标的 tooltip。 + pricingHowTwin: '按名称推测', + pricingHowLiteral: '按名称精确', + pricingHowCustom: '用户指定', + // 未匹配态的徽标文案。原来 MATCH_STATE_LABEL 指向一个不存在的键 + // 'unmatched',t() 缺键时原样返回 key,于是徽标上印着英文 "unmatched"。 + pricingHowNone: '未匹配', + pricingStale: '定价表里已没有这个模型', + pricingTableMeta: '数据源 {source} · 读取于 {date}', + pricingCounts: '精确 {exact} · 模糊 {fuzzy} · 未匹配 {none} · 自定义 {custom}', + pricingBothTiers: '高峰 {peak} · 空闲 {off}', + pricingSourceDeepseek: 'DeepSeek', + pricingSourceZen: 'Zen', + pricingSourceCustom: '自定义', + // 「自定义」是选择器顶部的一个选项,不是一组参考模型:选它就直接给这一行填三个单价。 + pricingCustomOption: '自定义', + pricingCustomHint: '直接填每百万 Token 的单价', + pricingCustomInput: '输入', + pricingCustomOutput: '输出', + pricingCustomCacheRead: '缓存读', + pricingCustomUnit: '单位:{unit} / 1M Token', + pricingCustomPending: '待填写', + rpcInternalError: '统计服务出错了,细节已写进宿主日志', + done: '完成', + pricingCardNoteTip: '参考成本的算法、匹配范围与高峰时段规则', + askEmpty: '暂无待匹配的模型', + askPick: '选择参考模型', + pricingFailed: '定价表拉取失败:{message}', dailyTrend: '每日用量趋势', barView: '柱状图', lineView: '折线图', @@ -1377,14 +2051,16 @@ const ZH = { unpriced: '未定价', unpricedRowHint: '该模型未配置定价,按 0 计价', noData: '暂无数据', + loading: '正在加载用量数据…', + computing: '正在统计 {done}/{total} 个会话…', emptyDesc: '该时间段内没有调用记录', emptyFiltered: '当前筛选条件下没有数据,试试调整时间范围或模型', resetFilters: '重置筛选', loadFailed: '数据加载失败:{message}', loadFailedTitle: '数据加载失败', retry: '重试', - barAria: '{key},输入 {in} Token,输出 {out} Token,合计 {tot} Token', - hmTitle: '{key}:{tokens} Token{calls}', + barAria: '{key},输入 {in} Token,输出 {out} Token,合计 {tot} Token,参考成本 {cost}', + hmTitle: '{key}:{tokens} Token{calls},参考成本 {cost}', hmCalls: ',{count} 次调用', wd0: '周日', wd1: '周一', @@ -1433,6 +2109,48 @@ const EN = { cost: 'Est. cost', approxIO: '≈ input+output', unpricedNote: '{count} calls unpriced', + costSourceNote: 'Estimated from the current opencode Zen and DeepSeek price lists', + peakRuleShort: 'Peak: Mon-Fri 09:00-12:00 and 14:00-18:00 Beijing time, excluding public holidays', + holidayNote: 'Holiday calendar: {range}', + holidayNoteUnpublished: 'Holiday calendar: {range}; {years} is not published yet, so that year falls back to the weekend rule (a holiday inside a peak window may be billed high)', + pricingOpen: 'Pull the latest price matching', + pricingTitle: 'Price matching', + pricingDesc: 'Models seen in this session are matched to the price list by name, one level at a time. Anything unmatched is billed at 0 and listed under unpriced.', + pricingPull: 'Update matches', + pricingHistory: 'Confirmed matches ({n})', + pricingHistoryEmpty: 'No match has been confirmed yet', + pricingColModel: 'Model', + pricingColMatch: 'Matched to', + pricingColState: 'State', + pricingColPrice: 'Rate ({unit} / 1M)', + pricingSave: 'Apply', + pricingClear: 'Back to automatic', + pricingApplyHint: 'Nothing changes until you press Apply', + pricingHowAlias: 'by alias', + pricingHowTwin: 'inferred from the name', + pricingHowLiteral: 'by exact name', + pricingHowCustom: 'set by you', + pricingHowNone: 'unmatched', + pricingStale: 'This model is no longer in the price list', + pricingTableMeta: 'Sources {source} · read {date}', + pricingCounts: 'exact {exact} · fuzzy {fuzzy} · unmatched {none} · custom {custom}', + pricingBothTiers: 'Peak {peak} · off-peak {off}', + pricingSourceDeepseek: 'DeepSeek', + pricingSourceZen: 'Zen', + pricingSourceCustom: 'Custom', + pricingCustomOption: 'Custom', + pricingCustomHint: 'Type the per-million rates by hand', + pricingCustomInput: 'Input', + pricingCustomOutput: 'Output', + pricingCustomCacheRead: 'Cache read', + pricingCustomUnit: 'Unit: {unit} per 1M tokens', + pricingCustomPending: 'not set', + rpcInternalError: 'The usage service failed — details are in the host log', + done: 'Done', + pricingCardNoteTip: 'How the reference cost is estimated, which models it covers, and the peak-hour rule', + askEmpty: 'No model is waiting to be matched', + askPick: 'Choose a reference model', + pricingFailed: 'Could not load the price lists: {message}', dailyTrend: 'Daily usage trend', barView: 'Bars', lineView: 'Line', @@ -1468,14 +2186,16 @@ const EN = { unpriced: 'Unpriced', unpricedRowHint: 'No pricing configured; counted as 0', noData: 'No data', + loading: 'Loading usage data…', + computing: 'Computing {done}/{total} sessions…', emptyDesc: 'No calls in this period', emptyFiltered: 'No data for the current filters, try adjusting the range or models', resetFilters: 'Reset filters', loadFailed: 'Failed to load data: {message}', loadFailedTitle: 'Failed to load data', retry: 'Retry', - barAria: '{key}, input {in} tokens, output {out} tokens, total {tot} tokens', - hmTitle: '{key}: {tokens} tokens{calls}', + barAria: '{key}, input {in} tokens, output {out} tokens, total {tot} tokens, {cost} reference cost', + hmTitle: '{key}: {tokens} tokens{calls}, {cost} reference cost', hmCalls: ', {count} calls', wd0: 'Sun', wd1: 'Mon', @@ -1662,6 +2382,31 @@ const ICON_PATHS = { // 饼图(占比):外圈 + 楔形,与卡片标题的「占比」语义一致 share: 'M21.21 15.89A10 10 0 118 2.83M22 12A10 10 0 0012 2v10z', check: 'M20 6L9 17l-5-5', + x: 'M6 6l12 12M18 6L6 18', + // 拉取匹配表按钮的图标:DSH 原生 icon-context-injection-outline。16 网格、 + // 线宽 1.5;图形是「剪贴板 + 从顶部穿入的下载箭头 + 一条内容线」,描边沿用 + // 外层的 currentColor,所以每条 path 只给 d。 + contextInjection: { + viewBox: '0 0 16 16', + strokeWidth: 1.5, + paths: [ + 'M5 2.5H3.5C3.23478 2.5 2.98043 2.60536 2.79289 2.79289C2.60536 2.98043 2.5 3.23478 2.5 3.5V13.5C2.5 13.7652 2.60536 14.0196 2.79289 14.2071C2.98043 14.3946 3.23478 14.5 3.5 14.5H12.5C12.7652 14.5 13.0196 14.3946 13.2071 14.2071C13.3946 14.0196 13.5 13.7652 13.5 13.5V3.5C13.5 3.23478 13.3946 2.98043 13.2071 2.79289C13.0196 2.60536 12.7652 2.5 12.5 2.5H11', + 'M8 0.5V7.5', + 'M5.5 5L8 7.5L10.5 5', + 'M5.5 11H10.5', + ], + }, + // 提示记号:圆圈 + 感叹号(16 网格、线宽 1.5)。原来用一个 CSS 伪元素画「?」, + // 字重太小、缩到 10px 后几乎看不见,也不能点;换成真图标后可以当按钮用。 + hint: { + viewBox: '0 0 16 16', + strokeWidth: 1.5, + paths: [ + 'M8 1.75a6.25 6.25 0 1 1 0 12.5a6.25 6.25 0 1 1 0-12.5Z', + 'M8 4.9v3.5', + 'M8 11.15h.01', + ], + }, } function SvgIcon(props) { const { name, size, className } = props @@ -1755,18 +2500,25 @@ function useFixedPanel(anchorRef, open, width, gap = 6, margin = 12) { * Header:标题 + 副标题 + 刷新按钮 * ------------------------------------------------------------------------- */ function StatsHeader(props) { - const { busy, onRefresh } = props + // `computing` 表示"统计还没算完"(宿主 backfill.status === 'running'), + // 和 `busy`(这一轮请求在飞)不是一回事:请求几十毫秒就结束,而全量统计要几秒。 + // 只在请求在飞时转圈,用户就会看到"页面明明没算完却什么都不动"。 + const { busy, onRefresh, computing, computeDone, computeTotal } = props const t = useT() + const subtitle = computing + ? t('computing', { done: formatInt(computeDone || 0), total: formatInt(computeTotal || 0) }) + : t('subtitle') return h('div', { className: 'tsd-header' }, h('div', { className: 'tsd-header-text' }, h('h2', { className: 'tsd-title' }, t('nav')), - h('p', { className: 'tsd-subtitle' }, t('subtitle'))), + // 副标题这一句直接换成进度句:同一个位置、同样的行高,所以不算完→算完不跳版。 + h('p', { className: 'tsd-subtitle', role: computing ? 'status' : null }, subtitle)), h('button', { type: 'button', - className: 'tsd-icon-btn' + (busy ? ' tsd-spinning' : ''), + className: 'tsd-icon-btn' + (busy || computing ? ' tsd-spinning' : ''), onClick: onRefresh, disabled: busy, - 'aria-busy': busy || undefined, + 'aria-busy': busy || computing || undefined, title: t('refresh'), 'aria-label': t('refresh'), }, h(SvgIcon, { name: 'refresh' }))) @@ -2073,6 +2825,20 @@ function shortModelName(key) { const i = s.lastIndexOf('/') return i === -1 ? s : s.slice(i + 1) } +/** + * 「读取于」要的是真实时刻,不是日期。 + * + * 价目表里存的是抓取那一刻的 ISO 时间(带秒、UTC);这里换成浏览器本地时区 + * 显示,精确到秒。早期只有日期(`2026-10-19`)的表也认得,补 00:00:00。 + */ +function stampText(v) { + if (!v) return '—' + const d = new Date(v) + if (Number.isNaN(d.getTime())) return String(v) + const p = (n) => (n < 10 ? '0' + n : String(n)) + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()) +} /** 宿主行是 `totalTokens`;兼容已映射成 `total` 的形状,空行也不能抛。 */ function rowTotal(r) { return r ? (r.totalTokens || r.total || 0) : 0 @@ -2130,7 +2896,7 @@ function ModelShareBar(props) { const shown = (tip && find(tip.key)) || occ.segs[0] const labelOf = (s) => (s.key === SHARE_REST_KEY ? t('shareOther') : s.label) const fullOf = (s) => (s.key === SHARE_REST_KEY ? t('shareOther') : s.key) - const costOf = (s) => (s.tokens > 0 && !(s.cost > 0) ? t('unpriced') : formatCost(s.cost, currency)) + const costOf = (s) => (s.unpriced === true ? t('unpriced') : formatCost(s.cost, currency)) // 悬停/聚焦:从段的视口矩形推开,fixed 定位不受设置面板 overflow 裁剪 const showTip = (e, s) => { const r = e.currentTarget.getBoundingClientRect() @@ -2180,11 +2946,76 @@ function ModelShareBar(props) { tipBox) } +/* --------------------------------------------------------------------------- + * 标题旁的提示记号 + * + * 「点击 / 悬浮双显示」是硬要求: + * - 悬浮走原生 title(浏览器自己弹,不依赖脚本,也不会被设置面板的 overflow 裁掉); + * - 点击展开同一个浮层 —— 触屏没有 hover,键盘也要能读到,且浮层里两段规则换行 + * 读起来比一整行 tooltip 清楚得多。 + * 原生 title 与浮层共用同一份文本(宿主/调用方只传一次),不维护第二份文案。 + * ------------------------------------------------------------------------- */ +function HintTip(props) { + const { text, label } = props + const [open, setOpen] = React.useState(false) + const ref = React.useRef(null) + const panelStyle = useFixedPanel(ref, open, 260) + useOutsideClick(open, ref, () => setOpen(false)) + const lines = String(text == null ? '' : text).split('\n').filter((s) => s.trim().length > 0) + return h('span', { className: 'tsd-hint', ref }, + h('button', { + type: 'button', + className: 'tsd-hint-btn' + (open ? ' tsd-hint-btn-open' : ''), + title: text || null, + 'aria-label': label || text || '', + 'aria-expanded': open, + onClick: () => setOpen((v) => !v), + onKeyDown: (e) => { + if (e.key === 'Escape' && open) { e.stopPropagation(); setOpen(false) } + }, + }, h(SvgIcon, { name: 'hint', size: 12 })), + open + ? h('div', { + className: 'tsd-popover tsd-hint-popover', + style: panelStyle, + role: 'note', + }, lines.map((s, i) => h('p', { key: i, className: 'tsd-hint-line' }, s))) + : null) +} + /* --------------------------------------------------------------------------- * 汇总指标卡 ×6 * ------------------------------------------------------------------------- */ + +/** + * 节假日覆盖说明:哪些年份在手、哪一年还没公布。 + * + * 峰谷价依赖中国法定节假日表;未公布的年份只能按「周末 + 时段」判定,节假日 + * 落在高峰窗口内的调用会被算高。与其让这个误差隐身,不如在「参考成本」的提示 + * 里把覆盖范围写清楚。 + */ +function holidayNote(pricing, t) { + const years = pricing && pricing.holidays && typeof pricing.holidays === 'object' + ? Object.keys(pricing.holidays).map(Number).filter((n) => Number.isFinite(n)).sort((a, b) => a - b) + : [] + if (years.length === 0) return null + const range = years.length === 1 ? String(years[0]) : years[0] + '–' + years[years.length - 1] + const missing = years.filter((year) => pricing.holidays[year] && pricing.holidays[year].unpublished === true) + if (missing.length === 0) return t('holidayNote', { range }) + return t('holidayNoteUnpublished', { range, years: missing.join('、') }) +} + +/* 统计卡的槽位表:骨架屏按它画占位块。 + * + * 这张表存在的唯一理由是**别再漂**:骨架屏曾经写死 5 个占位块,而真实卡片已经 + * 6 张(补了「模型占比」),结果加载态少一格、数据到达时整行重排。 + * 槽位顺序必须与 `SummaryCards` 里的 `cards` 数组一致 —— `tests/client-slots.test.js` + * 会逐字比对两边的 key 列表,改一处漏另一处直接变红。 + */ +const SUMMARY_CARD_SLOTS = ['total', 'input', 'output', 'calls', 'cost', 'share'] + function SummaryCards(props) { - const { total, currency, unpricedCalls, currencyCode, onCurrencyChange, byModel } = props + const { total, currency, unpricedCalls, currencyCode, onCurrencyChange, byModel, pricing, onOpenPricing } = props const tt = useT() const d = total || {} const inputTotal = (d.inputTokens || 0) + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0) @@ -2210,11 +3041,15 @@ function SummaryCards(props) { color: 'var(--dsw-static-deepseek-500)', }, { - key: 'cost', icon: 'coins', label: tt('cost'), + key: 'cost', icon: 'coins', label: tt('cost'), action: true, // 符号由 prefix 的币种切换器画(它显示当前币种符号),这里只给数字 value: costDigits(d.cost), title: formatCost(d.cost, currency), color: 'var(--dsw-static-green-500)', prefix: h(CurrencyPicker, { code: currencyCode, onChange: onCurrencyChange }), + // 「这个数怎么来的」:口径句与高峰规则原本是两行常驻小字,把小卡挤满了, + // 改成标题的悬浮提示 —— 不在视野里占位,指一下就能读到全文。 + // 未定价条数仍然留在卡面上:它是一条待办,不是解释。 + tip: [tt('costSourceNote'), tt('peakRuleShort'), holidayNote(pricing, tt)].filter(Boolean).join('\n'), }, { key: 'share', icon: 'share', label: tt('modelShare'), @@ -2228,21 +3063,602 @@ function SummaryCards(props) { h('div', { className: 'tsd-card-head' }, h('span', { className: 'tsd-card-icon', style: { color: c.color } }, h(SvgIcon, { name: c.icon, size: 14 })), - h('span', { className: 'tsd-card-label' }, c.label)), + h('span', { className: 'tsd-card-label', title: c.tip || null }, c.label), + // 提示记号是独立按钮:悬浮有原生 title,点击展开浮层。 + c.tip ? h(HintTip, { text: c.tip, label: tt('pricingCardNoteTip') }) : null, + // 参考成本卡的「怎么算出来的」入口:打开匹配弹窗。图标与弹窗里的 + // 「更新匹配表」同款(剪贴板),一眼看出两者是同一件事。 + c.action + ? h('button', { + type: 'button', + className: 'tsd-card-action', + title: tt('pricingOpen'), + 'aria-label': tt('pricingOpen'), + onClick: onOpenPricing, + }, h(SvgIcon, { name: 'contextInjection', size: 12 })) + : null), c.content ? c.content : h('div', { className: 'tsd-card-value', title: c.title }, c.prefix || null, c.value), - c.sub ? h('div', { className: 'tsd-card-sub' }, c.sub) : null, + // 参考成本卡的脚注:口径与高峰规则已移到标题的悬浮提示,这里只剩 + // 「还有几次调用没定价」这条待办(有事才出现)。 c.key === 'cost' && unpricedCalls > 0 - ? h('div', { className: 'tsd-card-note' }, tt('unpricedNote', { count: formatInt(unpricedCalls) })) - : null))) + ? h('div', { className: 'tsd-card-notes' }, + h('div', { className: 'tsd-card-note tsd-card-note-warn' }, tt('unpricedNote', { count: formatInt(unpricedCalls) }))) + : null, + c.sub ? h('div', { className: 'tsd-card-sub' }, c.sub) : null))) +} + +/* --------------------------------------------------------------------------- + * 定价匹配弹窗 + * + * 解决的是「参考成本为什么是这个数」:每个模型按名称逐级匹配到定价表,匹配 + * 结果分四态(精确 / 模糊 / 未匹配 / 自定义),四态都能改、都能收回。 + * + * 两条硬规则写在这里,别处不要各写一份: + * 1. 打开弹窗、拉取匹配表都**不落盘**。只有「确定」才写,写入的是脏行和被 + * 收回的 key —— 没动过的行不写,免得把别人刚确认的匹配覆盖掉。 + * 2. 候选来自当前定价表的 id(宿主 propose 一并带回),不在前端另存一份名单。 + * ------------------------------------------------------------------------- */ +const MATCH_STATE_LABEL = { exact: 'pricingHowLiteral', fuzzy: 'pricingHowTwin', none: 'pricingHowNone', custom: 'pricingHowCustom' } + +/* + * 四态由宿主给(priceRow.state),前端不再解释 how。 + * 这里曾经有一份 matchStateOf(row),把 how 映射成四态;它写死了一个阶梯里不存在 + * 的值('literal'),于是所有精确命中都显示成「未匹配」——两个词表,两处维护, + * 一处错就全错。现在只有一处:pricing.js 的 resolvePrice。 + */ + +/** 弹窗里改动的草稿:key → { target, cleared, dirty }。 */ +function mergeDraft(prev, key, patch) { + const next = { ...prev } + const cur = next[key] || { target: null, cleared: false, dirty: false } + next[key] = { ...cur, ...patch, dirty: true } + return next +} + +/** 一行提交时会带什么:`undefined` = 没动过,不提交。 */ +function decisionOf(draft) { + if (!draft || !draft.dirty) return undefined + if (draft.cleared) return { key: draft.key, clear: true } + if (typeof draft.target === 'string' && draft.target !== '') return { key: draft.key, target: draft.target } + return undefined +} + +function PricingDialog(props) { + const { onClose, onApplied, currency } = props + const t = useT() + const [rows, setRows] = React.useState([]) + const [table, setTable] = React.useState(null) + const [saved, setSaved] = React.useState({}) + const [draft, setDraft] = React.useState({}) + const [status, setStatus] = React.useState('loading') // loading | ready | error + const [error, setError] = React.useState(null) + const [saving, setSaving] = React.useState(false) + const [pickFor, setPickFor] = React.useState(null) // 正在选参考模型的 key + const [pickText, setPickText] = React.useState('') + const [editFor, setEditFor] = React.useState(null) // 正在手填单价的 key(自定义选项) + const [historyOpen, setHistoryOpen] = React.useState(false) + // 候选列表挂在行内「匹配到」那一格上:浮层是 position: fixed,撑不开表格; + // 但 DOM 上仍留在那一格里 —— useOutsideClick 用 contains 判内外,必须这样挂。 + const pickAnchor = React.useRef(null) + const closePick = React.useCallback(() => { + setPickFor(null) + setPickText('') + }, []) + const pickStyle = useFixedPanel(pickAnchor, pickFor !== null, 300, 4, 12) + const customStyle = useFixedPanel(pickAnchor, editFor !== null, 280, 4, 12) + useOutsideClick(pickFor !== null, pickAnchor, closePick) + + const pull = React.useCallback(() => { + setStatus('loading') + setError(null) + hostCall('usage/query', { fill: false, models: [], heatmap: false, backfill: false }) + .then((r) => { + const seen = Array.isArray(r && r.models) ? r.models : [] + return hostCall('usage/pricing', { action: 'propose', models: seen }) + }) + .then((v) => { + setRows(Array.isArray(v && v.rows) ? v.rows : []) + setTable(v && v.table ? v.table : null) + setSaved(v && v.matchMap ? v.matchMap : {}) + setDraft({}) + setStatus('ready') + }, (e) => { + setError(e) + setStatus('error') + }) + }, []) + + React.useEffect(() => { pull() }, [pull]) + + const counts = React.useMemo(() => { + const n = { exact: 0, fuzzy: 0, none: 0, custom: 0 } + // 每一行都自带 state(宿主 priceRow 给的),前端不再从 how 反推 —— 反推过 + // 一次,写死的 'literal' 阶梯里根本不存在,所有精确命中都显示成「未匹配」。 + for (const r of rows) n[r.state] += 1 + return n + }, [rows]) + + // 单价本身是美元价(价目表就是美元),显示时套用当前展示币种,和卡片同一个数。 + const moneyPerUsd = React.useCallback(() => (currency && currency.code === 'USD' ? 1 : 7), [currency]) + const moneyOf = React.useCallback((usd) => { + const code = currency && currency.code === 'USD' ? 'USD' : 'CNY' + const symbol = currency && currency.symbol ? currency.symbol : (CURRENCY_SYMBOLS[code] || '¥') + return typeof usd === 'number' ? formatCost(usd * (code === 'USD' ? 1 : 7), symbol) : '—' + }, [currency]) + // 表头单位必须和单元格用的是同一个符号,否则会出现「单价($ / 1M)」配「¥2.10」。 + const priceUnit = (currency && currency.symbol) || '¥' + + // 待选 = 阶梯没找到价的行。判据用 state 而不是 unpriced:`unpriced` 是查询那边 + // 「某条记录没价」的字段,propose 的行从来没有它 —— 拿它当过滤条件的后果是待选 + // 表永远是空的,未匹配的模型一行都露不出来。 + const pending = rows.filter((r) => r.state === 'none') + // 历史表 = 已经写进 matchMap 的 confirmed 记录。判据只看 matchMap:目标若已从 + // 价目表下线,这一行仍然留在这里(还能收回),主表里则按「未匹配」显示并标 stale。 + const historyRows = rows.filter((r) => saved[r.key] !== undefined) + const pendingRows = pending.filter((r) => !historyRows.includes(r)) + const matchedRows = rows.filter((r) => r.state !== 'none' && !historyRows.includes(r)) + // 空状态判的是主表有没有行。此前判的是 `pending.length` —— 只要有模型按阶梯 + // 命中了价,第一张表就整块消失,于是「精确 / 模糊」两种状态永远没机会露面。 + const hasMainRows = pendingRows.length > 0 || matchedRows.length > 0 + + // 注意:组件里的 `setDraft` 是 useState 的 setter,不是上面那个纯函数。 + // 写成 setDraft((prev) => setDraft(prev, ...)) 会递归调自己(初值 undefined), + // 所以这里用纯函数的名字 `mergeDraft` 区分开。 + const choose = (key, target) => { + setDraft((prev) => mergeDraft(prev, key, { target, cleared: false, custom: null, key })) + setEditFor(null) + closePick() + } + const clearRow = (key) => { + setDraft((prev) => mergeDraft(prev, key, { target: null, cleared: true, custom: null, key })) + setEditFor((k) => (k === key ? null : k)) + } + + /** 手填单价:三格,都是「每百万 Token」的展示币种金额。 */ + const CUSTOM_FIELDS = [['input', 'pricingCustomInput'], ['output', 'pricingCustomOutput'], ['cacheRead', 'pricingCustomCacheRead']] + /** 某个 key 当前的手填值(草稿优先,其次宿主已有的自定义价)。 */ + const customOf = (r) => { + const d = draft[r.key] + if (d && d.custom) return d.custom + // 宿主已按自定义价计过账:用它当前的费率回填,方便改。 + if (r.how === 'custom' && r.rates) { + const per = moneyPerUsd() + const at = r.rates.peak || r.rates.offPeak + return { + input: String(Number((at.input * per).toFixed(4))), + output: String(Number((at.output * per).toFixed(4))), + cacheRead: String(Number((at.cacheRead * per).toFixed(4))), + } + } + return { input: '', output: '', cacheRead: '' } + } + /** 开始手填:把这一行切成编辑态,草稿里先放一份可编辑的值。 */ + const startCustom = (key) => { + const row = rows.find((x) => x.key === key) + const seed = row ? customOf(row) : { input: '', output: '', cacheRead: '' } + setDraft((prev) => mergeDraft(prev, key, { target: null, cleared: false, custom: seed, key })) + setEditFor(key) + closePick() + } + const setCustomField = (key, field, value) => { + setDraft((prev) => { + const cur = (prev[key] && prev[key].custom) || { input: '', output: '', cacheRead: '' } + return mergeDraft(prev, key, { target: null, cleared: false, custom: { ...cur, [field]: value }, key }) + }) + } + + const dirtyKeys = Object.keys(draft).filter((k) => draft[k].dirty) + /** 三格都能读成非负数字才算一条手填价,否则不发出去(避免把空串写成 0)。 */ + const customOfDraft = (d) => { + if (!d || !d.custom) return null + const out = {} + for (const [field] of CUSTOM_FIELDS) { + const raw = String(d.custom[field] == null ? '' : d.custom[field]).trim() + if (raw === '') return null + const v = Number(raw) + if (!Number.isFinite(v) || v < 0) return null + out[field] = v + } + return out + } + const submit = () => { + if (dirtyKeys.length === 0 || saving) { if (dirtyKeys.length === 0) onClose(); return } + const decisions = [] + const cleared = [] + const customs = [] + for (const k of dirtyKeys) { + const d = draft[k] + if (d.cleared) cleared.push(k) + else if (d.custom) { + const rate = customOfDraft(d) + if (rate !== null) customs.push({ key: k, ...rate }) + } else if (typeof d.target === 'string' && d.target !== '') decisions.push({ key: k, target: d.target }) + } + setSaving(true) + hostCall('usage/pricing', { action: 'commit', decisions, cleared, customs, currency: currency && currency.code ? currency.code : 'CNY' }) + .then((v) => { + setSaving(false) + setDraft({}) + setEditFor(null) + if (onApplied) onApplied(v) + onClose() + }, (e) => { + setSaving(false) + setError(e) + setStatus('error') + }) + } + + const onKeyDown = (e) => { + if (e.key === 'Escape') { + // 一路退出:先收候选列表,再关弹窗 + if (pickFor !== null) { closePick(); return } + onClose() + } + } + + // 单价:宿主给的是两档费率(rates),tier 是它自己算的「现在按哪档显示」。 + // 前端只查表、不猜档 —— 之前宿主把档折成一行再发过来,另一档就凭空消失了。 + const rateLine = (rates) => [rates.input, rates.output, rates.cacheRead].map(moneyOf).join(' / ') + const priceLine = (r) => { + // 手填价:三格都填好就照填的印(它们本来就是展示币种),否则提示还没填完。 + const d = r ? draft[r.key] : null + if (d && d.custom) { + const vals = CUSTOM_FIELDS.map(([field]) => String(d.custom[field] == null ? '' : d.custom[field]).trim()) + if (vals.every((v) => v !== '' && Number.isFinite(Number(v)))) { + return vals.map((v) => priceUnit + Number(v).toFixed(2)).join(' / ') + } + return t('pricingCustomPending') + } + return r && r.rates ? rateLine(r.rates[r.tier] || r.rates.peak) : t('unpriced') + } + /** 悬停时把两档都列出来:同一格只印一档,但不能让另一半价格看不见。 */ + const priceTitle = (r) => { + if (!r || !r.rates) return null + return t('pricingBothTiers', { peak: rateLine(r.rates.peak), off: rateLine(r.rates.offPeak) }) + } + + // 参考模型候选按来源分组:DeepSeek 官方、opencode Zen。「自定义」不是一个组, + // 而是列表顶部的一个单独选项 —— 选它不是挑一个参考模型,而是直接给这一行填 + // 三个单价(输入 / 输出 / 缓存读),存进 `prices`。 + const SOURCE_LABEL = { deepseek: 'pricingSourceDeepseek', zen: 'pricingSourceZen', custom: 'pricingSourceCustom' } + const candidateGroups = () => { + if (!table || !Array.isArray(table.groups)) return [] + const q = pickText.trim().toLowerCase() + let budget = 80 + const out = [] + for (const group of table.groups) { + const all = Array.isArray(group.ids) ? group.ids : [] + const ids = all.filter((id) => (q === '' ? true : id.toLowerCase().includes(q))) + if (ids.length === 0) continue + const kept = ids.slice(0, Math.max(0, budget)) + budget -= kept.length + out.push({ source: group.source, ids: kept }) + if (budget <= 0) break + } + return out + } + + /** 某组里的 id 属于哪个来源(草稿刚选的目标也查得到)。 */ + const sourceOf = (id) => { + if (!id || !table || !Array.isArray(table.groups)) return null + for (const group of table.groups) { + if (Array.isArray(group.ids) && group.ids.includes(id)) return group.source + } + return null + } + + /** + * 当前行要显示的匹配目标:草稿优先于宿主的答案。 + * + * 用户刚选完目标就要立刻看见,否则读起来像「点了没反应」;已收回的显示为空。 + */ + const shownOf = (r) => { + const d = draft[r.key] + if (!d) return r.target + if (d.cleared) return null + if (d.custom) return null // 手填价没有参考模型 id,格子显示「自定义」 + return d.target + } + + /** 一行的四态:收回即未匹配,选了目标或手填价即自定义,其余用宿主给的 state。 */ + const stateOf = (r) => { + const d = draft[r.key] + if (d && d.cleared) return 'none' + if (d && (d.target || d.custom)) return 'custom' + return r.state + } + + /** + * 选择器本体。 + * + * 未匹配的行不再要求先点一次「选择参考模型」:展开的那一刻输入框就带焦点, + * 选完直接落进草稿(仍然要按「确定」才写盘)。 + */ + const pickerFor = (key) => { + const row = rows.find((x) => x.key === key) + const cur = row ? shownOf(row) : null + return [ + h('input', { + key: 'q', + type: 'text', + className: 'tsd-price-pick-input', + value: pickText, + placeholder: t('askPick'), + autoFocus: true, + onChange: (e) => setPickText(e.target.value), + }), + h('div', { key: 'l', className: 'tsd-price-pick-list', role: 'listbox' }, + // 顶部固定一条「自定义」:选它不是挑参考模型,而是给这一行手填三个单价。 + h('button', { + key: 'custom', + type: 'button', + role: 'option', + 'aria-selected': 'false', + className: 'tsd-price-pick-item tsd-price-pick-custom', + onClick: () => startCustom(key), + }, + h('span', { className: 'tsd-price-pick-custom-label' }, t('pricingCustomOption')), + h('span', { className: 'tsd-price-pick-custom-hint' }, t('pricingCustomHint'))), + h('div', { key: 'sep', className: 'tsd-price-pick-sep', role: 'presentation' }), + // 一组一个表头 + 若干选项。role 保持 listbox/option,表头只是分组标签。 + candidateGroups().map((group) => [ + h('div', { + key: 'g:' + group.source, + className: 'tsd-price-pick-group', + role: 'presentation', + }, t(SOURCE_LABEL[group.source] || group.source)), + ...group.ids.map((id) => h('button', { + key: id, + type: 'button', + role: 'option', + 'aria-selected': id === (cur || ''), + className: 'tsd-price-pick-item' + (id === cur ? ' tsd-price-pick-item-active' : ''), + onClick: () => choose(key, id), + }, id)), + ])), + ] + } + + const bindPickAnchor = (el) => { if (el) pickAnchor.current = el } + + const renderRow = (r) => { + const d = draft[r.key] + const state = stateOf(r) + const shown = shownOf(r) + const picking = pickFor === r.key + const editing = editFor === r.key + const openPick = () => { if (picking) { closePick(); return } setEditFor(null); setPickFor(r.key); setPickText('') } + // 还原只在真的改过(或历史确认过)的行上出现:精确/模糊命中的行本来就没有 + // 可还原的东西,每行都挂一颗按钮只会把表格挤成表单。 + const canReset = state === 'custom' || (d && d.dirty) + // 弹窗遮罩是 z-index 60,浮层必须压在它上面;其余定位(上翻、封高)交给 hook。 + const pickPanel = picking + ? h('div', { + className: 'tsd-price-pick-box', + style: Object.assign({}, pickStyle, { zIndex: 70 }), + onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); closePick() } }, + }, pickerFor(r.key)) + : null + // 手填单价的浮层:三格(输入 / 输出 / 缓存读),单位就是表格的展示币种。 + const cur = editing ? customOf(r) : null + const customPanel = editing + ? h('div', { + className: 'tsd-price-pick-box tsd-price-custom-box', + style: Object.assign({}, customStyle, { zIndex: 70 }), + onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); setEditFor(null) } }, + }, + h('div', { className: 'tsd-price-custom-title' }, t('pricingCustomOption')), + ...CUSTOM_FIELDS.map(([field, label]) => h('label', { key: field, className: 'tsd-price-custom-field' }, + h('span', null, t(label)), + h('input', { + type: 'number', + min: '0', + step: '0.001', + className: 'tsd-price-custom-input', + 'data-field': field, + value: cur[field], + autoFocus: field === 'input', + onChange: (e) => setCustomField(r.key, field, e.target.value), + }))), + h('div', { className: 'tsd-price-custom-hint' }, t('pricingCustomUnit', { unit: priceUnit })), + h('button', { + type: 'button', + className: 'tsd-btn tsd-btn-ghost tsd-btn-xs', + onClick: () => setEditFor(null), + }, t('done'))) + : null + // 这一行匹配到的参考模型属于哪一类(DeepSeek / Zen / 自定义)。草稿里刚选的 + // 目标也查得到:候选名单本来就按来源分好了组,不用再问宿主一次。 + // 「自定义」不仅指草稿:从 `prices` 计价的行也没有参考模型 id(target 为空), + // 它的目标格要写「自定义」,不然会显示成待选。 + const isCustom = Boolean(d && d.custom) || (r.how === 'custom' && !r.target) + const src = shown ? (sourceOf(shown) || r.source) : (isCustom ? 'custom' : null) + return h('tr', { key: r.key, className: 'tsd-price-row', 'data-state': state, 'data-model': r.key, 'data-dirty': d && d.dirty ? 'true' : undefined }, + h('td', { className: 'tsd-price-model', title: r.key }, + h('span', { className: 'tsd-price-model-name' }, shortModelName(r.key)), + r.provider ? h('span', { className: 'tsd-price-model-provider' }, r.provider) : null), + // 匹配目标格本身就是选择器入口:一颗紧凑按钮,未匹配时虚线 + 提示语。 + h('td', { className: 'tsd-price-target', ref: picking || editing ? bindPickAnchor : undefined }, + h('button', { + type: 'button', + className: 'tsd-price-target-btn', + 'data-empty': shown || isCustom ? undefined : 'true', + 'aria-expanded': picking || editing ? 'true' : 'false', + 'aria-haspopup': 'listbox', + title: shown ? shown + (src ? ' · ' + t(SOURCE_LABEL[src] || src) : '') : null, + onClick: openPick, + }, + h('span', { className: 'tsd-price-target-id' }, shown || (isCustom ? t('pricingCustomOption') : t('askPick'))), + h(SvgIcon, { name: 'chevron', size: 12, className: 'tsd-price-target-chev' })), + src + ? h('span', { className: 'tsd-price-src', 'data-source': src }, t(SOURCE_LABEL[src] || src)) + : null, + state !== 'none' && !r.rates && !isCustom + ? h('span', { className: 'tsd-price-stale' }, t('pricingStale')) + : null, + pickPanel, + customPanel), + h('td', null, h('span', { className: 'tsd-price-badge', 'data-state': state, title: r.why || null }, t(MATCH_STATE_LABEL[state]))), + h('td', { className: 'tsd-price-rate', title: priceTitle(r) }, priceLine(r)), + h('td', { className: 'tsd-price-ops' }, + canReset + ? h('button', { + type: 'button', + className: 'tsd-price-reset', + title: t('pricingClear'), + 'aria-label': t('pricingClear'), + onClick: () => clearRow(r.key), + }, h(SvgIcon, { name: 'refresh', size: 12 })) + : null)) + } + + const body = status === 'loading' + ? h('div', { className: 'tsd-price-loading' }, t('loading')) + : status === 'error' + ? h('div', { className: 'tsd-alert-banner', role: 'alert' }, + h('span', null, t('pricingFailed', { message: String((error && error.message) || error) })), + h('button', { type: 'button', className: 'tsd-btn tsd-btn-ghost', onClick: pull }, t('retry'))) + : h('div', { className: 'tsd-price-body' }, + h('div', { className: 'tsd-price-meta' }, + table && table.source + ? t('pricingTableMeta', { source: table.source, date: stampText(table.capturedAt) }) + : null, + h('span', { className: 'tsd-price-counts' }, t('pricingCounts', { + exact: counts.exact, fuzzy: counts.fuzzy, none: counts.none, custom: counts.custom, + }))), + // 一张表装两段行:待选的未定价行在前,已按阶梯命中的行在后。 + // 分成两张表会出现两行表头,看起来像两份数据。 + !hasMainRows + ? h('div', { className: 'tsd-price-empty' }, t('askEmpty')) + : h('table', { className: 'tsd-price-table' }, + h('thead', null, h('tr', null, + h('th', null, t('pricingColModel')), + h('th', null, t('pricingColMatch')), + h('th', null, t('pricingColState')), + h('th', null, t('pricingColPrice', { unit: priceUnit })), + h('th', null, ''))), + h('tbody', null, + pendingRows.map((r) => renderRow(r)), + matchedRows.map((r) => renderRow(r)))), + h('div', { className: 'tsd-price-history' }, + h('button', { + type: 'button', + className: 'tsd-price-history-toggle', + 'aria-expanded': historyOpen, + onClick: () => setHistoryOpen((v) => !v), + }, + h(SvgIcon, { name: 'chevron', size: 12, className: 'tsd-price-history-chev' + (historyOpen ? ' tsd-price-history-chev-open' : '') }), + h('span', null, t('pricingHistory', { n: historyRows.length }))), + historyOpen + ? historyRows.length === 0 + ? h('div', { className: 'tsd-price-empty' }, t('pricingHistoryEmpty')) + : h('div', null, + h('table', { className: 'tsd-price-table tsd-price-table-sub' }, + h('thead', null, h('tr', null, + h('th', null, t('pricingColModel')), + h('th', null, t('pricingColMatch')), + h('th', null, t('pricingColState')), + h('th', null, t('pricingColPrice', { unit: priceUnit })), + h('th', null, ''))), + h('tbody', null, historyRows.map((r) => renderRow(r))))) + : null)) + + return h('div', { className: 'tsd-price-overlay', role: 'presentation', onMouseDown: (e) => { if (e.target === e.currentTarget) onClose() } }, + h('div', { + className: 'tsd-price-dialog', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': t('pricingTitle'), + onKeyDown, + }, + h('div', { className: 'tsd-price-head' }, + h('h3', { className: 'tsd-price-title' }, t('pricingTitle')), + h('button', { + type: 'button', + className: 'tsd-price-close', + title: t('cancel'), + 'aria-label': t('cancel'), + onClick: onClose, + }, h(SvgIcon, { name: 'x', size: 14 }))), + h('p', { className: 'tsd-price-desc' }, t('pricingDesc')), + body, + h('div', { className: 'tsd-price-foot' }, + h('button', { + type: 'button', + className: 'tsd-btn tsd-btn-ghost tsd-btn-icon-label', + onClick: pull, + disabled: status === 'loading' || saving, + }, + h(SvgIcon, { name: 'contextInjection', size: 14 }), + h('span', null, t('pricingPull'))), + h('span', { className: 'tsd-price-hint' }, dirtyKeys.length > 0 ? t('pricingApplyHint') : ''), + h('button', { type: 'button', className: 'tsd-btn tsd-btn-ghost', onClick: onClose, disabled: saving }, t('cancel')), + h('button', { + type: 'button', + className: 'tsd-btn tsd-btn-primary', + onClick: submit, + disabled: saving, + }, saving ? t('loading') : t('pricingSave'))))) } /* --------------------------------------------------------------------------- * 每日用量柱状图(手写 div 堆叠柱:下段输入蓝 / 上段输出绿) * ------------------------------------------------------------------------- */ +/** + * One hover-tip content block, shared by the trend chart (bar + line) and the + * yearly heatmap: date, input, output, total, reference cost. + * + * Cost comes from the host bucket (`cost`, already converted to the display + * currency), so all three tips show the same number as the summary card and the + * model table. A zero is printed as ¥0.00 — "0" is a price, "unpriced" is a + * different verdict, and it is the host's to give. + */ +function TipRows(props) { + const { row, t, currency, withSplit } = props + const inT = row.inputTokens || 0 + const outT = row.outputTokens || 0 + const totT = row.totalTokens || 0 + return [ + withSplit + ? h('div', { key: 'in', className: 'tsd-tooltip-row' }, + h('span', { className: 'tsd-tooltip-dot tsd-legend-in' }), + h('span', null, t('legendInput')), + h('span', { className: 'tsd-tooltip-val' }, formatInt(inT))) + : h('div', { key: 'in', className: 'tsd-tooltip-row' }, + h('span', { className: 'tsd-tooltip-dot', style: { background: 'var(--dsw-static-deepseek-500)' } }), + h('span', null, t('totalTokens')), + h('span', { className: 'tsd-tooltip-val' }, formatInt(totT))), + withSplit + ? h('div', { key: 'out', className: 'tsd-tooltip-row' }, + h('span', { className: 'tsd-tooltip-dot tsd-legend-out' }), + h('span', null, t('legendOutput')), + h('span', { className: 'tsd-tooltip-val' }, formatInt(outT))) + : null, + // 成本与调用次数没有对应图例,所以只留 10px 的空白占位(.tsd-tooltip-dot 本身无底色), + // 不画色块:色块的含义是「这一行对应图例里的哪个颜色」。 + h('div', { key: 'cost', className: 'tsd-tooltip-row' }, + h('span', { className: 'tsd-tooltip-dot', 'aria-hidden': 'true' }), + h('span', null, t('cost')), + h('span', { className: 'tsd-tooltip-val' }, formatCost(row.cost || 0, currency))), + row.calls > 0 + ? h('div', { key: 'calls', className: 'tsd-tooltip-row' }, + h('span', { className: 'tsd-tooltip-dot', 'aria-hidden': 'true' }), + h('span', null, t('calls')), + h('span', { className: 'tsd-tooltip-val' }, formatInt(row.calls))) + : null, + withSplit + ? h('div', { key: 'total', className: 'tsd-tooltip-total' }, + h('span', null, t('tooltipTotal')), + h('span', null, formatInt(totT))) + : null, + ] +} + function DailyChart(props) { - const { daily, gran, stepDays, granLabel } = props + const { daily, gran, stepDays, granLabel, currency } = props const t = useT() const chartRef = React.useRef(null) const [tip, setTip] = React.useState(null) // { row, x } @@ -2292,21 +3708,11 @@ function DailyChart(props) { const barTip = tip ? h('div', { className: 'tsd-tooltip', style: { left: tip.x + 'px' } }, h('div', { className: 'tsd-tooltip-date' }, tip.row.key + ' ' + weekdayName(tip.row.key, t)), - h('div', { className: 'tsd-tooltip-row' }, - h('span', { className: 'tsd-tooltip-dot tsd-legend-in' }), - h('span', null, t('legendInput')), - h('span', { className: 'tsd-tooltip-val' }, formatInt(inOf(tip.row)))), - h('div', { className: 'tsd-tooltip-row' }, - h('span', { className: 'tsd-tooltip-dot tsd-legend-out' }), - h('span', null, t('legendOutput')), - h('span', { className: 'tsd-tooltip-val' }, formatInt(outOf(tip.row)))), - h('div', { className: 'tsd-tooltip-total' }, - h('span', null, t('tooltipTotal')), - h('span', null, formatInt(totOf(tip.row))))) + h(TipRows, { row: tip.row, t, currency, withSplit: true })) : null const body = view === 'line' - ? h(LineChart, { rows, gran, stepDays, showTip, tip, setTip, chartRef }) + ? h(LineChart, { rows, gran, stepDays, showTip, tip, setTip, chartRef, currency }) : h('div', { className: 'tsd-chart', ref: chartRef }, rows.map((row, i) => { const inT = inOf(row) @@ -2323,7 +3729,7 @@ function DailyChart(props) { onMouseLeave: () => setTip(null), onFocus: (e) => showTip(e, row), onBlur: () => setTip(null), - 'aria-label': t('barAria', { key: row.key, in: formatInt(inT), out: formatInt(outT), tot: formatInt(totT) }), + 'aria-label': t('barAria', { key: row.key, in: formatInt(inT), out: formatInt(outT), tot: formatInt(totT), cost: formatCost(row.cost || 0, currency) }), }, h('div', { className: 'tsd-chart-plot' }, h('div', { className: 'tsd-chart-stack' }, @@ -2374,7 +3780,7 @@ function smoothPath(pts) { } function LineChart(props) { - const { rows, gran, stepDays, showTip, tip, setTip, chartRef } = props + const { rows, gran, stepDays, showTip, tip, setTip, chartRef, currency } = props const t = useT() const W = 620 const H = 240 @@ -2492,17 +3898,7 @@ function LineChart(props) { tip ? h('div', { className: 'tsd-tooltip', style: { left: tip.x + 'px' } }, h('div', { className: 'tsd-tooltip-date' }, tip.row.key + (stepDays > 1 ? ' ' + t('startNDays', { n: stepDays }) : '') + ' ' + weekdayName(tip.row.key, t)), - h('div', { className: 'tsd-tooltip-row' }, - h('span', { className: 'tsd-tooltip-dot tsd-legend-in' }), - h('span', null, t('legendInput')), - h('span', { className: 'tsd-tooltip-val' }, formatInt(inOf(tip.row)))), - h('div', { className: 'tsd-tooltip-row' }, - h('span', { className: 'tsd-tooltip-dot tsd-legend-out' }), - h('span', null, t('legendOutput')), - h('span', { className: 'tsd-tooltip-val' }, formatInt(outOf(tip.row)))), - h('div', { className: 'tsd-tooltip-total' }, - h('span', null, t('tooltipTotal')), - h('span', null, formatInt(totOf(tip.row))))) + h(TipRows, { row: tip.row, t, currency, withSplit: true })) : null) } @@ -2510,7 +3906,7 @@ function LineChart(props) { * 年度用量热力图(GitHub 风格) * ------------------------------------------------------------------------- */ function HeatmapCard(props) { - const { days } = props + const { days, currency } = props const t = useT() const [htip, setHtip] = React.useState(null) // { cell, left, top } in viewport coords const grid = React.useMemo(() => { @@ -2559,16 +3955,7 @@ function HeatmapCard(props) { style: { position: 'fixed', left: htip.left + 'px', top: htip.top + 'px', transform: 'translate(-50%, -110%)' }, }, h('div', { className: 'tsd-tooltip-date' }, htip.cell.key + ' ' + weekdayName(htip.cell.key, t)), - h('div', { className: 'tsd-tooltip-row' }, - h('span', { className: 'tsd-tooltip-dot', style: { background: 'var(--dsw-static-deepseek-500)' } }), - h('span', null, t('totalTokens')), - h('span', { className: 'tsd-tooltip-val' }, formatInt(htip.cell.totalTokens || 0))), - htip.cell.calls > 0 - ? h('div', { className: 'tsd-tooltip-row' }, - h('span', { className: 'tsd-tooltip-dot', style: { background: 'var(--dsw-alias-label-tertiary)' } }), - h('span', null, t('calls')), - h('span', { className: 'tsd-tooltip-val' }, formatInt(htip.cell.calls))) - : null) + h(TipRows, { row: htip.cell, t, currency, withSplit: false })) : null const dayNames = ['', t('weekdayMon'), '', t('weekdayWed'), '', t('weekdayFri'), ''] @@ -2600,7 +3987,7 @@ function HeatmapCard(props) { key: c.key, className: 'tsd-heatmap-cell', style: { background: heatColor(c.totalTokens || 0, grid.max) }, - title: t('hmTitle', { key: c.key, tokens: formatInt(c.totalTokens || 0), calls: c.calls ? t('hmCalls', { count: formatInt(c.calls) }) : '' }), + title: t('hmTitle', { key: c.key, tokens: formatInt(c.totalTokens || 0), calls: c.calls ? t('hmCalls', { count: formatInt(c.calls) }) : '', cost: formatCost(c.cost || 0, currency) }), onMouseEnter: (e) => showCellTip(e, c), onMouseLeave: () => setHtip(null), onFocus: (e) => showCellTip(e, c), @@ -2637,7 +4024,11 @@ function ModelTable(props) { output: r.outputTokens || 0, total: r.totalTokens || 0, cost: r.cost || 0, - unpriced: (r.totalTokens || 0) > 0 && !(r.cost > 0), + // The verdict comes from the host, which knows whether a price was found. + // This used to be re-derived here as `tokens > 0 && !(cost > 0)`, which + // labels every 0-cost row 未定价 — including a model the user explicitly + // priced at 0 (a real price, cost 0, so ¥0.00 is the honest answer). + unpriced: r.unpriced === true, })), [all]) const sumTotal = React.useMemo(() => view.reduce((s, r) => s + r.total, 0), [view]) @@ -2720,12 +4111,19 @@ function ModelTable(props) { /* --------------------------------------------------------------------------- * 三态:加载 / 错误 / 空 * ------------------------------------------------------------------------- */ -function LoadingState() { +function LoadingState(props) { const t = useT() - return h('div', { className: 'tsd-loading' }, + // 统计中(computing)时头部副标题已经在播报进度,这里就不再做第二个 live 区域, + // 否则屏幕阅读器会把「正在加载」和「正在统计 12/258」各念一遍。 + const announce = !props || props.announce !== false + return h('div', { className: 'tsd-loading', role: 'status', 'aria-live': announce ? 'polite' : 'off' }, + // 骨架屏对眼睛是"正在来",对屏幕阅读器什么都不是:给它一句可读的状态。 + announce ? h('span', { className: 'tsd-visually-hidden' }, t('loading')) : null, h('div', { className: 'tsd-cards' }, - [0, 1, 2, 3, 4].map((i) => - h('div', { key: i, className: 'tsd-card tsd-card-skeleton' }, + // 一张卡一个占位块,张数取自 SUMMARY_CARD_SLOTS —— 与真实卡片同一份顺序表, + // 所以补卡片时骨架自动跟上(这里曾经写死 5 个,真实是 6 个)。 + SUMMARY_CARD_SLOTS.map((slot) => + h('div', { key: slot, className: 'tsd-card tsd-card-skeleton' }, h('div', { className: 'tsd-skeleton', style: { width: '40%', height: 12 } }), h('div', { className: 'tsd-skeleton', style: { width: '70%', height: 22, marginTop: 10 } })))), h('div', { className: 'tsd-card' }, @@ -2774,10 +4172,17 @@ function StatsDashboard(props) { // 展示币种:宿主按它换算成本,所以换币种必须重查;选择记在本地(展示偏好, // 不写插件设置,免得每次取数都在改写用户配置)。 const [displayCurrency, setDisplayCurrency] = React.useState(readStoredCurrency) + // 定价匹配弹窗:只在这里开着,关掉即卸载(它的内部状态随之丢弃, + // 所以「不确定就不生效」不需要额外清理逻辑)。 + const [pricingOpen, setPricingOpen] = React.useState(false) // P1-6 backfill 轮询状态:queryKey 变化时重置,仅对同一查询串轮询 const queryKeyRef = React.useRef(null) const backfillSeenRef = React.useRef(false) const finalRefreshRef = React.useRef(false) + // 轮询节奏:统计窗口短则快问、长则慢问。固定 3 s 时,暖缓存 ~0.8 s 就跑完, + // 客户端要等 3 s 才更新 —— 进度条先躺着再跳一格,比没有更糟。 + const pollStepRef = React.useRef(0) + const POLL_DELAYS = [600, 1500, 3000] const eff = React.useMemo(() => rangeFor(range, custom), [range, custom]) // Fixed step policy: today = hourly; 7d/30d/custom = daily, custom uses a @@ -2796,6 +4201,7 @@ function StatsDashboard(props) { queryKeyRef.current = queryKey backfillSeenRef.current = false finalRefreshRef.current = false + pollStepRef.current = 0 } const isFinalRun = finalRefreshRef.current finalRefreshRef.current = false @@ -2819,10 +4225,14 @@ function StatsDashboard(props) { const bs = r && r.backfill ? r.backfill.status : null if (bs === 'running') { backfillSeenRef.current = true - // 同一 effect 至多挂一个定时器;重查复用 retry 计数器触发 effect 重跑 + // 同一 effect 至多挂一个定时器;重查复用 retry 计数器触发 effect 重跑。 + // 先快后慢:600 ms 起步能跟上 0.8 s 的暖缓存窗口,长任务再退到 3 s, + // 不至于把宿主问成压力测试。 + const delay = POLL_DELAYS[Math.min(pollStepRef.current, POLL_DELAYS.length - 1)] + pollStepRef.current += 1 backfillTimer = setTimeout(() => { if (current) setRetry((v) => v + 1) - }, 3000) + }, delay) } else if (bs === 'done' && backfillSeenRef.current && !isFinalRun) { // 回填收尾:恰好再查一次拿到完整数据后停止 finalRefreshRef.current = true @@ -2901,10 +4311,38 @@ function StatsDashboard(props) { const ready = data !== null const isError = status === 'error' const isEmpty = ready && data.daily.length === 0 && data.byModel.length === 0 + // "统计没算完":宿主还在回填历史(backfill.status === 'running')。 + // 它比 busy 长得多(全量 258 个会话要几秒),所以进度条与文案都以它为准; + // 请求在飞(busy)只是刷新,不该被当成"在算"。 + const backfill = (data && data.backfill) || null + const computing = Boolean(backfill && backfill.status === 'running') + const computeTotal = (backfill && backfill.total) || 0 + const computeDone = (backfill && backfill.done) || 0 + const computePct = computing && computeTotal > 0 + ? Math.min(100, Math.round((computeDone / computeTotal) * 100)) + : null + const showProgress = computing || (busy && ready && !isError) return h(T.Provider, { value: t }, - h('div', { className: 'tsd-section', 'aria-busy': busy || undefined }, - h(StatsHeader, { busy, onRefresh: reload }), + h('div', { className: 'tsd-section', 'aria-busy': busy || computing || undefined }, + // 首屏走后骨架屏(见 LoadingState);这里管"有数据但没算完"与"正在刷新": + // 一条 2px 的细进度条,绝对定位,不推动版面也不碰任何数字。 + // 算得完的时候它是确定进度(宽度 = done/total),否则是不确定扫动。 + showProgress + ? h('div', { + className: 'tsd-progress', + 'aria-hidden': 'true', + 'data-determinate': computePct === null ? undefined : 'true', + style: computePct === null ? undefined : { '--tsd-progress': computePct + '%' }, + }) + : null, + h(StatsHeader, { + busy, + computing, + computeDone, + computeTotal, + onRefresh: reload, + }), h(FilterBar, { range, custom, @@ -2920,19 +4358,27 @@ function StatsDashboard(props) { isError && !ready ? h(ErrorState, { error, onRetry: reload }) : null, - !ready && !isError - ? h(LoadingState, null) + // **从 DSH 启动到统计算完,整段都是加载态。** + // 覆盖两种"还没东西可看": + // ① 首屏请求还没回来(!ready); + // ② 宿主还在回填历史(computing)—— 哪怕是第二次进来、手里已经有半份数据。 + // 半份数字比骨架更误导:它看起来像"你只有这么多用量",而且会一格一格往上爬。 + // 这也正是「数字一点点往上爬」那个抱怨的来源,所以宁可等,也不要给残缺的答案。 + (!ready || computing) && !isError + ? h(LoadingState, { announce: !computing }) : null, ready && isError ? h('div', { className: 'tsd-alert-banner', role: 'alert' }, h('span', null, t('loadFailed', { message: String((error && error.message) || error) })), h('button', { type: 'button', className: 'tsd-btn tsd-btn-ghost', onClick: reload }, t('retry'))) : null, - ready && !isError && isEmpty + // 空态只在"确实算完了还没有记录"时出现。统计中不落到空态。 + ready && !isError && isEmpty && !computing ? h(EmptyState, { filtered: models.length > 0, onReset: resetFilters }) : null, - ready && !isError && !isEmpty - ? h('div', { className: 'tsd-content', 'data-busy': busy ? 'true' : undefined }, + // 数据区同理:算完之前不渲染,免得残缺数字先出现再跳变。 + ready && !isError && !isEmpty && !computing + ? h('div', { className: 'tsd-content' }, h(SummaryCards, { total: data.total, currency: currencySymbol, @@ -2940,26 +4386,44 @@ function StatsDashboard(props) { currencyCode: displayCurrency, onCurrencyChange: changeCurrency, byModel: data.byModel, + pricing: data.pricing, + onOpenPricing: () => setPricingOpen(true), }), h(DailyChart, { daily: data.daily, gran: effGran, stepDays, granLabel, + currency: currencySymbol, }), - h(HeatmapCard, { days: data.heatmap || [] }), + h(HeatmapCard, { days: data.heatmap || [], currency: currencySymbol }), h(ModelTable, { rows: data.byModel, currency: currencySymbol })) + : null, + pricingOpen + ? h(PricingDialog, { + currency: { code: displayCurrency, symbol: currencySymbol }, + onClose: () => setPricingOpen(false), + // 确定后重查一次:匹配改了,卡片上的数字必须跟着变,否则用户会以为没保存成功。 + onApplied: reload, + }) : null)) } /* --------------------------------------------------------------------------- * 设置页导航图标 * - * `settings.section` 只投影 id/order/label,导航图标由设置页按 id 白名单自己 - * 决定(dsh-client-ui-settings-general 的 navIcon),第三方没有插口。所以这里 - * 给「自己那一行」打一个属性标记,再用 CSS mask 把兜底的齿轮换成用量图标: - * 不替换节点、不碰 React 管的 svg(替换会让 React 卸载时抛 NotFoundError), - * 只加属性、只用 ::before 画字形。 + * 原生确实没有这个插口 —— 取证过的,不是"我觉得": + * · 官方设置页把 `settings.section` 的条目投影成 `{id, order, label}` 三字段, + * 没有 icon(`@deepseek-ai/dsh-client-ui-settings-general` 的 nav rows + * getSnapshot:`ctx.slots.entries("settings.section").map(e => ({id, order, + * label: resolveSlotLabel(e.options.label)}))`); + * · 图标由该包自己的 `navIcon(id)` 按 id 白名单选,未列出的 id 回落到设置齿轮 + * (同包 `function navIcon(id)`,其注释原文 "unknown ids fall back to the + * settings gear")。 + * 所以第三方只能给自己的那一行打属性标记、用 CSS mask 换字形:不替换节点、不碰 + * React 管的 svg(替换会让 React 卸载时抛 NotFoundError),只加属性、只用 ::before + * 画字形。官方一旦给 `settings.section` 加上 icon 字段,这个模块应整体删除 —— + * 那时它是可引用的 L0,不该再留 L2 补缺。 * * 认行只能按可见文本:类名是 CSS Modules 哈希,账号行还会条件出现。 * 官方一旦给 settings.section 加上 icon 字段,这个模块应整体删除。 @@ -2998,7 +4462,7 @@ function installSettingsNavIcon(label) { styleEl.textContent = NAV_ICON_CSS document.head.appendChild(styleEl) } catch (e) { - console.warn('[usage] settings nav icon: styles unavailable') + logWarn('[usage] settings nav icon: styles unavailable') return null } const mark = () => { @@ -3018,7 +4482,7 @@ function installSettingsNavIcon(label) { observer = new MutationObserver(() => { try { mark() } catch (e) {} }) observer.observe(document.body, { childList: true, subtree: true }) } catch (e) { - console.warn('[usage] settings nav icon: observer unavailable', e) + logWarn('[usage] settings nav icon: observer unavailable', e) } return () => { if (observer) observer.disconnect() @@ -3124,6 +4588,9 @@ function CostPill(props) { * ------------------------------------------------------------------------- */ function apply(ctx) { usageCtx = ctx + // Mount the native Remote face once the page is up; the Fetch routes carry + // every call until it lands. + mountRemoteChannel(ctx) _usagePricesScope = null // 每次激活重绑,避免复用已释放的 scope ctx.effect(() => { if (typeof styles !== 'undefined' && styles && typeof styles.insert === 'function') { @@ -3159,7 +4626,7 @@ function apply(ctx) { } } if (!slots || typeof slots.inject !== 'function' || typeof slots.register !== 'function') { - console.warn('[usage] settings.section slot unavailable') + logWarn('[usage] settings.section slot unavailable') return } slots.inject('settings.section', () => slots.register( diff --git a/src/host/client-source.js b/src/host/client-source.js deleted file mode 100644 index facbf6f..0000000 --- a/src/host/client-source.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Client source loader. - * - * The full dashboard UI lives in src/client/index.js (single source of - * truth). A dynamic plugin cannot import files, so the host reads the file - * and serves it to the browser half, which evaluates it with new Function. - * - * P1-4: candidates are derived from import.meta.url so the dynamic-plugin - * dev flow works from any checkout location. - */ -import { fileURLToPath } from 'node:url'; - -const CLIENT_SOURCE_CANDIDATES = [ - // src/host -> src/client in dev; lib/host -> lib/client when built. - fileURLToPath(new URL('../client/index.js', import.meta.url)), - // Repo source fallback when the built lib runs inside a checkout. - fileURLToPath(new URL('../../src/client/index.js', import.meta.url)), -]; - -export async function getClientSource(ctx) { - const fs = ctx.get('fs'); - if (fs === undefined) return { ok: false, error: 'fs service unavailable' }; - for (const p of CLIENT_SOURCE_CANDIDATES) { - try { - const target = await fs.resolve(p); - const body = await fs.readText(target); - if (typeof body === 'string' && body.length > 0) return { ok: true, body }; - } catch (error) { - // try the next candidate - } - } - return { ok: false, error: 'client source not found (tried: ' + CLIENT_SOURCE_CANDIDATES.join(', ') + ')' }; -} diff --git a/src/host/fold-cache.js b/src/host/fold-cache.js new file mode 100644 index 0000000..8bc874f --- /dev/null +++ b/src/host/fold-cache.js @@ -0,0 +1,117 @@ +/** + * Fold cache: the folded records of one session, kept on disk between runs. + * + * Why this exists: the plugin keeps its index in memory, so every restart folded + * the whole persisted history again. Measured on this machine (2026-10-29): + * 258 sessions, 404 MB compressed, 1.35 GB / 574k lines after inflation — 12.5 s + * just to read and inflate, before parsing and folding. A cache turns an + * unchanged session into a single JSON read. + * + * Layout: one file per session under `/cache/dsh-plugin-usage/fold/`. + * One file per session, not one big file, because the slow scan rewrites only + * the sessions that changed. + * + * Safety, in order of importance: + * - a cache entry is keyed by the backend's own change token (`revision`), so an + * appended log always misses and is re-folded; + * - the signature carries FOLD_CACHE_VERSION and the holiday calendar's + * `capturedAt` — a cached `tokens.peak`/`tokens.off` split is only valid for the + * calendar that produced it, and a fold-rule change must not be papered over; + * - every failure is a miss, never a throw: a broken cache degrades to the + * full scan it replaces. + */ +import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +import { PUBLIC_HOLIDAYS } from './holidays.js' + +/** + * Bump when the shape of a folded record changes (fields added/removed, tokens + * split differently). Not a nice-to-have: a stale entry would be served as if it + * were freshly folded. + */ +export const FOLD_CACHE_VERSION = 1 + +/** Everything a cached record depends on beyond the session log itself. */ +export function foldSignature() { + return FOLD_CACHE_VERSION + ':' + (PUBLIC_HOLIDAYS.capturedAt || 'no-calendar') +} + +/** Where the per-session files live; `DSH_HOME` wins so tests can redirect it. */ +export function cacheDir(home) { + const root = home || process.env.DSH_HOME || join(homedir(), '.dsh') + return join(root, 'cache', 'dsh-plugin-usage', 'fold') +} + +/** File name for one session. Ids are opaque; encode so no separator can escape. */ +function entryPath(dir, id) { + return join(dir, encodeURIComponent(id) + '.json') +} + +/** + * Read one session's entry. + * + * @param {string} dir - cache directory. + * @param {string} id - session id. + * @returns {{ revision?: string, watermark: number, records: unknown[] } | null} + * the entry, or null when absent/unreadable/stale-shaped. + */ +export function readEntry(dir, id) { + let text + try { + text = readFileSync(entryPath(dir, id), 'utf8') + } catch { + return null + } + let payload + try { + payload = JSON.parse(text) + } catch { + return null + } + if (!payload || typeof payload !== 'object') return null + if (payload.sig !== foldSignature()) return null; + if (!Array.isArray(payload.records)) return null; + if (typeof payload.watermark !== 'number' || !Number.isFinite(payload.watermark)) return null + return { revision: payload.revision, watermark: payload.watermark, records: payload.records } +} + +/** + * Write one session's entry (atomic: temp file then rename). + * + * @param {string} dir - cache directory. + * @param {string} id - session id. + * @param {{ revision?: string, watermark: number, records: unknown[] }} entry + * @returns {boolean} whether the file was written. + */ +export function writeEntry(dir, id, entry) { + if (!Array.isArray(entry && entry.records)) return false + try { + mkdirSync(dir, { recursive: true }) + const path = entryPath(dir, id) + const temp = path + '.tmp' + writeFileSync(temp, JSON.stringify({ + sig: foldSignature(), + revision: entry.revision, + watermark: entry.watermark, + records: entry.records, + })) + renameSync(temp, path) + return true + } catch { + return false + } +} + +/** +/* + * There is deliberately no prune(). + * + * `sp.list()` returns a PARTIAL list while the session store warms up after a + * start, so "absent from this listing" does not mean "gone". Pruning on it + * deleted 257 of 258 valid entries on a live instance (2026-10-30 00:18) and + * every following start had to fold the whole history again — the exact symptom + * the cache exists to remove. A stale entry costs a few kilobytes and nothing + * else; it is only ever read by id from a listing. + */ diff --git a/src/host/fold.js b/src/host/fold.js index 32dc74a..b88ec55 100644 --- a/src/host/fold.js +++ b/src/host/fold.js @@ -127,6 +127,17 @@ export function foldEvent(state, sessionId, e) { ? u.totalTokens : inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens, }; + // Peak / off-peak buckets. This is the only place an event's own timestamp is + // available, and DeepSeek bills by that timestamp: one model can mix both + // tiers inside a single day, so an aggregate priced after the fact is always + // either too high or too low. `state.peakOf` is injected by apply(), which + // keeps this function a pure function of its arguments in tests. + if (typeof state.peakOf === 'function') { + const peak = state.peakOf(e.time) === true; + rec.tokens = { + [peak ? 'peak' : 'off']: { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }, + }; + } const previous = state.lastSample.get(sessionId); if (previous !== undefined diff --git a/src/host/holidays.js b/src/host/holidays.js new file mode 100644 index 0000000..d76b852 --- /dev/null +++ b/src/host/holidays.js @@ -0,0 +1,104 @@ +// GENERATED by scripts/gen-prices.mjs — do not edit by hand. +export const PUBLIC_HOLIDAYS = { + "capturedAt": "2026-10-29T15:02:16.737Z", + "source": "https://github.com/NateScarlet/holiday-cn", + "note": "中国法定节假日(isOffDay=true 的日期)。峰谷判定中这些日期全天按空闲时段计。", + "coverage": { + "fromYear": 2025, + "toYear": 2027, + "basis": "session store", + "years": [ + 2025, + 2026, + 2027 + ] + }, + "years": { + "2025": { + "offDays": 28, + "first": "2025-01-01", + "last": "2025-10-08", + "papers": [ + "https://www.gov.cn/zhengce/zhengceku/202411/content_6986383.htm" + ] + }, + "2026": { + "offDays": 33, + "first": "2026-01-01", + "last": "2026-10-07", + "papers": [ + "https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm" + ] + }, + "2027": { + "unpublished": true, + "offDays": 0, + "first": null, + "last": null, + "papers": [] + } + }, + "dates": { + "2025-01-01": "元旦", + "2025-01-28": "春节", + "2025-01-29": "春节", + "2025-01-30": "春节", + "2025-01-31": "春节", + "2025-02-01": "春节", + "2025-02-02": "春节", + "2025-02-03": "春节", + "2025-02-04": "春节", + "2025-04-04": "清明节", + "2025-04-05": "清明节", + "2025-04-06": "清明节", + "2025-05-01": "劳动节", + "2025-05-02": "劳动节", + "2025-05-03": "劳动节", + "2025-05-04": "劳动节", + "2025-05-05": "劳动节", + "2025-05-31": "端午节", + "2025-06-01": "端午节", + "2025-06-02": "端午节", + "2025-10-01": "国庆节、中秋节", + "2025-10-02": "国庆节、中秋节", + "2025-10-03": "国庆节、中秋节", + "2025-10-04": "国庆节、中秋节", + "2025-10-05": "国庆节、中秋节", + "2025-10-06": "国庆节、中秋节", + "2025-10-07": "国庆节、中秋节", + "2025-10-08": "国庆节、中秋节", + "2026-01-01": "元旦", + "2026-01-02": "元旦", + "2026-01-03": "元旦", + "2026-02-15": "春节", + "2026-02-16": "春节", + "2026-02-17": "春节", + "2026-02-18": "春节", + "2026-02-19": "春节", + "2026-02-20": "春节", + "2026-02-21": "春节", + "2026-02-22": "春节", + "2026-02-23": "春节", + "2026-04-04": "清明节", + "2026-04-05": "清明节", + "2026-04-06": "清明节", + "2026-05-01": "劳动节", + "2026-05-02": "劳动节", + "2026-05-03": "劳动节", + "2026-05-04": "劳动节", + "2026-05-05": "劳动节", + "2026-06-19": "端午节", + "2026-06-20": "端午节", + "2026-06-21": "端午节", + "2026-09-25": "中秋节", + "2026-09-26": "中秋节", + "2026-09-27": "中秋节", + "2026-10-01": "国庆节", + "2026-10-02": "国庆节", + "2026-10-03": "国庆节", + "2026-10-04": "国庆节", + "2026-10-05": "国庆节", + "2026-10-06": "国庆节", + "2026-10-07": "国庆节" + } +}; diff --git a/src/host/index.js b/src/host/index.js index ff12292..3c59fd8 100644 --- a/src/host/index.js +++ b/src/host/index.js @@ -1,8 +1,8 @@ /** * usage — Token usage statistics host half (entry module). * - * Wires the submodules (fold / scan / query / settings / aggregate / rpc / - * client-source) into the cordis plugin lifecycle. Strategy notes: + * Wires the submodules (fold / scan / query / settings / pricing / aggregate / + * rpc / typert) into the cordis plugin lifecycle. Strategy notes: * - DSH persists every loop model call's token usage in each session log as * an `assistant/message` (or `assistant/attempt`) settlement carrying * `usage`; this plugin folds those events with the same replacement / @@ -11,8 +11,12 @@ * - Live sessions are scanned from the in-memory store every poll tick; * persisted sessions are backfilled at startup and re-scanned incrementally * (watermark + revision per session). - * - Aggregation, cost (user-configurable price table) and model list are - * served through src/host/rpc.js. + * - The fold also splits each settlement by the price tier its own timestamp + * fell into, because DeepSeek bills peak and off-peak separately and one + * model can mix both inside a day. + * - Aggregation and the model list are served through src/host/rpc.js; prices + * come from the generated reference table (prices.js) unless the user has + * entered their own. * * The price table is the plugin's own config: the exported `Config` schema is * the official, durable form surface (`Plugins` settings tab -> profile @@ -25,65 +29,358 @@ */ import Schema from '@deepseek-ai/schemastery'; -import { DEFAULT_PRICES, DEFAULT_CURRENCY, createPriceStore, mergePrices } from './settings.js'; +import { DEFAULT_CURRENCY, configPrices, createPriceStore, currencyOf, normalizePrices } from './settings.js'; import { createState } from './state.js'; import { createUsageIndex } from './aggregate.js'; -import { scanLive, scanPersisted, runBackfill } from './scan.js'; +import { scanLive, scanPersisted, startBackfillOnDemand } from './scan.js'; import { query } from './query.js'; -import { registerRPC } from './rpc.js'; -import { getClientSource } from './client-source.js'; +import { registerRPC, logSink } from './rpc.js'; +import { USAGE_SERVICE } from './typert.js'; +import { priceRow, referenceGroups, referenceTable } from './pricing.js'; +import { isPeak } from './peak.js'; export const name = 'usage-host'; export const inject = ['timer']; -/** Durable price table + currency, materialized as the plugin's config form. */ +/** + * Durable overrides + currency + the user's confirmed model matches, + * materialized as the plugin's config form. + * + * `prices` defaults to EMPTY on purpose: every price the reference table can + * answer must come from the table (see settings.js). A row here is an override + * for an id only the user knows, and the ladder consults it first. + * + * `matchMap` is deliberately a free-form `dict(string)`: it maps a model key + * exactly as a session log recorded it (`provider/model`, including a `/` inside + * the model id) to a reference-table id. A plain dict rather than a structured + * object because the keys are user data, and a schema that rejected an + * unexpected shape would silently drop confirmed matches on save. + */ export const Config = Schema.object({ - currency: Schema.string().default(DEFAULT_CURRENCY), + // ⚠️ Every field this plugin writes back must be `.volatile()`. The official + // settings service refuses anything else: + // dsh-settings/lib/index.js `write()`: `volatileForm(schema)` is undefined + // when no field is volatile, and it throws `has no volatile fields`; + // `validatePaths` then rejects any non-volatile key in the patch. + // Without these markers `settings.update('usage', …)` threw on every write, + // and the old `.catch(() => {})` hid it — so confirmed matches, custom prices + // and the currency were lost at every restart. + currency: Schema.string().default(DEFAULT_CURRENCY).volatile().description({ + zh: '参考成本卡片与单价使用的币种(固定汇率 1 USD = 7 CNY)', + en: 'Currency for the reference-cost card and rates (fixed rate: 1 USD = 7 CNY)', + }), prices: Schema.dict(Schema.object({ inputPerM: Schema.number().default(0), outputPerM: Schema.number().default(0), cacheReadPerM: Schema.number().default(0), cacheWritePerM: Schema.number().default(0), - })).default(DEFAULT_PRICES), + })).default({}).volatile().description({ + zh: '自定义参考定价:模型 id → 每百万 token 的输入/输出/缓存读/缓存写价,单位美元(界面里按币种换算显示)', + en: 'Custom reference prices: model id → input/output/cache-read/cache-write per million tokens, in USD (the UI converts for display)', + }), + matchMap: Schema.dict(Schema.string()).default({}).volatile().description({ + zh: '已确认的模型匹配:模型 id → 参考模型 id(在「定价匹配」里点确定时写入)', + en: 'Confirmed model matches: model id → reference model id (written when you confirm in the pricing dialog)', + }), }); const LIVE_POLL_MS = 2000; const PERSISTED_POLL_MS = 30000; +/** + * Whether an instant bills at DeepSeek's peak rate. + * + * Wired into the state capsule rather than imported by fold.js so the fold stays + * a pure function of its arguments: tests pass their own predicate and never + * depend on today's calendar. Exported so a test can pin the wiring itself — + * the fold used to call `isPeak()` bare, which billed every public holiday at + * the peak rate while the price display showed it as off-peak. + * + * @param {number} atMs - epoch milliseconds. + * @returns {boolean} true inside a Beijing peak window on a non-holiday weekday. + */ +export function peakAt(atMs) { + return isPeak(atMs); +} + +/** + * Validate one `pricing` commit row. + * + * Nothing reaches the persisted config unless it passes: a bad id here becomes a + * silent zero cost later, which is the failure mode this whole rebuild exists to + * remove. + * + * @param {unknown} decision - `{ key, target }` from the dialog. + * @returns {{ ok: true, key: string, target: string } | { ok: false, key: string, why: string }} verdict. + */ +function checkDecision(decision) { + const key = decision && typeof decision.key === 'string' ? decision.key : ''; + if (key === '') return { ok: false, key, why: '缺少模型 key' }; + const target = decision.target; + if (typeof target !== 'string' || target.trim() === '') { + return { ok: false, key, why: '目标必须是一个参考模型 id' }; + } + if (referenceTable().models[target] === undefined) { + return { ok: false, key, why: '参考模型不在当前价目表中:' + target }; + } + return { ok: true, key, target }; +} + +/** + * Validate one hand-typed reference price. + * + * This is the dialog's 自定义 option: not a reference model but three rates the + * user enters, stored as a `prices` row for that model key. Values arrive in the + * DISPLAY currency the dialog printed (¥ by default), so they are converted to + * the table's USD here — the one place that knows the exchange rate. + * + * @param {unknown} entry - `{ key, input, output, cacheRead }`. + * @param {number} perUsd - display units per USD. + * @returns {{ ok: true, key: string, rate: object } | { ok: false, key: string, why: string }} verdict. + */ +function checkCustom(entry, perUsd) { + const key = entry && typeof entry.key === 'string' ? entry.key : ''; + if (key === '') return { ok: false, key, why: '缺少模型 key' }; + const rate = {}; + for (const field of ['input', 'output', 'cacheRead']) { + const v = Number(entry[field]); + if (!Number.isFinite(v) || v < 0) return { ok: false, key, why: '单价必须是非负数字:' + field }; + // Six decimals: a price per million tokens never needs more, and dividing by + // 7 otherwise stores 0.09999999999999999 where the user typed 0.7. + rate[field] = Math.round((v / (perUsd > 0 ? perUsd : 1)) * 1e6) / 1e6; + } + rate.cacheWrite = 0; + return { ok: true, key, rate }; +} + +/** + * Unwrap a cordis "volatile" config value. + * + * `Schema.prototype.volatile()` marks a field writable at runtime, and cordis + * then keeps the raw value in a wrapper (so a `!!js` expression stays unevaluated + * until someone asks). Neighbouring official code unwraps it — `dsh-settings` + * does `if (isVolatile(value)) value = value.get()` — and so must we: without + * this, `currency`/`prices`/`matchMap` arrive as wrapper objects. + */ +function plainConfigValue(value) { + if (value === null || typeof value !== 'object') return value; + // The wrapper is a plain object carrying `get` plus `Symbol(cosmokit.volatile.write)`, + // so detect it by that symbol rather than by its prototype. + const volatileOfValue = Object.getOwnPropertySymbols(value).some((symbol) => String(symbol).includes('cosmokit.volatile')); + if (volatileOfValue && typeof value.get === 'function') return value.get(); + return value; +} + +/** + * Resolved configuration: defaults applied, volatile wrappers unwrapped. + * + * ⚠️ Unwrap the INPUT too. The loader hands us the value the settings line + * stored, which for a volatile field is already the wrapper; calling `Config()` + * on it throws `ValidationError: $.currency expected string but got [object + * Object]` and the whole plugin fails to activate — no Remote face, no Fetch + * routes, every dashboard call 404/405. `dsh-settings` unwraps before validating + * for exactly this reason (`projectForm(…, plainConfig(raw) …)`). + * + * Exported because the tests must see exactly what `apply` sees. + */ +export function resolveConfig(raw) { + const source = raw && typeof raw === 'object' ? raw : {}; + // Deep-clone into mutable plain JSON: the values inside a volatile wrapper are + // frozen (`schemastery` freezes dict defaults), and re-validating a frozen + // object throws `Cannot assign to read only property …` inside the schema. + const clone = (value) => { + if (value === null || typeof value !== 'object') return value; + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return value; + } + }; + const plain = {}; + for (const key of ['currency', 'prices', 'matchMap']) { + const value = plainConfigValue(source[key]); + if (value !== undefined) plain[key] = clone(value); + } + let resolved; + try { + resolved = Config(plain); + } catch { + // A hand-edited patch with a wrong type must not take the plugin down: fall + // back to the shipped defaults (the dashboard still works, the row shows 0). + resolved = Config({}); + } + return { + currency: plainConfigValue(resolved.currency), + prices: plainConfigValue(resolved.prices), + matchMap: plainConfigValue(resolved.matchMap), + }; +} + export function apply(ctx, config) { - const resolved = config && typeof config === 'object' ? config : {}; + const resolved = resolveConfig(config); const priceStore = createPriceStore( - mergePrices(resolved.prices), + resolved.prices, resolved.currency || DEFAULT_CURRENCY, + // A getter, not the map: a commit replaces `state.matchMap`, and a store + // closed over the old object would keep pricing from the pre-commit map. + { matches: () => state.matchMap }, ); const state = createState(priceStore); state.index = createUsageIndex(state); + state.peakOf = peakAt; + state.matchMap = resolved.matchMap && typeof resolved.matchMap === 'object' ? { ...resolved.matchMap } : {}; + + /** + * Write the durable half of the pricing config. + * + * Failures are LOGGED, not swallowed: a silent `.catch(() => {})` here hid a + * real defect for weeks — the settings service refused every patch (no field + * was volatile), so confirmed matches and custom prices vanished on restart + * with nothing in any log to say why. + */ + const persist = (patch) => { + // `logSink` returns a **function** `(message, level?)`, not a logger object. + // Calling `.warn` on it threw `TypeError: … warn is not a function`, which is + // how a "settings service unavailable" branch turned into a crashed host. + const log = logSink(ctx); + let settings; + try { + settings = typeof ctx.get === 'function' ? ctx.get('settings') : undefined; + } catch { + settings = undefined; + } + if (!settings || typeof settings.update !== 'function') { + log('[usage] settings service unavailable: this session keeps pricing in memory only'); + return patch; + } + Promise.resolve() + .then(() => settings.update('usage', patch)) + .catch((error) => { + log('[usage] could not persist pricing config: ' + (error && error.message ? error.message : String(error))); + }); + return patch; + }; const handlers = new Map(); - handlers.set('query', (args) => query(state, args || {})); + handlers.set('query', (args) => { + // 「缓存加载也是加载」:全量统计由第一次查询拉起,所以面板打开的这一刻就是 + // 统计开始的那一刻(暖缓存 ~0.8 s、冷缓存 ~12 s 的骨架与进度条都看得见)。 + // 幂等:只有第一次真正启动,后续查询只是读状态。 + startBackfillOnDemand(ctx, state, { log: logSink(ctx) }); + return query(state, args || {}); + }); + /** + * The face this plugin registers on the native Remote channel. + * + * The browser half cannot enumerate a face that was registered at runtime, and + * the endpoint names must match what the host registered (a duplicated list + * drifts). So it asks for them here — over the Fetch fallback — and mounts its + * own descriptors: the gateway's client side wants a src-json result, while the + * host contribution carries a strict codec, so the two are not identical. + */ + handlers.set('contract', () => ({ + package: USAGE_SERVICE, + names: [...handlers.keys()].filter((name) => name !== 'contract'), + })); handlers.set('prices', (args) => { const store = state.prices; store.setPrices(args && args.prices); if (args && typeof args.currency === 'string') store.setCurrency(args.currency); - // Secondary durability for hosts where the client cannot reach its config - // form: write the same values through the official SettingsForms API. - // The entry id is the bundle patch row id (`usage`). - try { - const settings = typeof ctx.get === 'function' ? ctx.get('settings') : undefined; - if (settings && typeof settings.update === 'function') { - settings.update('usage', { prices: Object.assign({}, store.prices), currency: store.currency }) - .catch(() => {}); + persist({ prices: configPrices(store.prices), currency: store.currency }); + return store.snapshot(); + }); + /** + * Model-id matching: propose, then commit. + * + * Two actions on one endpoint because they are two halves of one decision: + * `propose` answers "what would each of my models match to?" and writes + * nothing; `commit` applies the rows the user confirmed. Keeping them apart is + * what makes "确定才保存" true — opening the dialog cannot change a price. + */ + handlers.set('pricing', (args) => { + const action = args && args.action === 'commit' ? 'commit' : 'propose'; + + if (action === 'commit') { + const decisions = Array.isArray(args?.decisions) ? args.decisions : []; + const customs = Array.isArray(args?.customs) ? args.customs : []; + const applied = {}; + const typed = {}; + const rejected = []; + for (const decision of decisions) { + const verdict = checkDecision(decision); + if (verdict.ok) applied[verdict.key] = verdict.target; + else rejected.push({ key: verdict.key, why: verdict.why }); } - } catch { - // settings is optional; the in-memory store still answers this session. + // 自定义:三档手填单价,写进 `prices`(用户覆盖),不写 matchMap。 + const perUsd = currencyOf(args?.currency, state.prices.currency).perUsd; + for (const entry of customs) { + const verdict = checkCustom(entry, perUsd); + if (verdict.ok) typed[verdict.key] = verdict.rate; + else rejected.push({ key: verdict.key, why: verdict.why }); + } + // Clearing is expressed by listing the key with an empty target, and an + // empty change set is a no-op rather than a wipe. + const cleared = Array.isArray(args?.cleared) ? args.cleared.filter((k) => typeof k === 'string' && k !== '') : []; + if (Object.keys(applied).length > 0 || cleared.length > 0 || Object.keys(typed).length > 0) { + const next = { ...state.matchMap }; + for (const key of cleared) delete next[key]; + Object.assign(next, applied); + state.matchMap = next; + // One key, one answer — whichever the user just gave wins: + // · a hand-typed price replaces the confirmed match (the ladder checks + // `prices` first, so leaving it would silently outrank the match); + // · a confirmed table target drops a stale hand-typed price; + // · clearing drops both. + if (Object.keys(typed).length > 0) state.prices.setPrices(typed); + for (const key of [...Object.keys(applied), ...cleared, ...Object.keys(typed)]) { + delete state.matchMap[key]; + delete state.prices.prices[key]; + } + Object.assign(state.matchMap, applied); + if (Object.keys(typed).length > 0) Object.assign(state.prices.prices, typed); + persist({ matchMap: { ...state.matchMap }, prices: configPrices(state.prices.prices) }); + } + return { + ok: true, + applied: Object.keys(applied).length, + custom: Object.keys(typed).length, + cleared: cleared.length, + rejected, + matchMap: { ...state.matchMap }, + prices: configPrices(state.prices.prices), + }; } - return store.snapshot(); + + const models = Array.isArray(args?.models) ? args.models : []; + const atMs = Date.now(); + const rows = models.map((entry) => { + const model = typeof entry === 'string' ? entry : String(entry?.model ?? ''); + const provider = typeof entry === 'string' ? '' : String(entry?.provider ?? ''); + const key = typeof entry?.key === 'string' ? entry.key : (provider === '' ? model : provider + '/' + model); + // `priceRow` owns the wire shape for both this endpoint and `query`, so the + // dialog can never classify a model differently from the bill it shows. + return { key, provider, model, ...priceRow(priceStore.lookup({ model, provider }), atMs) }; + }); + return { + table: { + source: 'opencode Zen + DeepSeek', + capturedAt: referenceTable().capturedAt, + unit: referenceTable().unit, + // Three groups, not one flat list: the reference ids come from DeepSeek, + // from Zen, or from the user's own price rows, and the dialog shows them + // apart. `custom` used to be unreachable from the picker entirely. + groups: referenceGroups(), + }, + rows, + matchMap: { ...state.matchMap }, + }; }); - handlers.set('client-source', () => getClientSource(ctx)); - registerRPC(ctx, handlers); + // The chosen client→host channel travels with every dashboard response. + state.channel = registerRPC(ctx, handlers); - // Full history scan once at startup. - runBackfill(ctx, state); + // 全量回填**不在激活时跑**:它按需触发(见 `startBackfillOnDemand`),由第一次 + // 查询拉起。缓存加载(暖 0.8 s / 冷 12 s)因此发生在用户打开面板的那一刻, + // 而不是进程启动的头一秒 —— 否则加载态永远看不见。 // Live sessions every tick; persisted sessions on a slow cadence to keep // per-session log reads cheap (unchanged revisions are skipped). ctx.interval(() => { diff --git a/src/host/peak.js b/src/host/peak.js new file mode 100644 index 0000000..e1eb4ad --- /dev/null +++ b/src/host/peak.js @@ -0,0 +1,113 @@ +/** + * Peak / off-peak classification for DeepSeek's two-tier list price. + * + * The rule is DeepSeek's own, restated in the base time zone so the + * holiday table (which is published per Chinese calendar day) and the + * weekday test agree on what "a day" means: + * + * 北京时间周一至周五(不含中国法定节假日)9:00-12:00、14:00-18:00 为高峰时段; + * 其余时段,包括周末及中国法定节假日全天均为空闲时段。 + * + * Equivalences worth knowing before touching this file: + * - `Asia/Shanghai` has been a fixed UTC+8 offset with no DST since 1991, so + * `atMs + 8h` read through `getUTC*()` *is* Beijing wall-clock time. No + * Intl/DateTimeFormat and no time-zone database are involved. + * - DeepSeek's own wording is "01:00-04:00 and 06:00-10:00 UTC, Monday through + * Friday": those UTC windows map one-to-one onto 09:00-12:00 and + * 14:00-18:00 Beijing, and neither window crosses a UTC day boundary, so + * the weekday test is identical in both frames. + * - A 调休 workday (a Saturday/Sunday declared a working day) is still a + * weekend here: the rule above lists weekdays explicitly and carves out + * only public holidays, so a make-up Saturday stays off-peak. + */ + +import { PUBLIC_HOLIDAYS } from './holidays.js'; + +/** Beijing-time hour windows that bill at the peak rate, `[start, end)`. */ +export const PEAK_WINDOWS = [ + [9, 12], + [14, 18], +]; + +/** + * Whether a Beijing date is a Chinese public holiday (all day off-peak). + * + * Lives here, next to the rule that needs it, so the peak test and the price + * display cannot disagree about what a holiday is — they did once: the fold + * called `isPeak()` without a calendar while `priceRow` used the full one, so a + * National Day call was billed at the peak rate and shown as off-peak. + * + * @param {string} date - `YYYY-MM-DD`, Beijing. + * @returns {boolean} true when the generated calendar lists that date. + */ +export function holidayOf(date) { + const dates = PUBLIC_HOLIDAYS.dates; + return typeof date === 'string' && Object.prototype.hasOwnProperty.call(dates, date); +} + +/** The calendar's coverage metadata (which years are known), for the UI note. */ +export function holidayCoverage() { + return PUBLIC_HOLIDAYS.years; +} + +/** Milliseconds in one hour. */ +const HOUR_MS = 3_600_000; + +/** Beijing is UTC+8, always. */ +const BEIJING_OFFSET_MS = 8 * HOUR_MS; + +/** + * Beijing wall-clock parts of an instant. + * + * @param {number} atMs - epoch milliseconds. + * @returns {{ year: number, month: number, day: number, weekday: number, hour: number, minute: number, date: string }} + * `weekday` is 0=Sunday..6=Saturday; `month` is 1-based; `date` is `YYYY-MM-DD`. + */ +export function beijingParts(atMs) { + const d = new Date(atMs + BEIJING_OFFSET_MS); + const year = d.getUTCFullYear(); + const month = d.getUTCMonth() + 1; + const day = d.getUTCDate(); + const pad = (n) => (n < 10 ? '0' + n : String(n)); + return { + year, + month, + day, + weekday: d.getUTCDay(), + hour: d.getUTCHours(), + minute: d.getUTCMinutes(), + date: `${year}-${pad(month)}-${pad(day)}`, + }; +} + +/** + * Whether an instant falls inside a peak window, ignoring the calendar. + * + * Exported separately so a test can compare the window arithmetic against the + * calendar rules without needing a holiday table. + * + * @param {number} atMs - epoch milliseconds. + * @returns {boolean} true inside `[start, end)` of any peak window. + */ +export function inPeakWindow(atMs) { + const { hour, minute } = beijingParts(atMs); + const t = hour + minute / 60; + return PEAK_WINDOWS.some(([start, end]) => t >= start && t < end); +} + +/** + * Whether an instant bills at DeepSeek's peak rate. + * + * @param {number} atMs - epoch milliseconds of the billed settlement. + * @param {(date: string) => boolean} [isHoliday] - holiday test; DEFAULTS to the + * generated calendar. It used to default to "no holidays known", which let a + * caller bill a public holiday at the peak rate by forgetting the argument. + * @returns {boolean} true for peak. + */ +export function isPeak(atMs, isHoliday = holidayOf) { + if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return false; + const { weekday, date } = beijingParts(atMs); + if (weekday === 0 || weekday === 6) return false; + if (!inPeakWindow(atMs)) return false; + return typeof isHoliday === 'function' ? !isHoliday(date) : true; +} diff --git a/src/host/prices.js b/src/host/prices.js new file mode 100644 index 0000000..09ae75d --- /dev/null +++ b/src/host/prices.js @@ -0,0 +1,876 @@ +// GENERATED by scripts/gen-prices.mjs — do not edit by hand. +export const REFERENCE_PRICES = { + "capturedAt": "2026-10-29T15:02:16.736Z", + "unit": "USD per 1M tokens", + "sources": [ + { + "name": "opencode Zen", + "url": "https://opencode.ai/v2/docs/console/models/", + "fetchedAt": "2026-10-29T15:02:16.729Z", + "models": 88 + }, + { + "name": "DeepSeek", + "url": "https://api-docs.deepseek.com/quick_start/pricing/", + "fetchedAt": "2026-10-29T15:02:16.730Z", + "models": 2 + } + ], + "note": "Zen 每个模型单一价;DeepSeek 条目来自官网,峰谷两套价,参考成本按成交时刻判定(见 peak.js)。", + "models": { + "big-pickle": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "glm-5.3-flash-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "deepseek-v4.1-flash-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "minimax-m3.1-flash-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "beam-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "ll-alpha-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "longcat-2.5-preview-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "exo-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "mimo-v2.6-flash-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "mimo-v2.5-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "nemotron-3-ultra-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "nemotron-3.5-lightning-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "nemotron-3.5-super-vl-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": null + }, + "jev-1.13-free": { + "source": "zen", + "input": 0, + "output": 0, + "cacheRead": null, + "cacheWrite": null + }, + "jev-1.13": { + "source": "zen", + "input": 0.042, + "output": 0, + "cacheRead": null, + "cacheWrite": null + }, + "minimax-m3": { + "source": "zen", + "input": 0.3, + "output": 1.2, + "cacheRead": 0.06, + "cacheWrite": null + }, + "minimax-m2.7": { + "source": "zen", + "input": 0.3, + "output": 1.2, + "cacheRead": 0.06, + "cacheWrite": null + }, + "minimax-m2.5": { + "source": "zen", + "input": 0.3, + "output": 1.2, + "cacheRead": 0.06, + "cacheWrite": null + }, + "glm-5.3-flash": { + "source": "zen", + "input": 0.15, + "output": 0.5, + "cacheRead": 0.03, + "cacheWrite": null + }, + "glm-5.3": { + "source": "zen", + "input": 1.4, + "output": 4.4, + "cacheRead": 0.26, + "cacheWrite": null + }, + "glm-5.2": { + "source": "zen", + "input": 1.4, + "output": 4.4, + "cacheRead": 0.26, + "cacheWrite": null + }, + "glm-5.1": { + "source": "zen", + "input": 1.4, + "output": 4.4, + "cacheRead": 0.26, + "cacheWrite": null + }, + "glm-5": { + "source": "zen", + "input": 1, + "output": 3.2, + "cacheRead": 0.2, + "cacheWrite": null + }, + "kimi-k2.7-code": { + "source": "zen", + "input": 0.95, + "output": 4, + "cacheRead": 0.19, + "cacheWrite": null + }, + "kimi-k3": { + "source": "zen", + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": null + }, + "kimi-k2.6": { + "source": "zen", + "input": 0.95, + "output": 4, + "cacheRead": 0.16, + "cacheWrite": null + }, + "kimi-k2.5": { + "source": "zen", + "input": 0.6, + "output": 3, + "cacheRead": 0.1, + "cacheWrite": null + }, + "mistral-large-4": { + "source": "zen", + "input": 0.68, + "output": 2.09, + "cacheRead": 0.07, + "cacheWrite": null + }, + "qwen3.8-max": { + "source": "zen", + "input": 2, + "output": 6, + "cacheRead": 0.25, + "cacheWrite": 2.5 + }, + "qwen3.8-flash": { + "source": "zen", + "input": 0.15, + "output": 0.47, + "cacheRead": 0.016, + "cacheWrite": 0.2 + }, + "qwen3.7-max": { + "source": "zen", + "input": 2.5, + "output": 7.5, + "cacheRead": 0.5, + "cacheWrite": 3.125 + }, + "qwen3.7-plus": { + "source": "zen", + "input": 0.4, + "output": 1.6, + "cacheRead": 0.04, + "cacheWrite": 0.5 + }, + "qwen3.6-plus": { + "source": "zen", + "input": 0.5, + "output": 3, + "cacheRead": 0.05, + "cacheWrite": 0.625 + }, + "qwen3.5-plus": { + "source": "zen", + "input": 0.2, + "output": 1.2, + "cacheRead": 0.02, + "cacheWrite": 0.25 + }, + "deepseek-v4.1-flash": { + "source": "deepseek", + "peak": { + "input": 0.3, + "output": 1.2, + "cacheRead": 0.006, + "cacheWrite": null + }, + "offPeak": { + "input": 0.15, + "output": 0.6, + "cacheRead": 0.003, + "cacheWrite": null + }, + "fallback": { + "source": "zen", + "input": 0.3, + "output": 1.2, + "cacheRead": 0.006, + "cacheWrite": null + } + }, + "deepseek-v4-pro": { + "source": "deepseek", + "peak": { + "input": 1.32, + "output": 3.96, + "cacheRead": 0.044, + "cacheWrite": null + }, + "offPeak": { + "input": 0.66, + "output": 1.98, + "cacheRead": 0.022, + "cacheWrite": null + }, + "fallback": { + "source": "zen", + "input": 1.74, + "output": 3.48, + "cacheRead": 0.145, + "cacheWrite": null + } + }, + "claude-fable-5-1": { + "source": "zen", + "input": 10, + "output": 50, + "cacheRead": 0.25, + "cacheWrite": 12.5 + }, + "claude-fable-5": { + "source": "zen", + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 12.5 + }, + "claude-opus-5-5": { + "source": "zen", + "input": 4, + "output": 20, + "cacheRead": 0.2, + "cacheWrite": 5 + }, + "claude-opus-5": { + "source": "zen", + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "claude-opus-4-8": { + "source": "zen", + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "claude-opus-4-7": { + "source": "zen", + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "claude-opus-4-6": { + "source": "zen", + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "claude-opus-4-5": { + "source": "zen", + "input": 5, + "output": 25, + "cacheRead": 0.5, + "cacheWrite": 6.25 + }, + "claude-sonnet-5-5": { + "source": "zen", + "input": 2, + "output": 10, + "cacheRead": 0.2, + "cacheWrite": 2.5 + }, + "claude-sonnet-5": { + "source": "zen", + "input": 2, + "output": 10, + "cacheRead": 0.2, + "cacheWrite": 2.5 + }, + "claude-sonnet-4-6": { + "source": "zen", + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "claude-sonnet-4-5": { + "source": "zen", + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75, + "contextTier": "≤ 200K tokens" + }, + "claude-haiku-5-5": { + "source": "zen", + "input": 0.1, + "output": 0.5, + "cacheRead": 0.01, + "cacheWrite": 0.125, + "contextTier": "≤ 100K tokens" + }, + "claude-haiku-4-5": { + "source": "zen", + "input": 1, + "output": 5, + "cacheRead": 0.1, + "cacheWrite": 1.25 + }, + "gemini-4-argon": { + "source": "zen", + "input": 2, + "output": 10, + "cacheRead": 0.1, + "cacheWrite": null + }, + "gemini-3.8-flash": { + "source": "zen", + "input": 1.5, + "output": 7.5, + "cacheRead": 0.15, + "cacheWrite": null + }, + "gemini-3.7-flash": { + "source": "zen", + "input": 1.5, + "output": 7.5, + "cacheRead": 0.15, + "cacheWrite": null + }, + "gemini-3.6-flash": { + "source": "zen", + "input": 1.5, + "output": 7.5, + "cacheRead": 0.15, + "cacheWrite": null + }, + "gemini-3.5-flash": { + "source": "zen", + "input": 1.5, + "output": 9, + "cacheRead": 0.15, + "cacheWrite": null + }, + "gemini-3.5-flash-lite": { + "source": "zen", + "input": 0.3, + "output": 2.5, + "cacheRead": 0.03, + "cacheWrite": null + }, + "gemini-3.1-pro": { + "source": "zen", + "input": 2, + "output": 12, + "cacheRead": 0.2, + "cacheWrite": null, + "contextTier": "≤ 200K tokens" + }, + "gemini-3-flash": { + "source": "zen", + "input": 0.5, + "output": 3, + "cacheRead": 0.05, + "cacheWrite": null + }, + "grok-4.7": { + "source": "zen", + "input": 2, + "output": 6, + "cacheRead": 0.5, + "cacheWrite": null, + "contextTier": "≤ 200K tokens" + }, + "grok-4.6": { + "source": "zen", + "input": 2, + "output": 6, + "cacheRead": 0.5, + "cacheWrite": null, + "contextTier": "≤ 200K tokens" + }, + "grok-4.5": { + "source": "zen", + "input": 2, + "output": 6, + "cacheRead": 0.3, + "cacheWrite": null, + "contextTier": "≤ 200K tokens" + }, + "grok-build-0.1": { + "source": "zen", + "input": 1, + "output": 2, + "cacheRead": 0.2, + "cacheWrite": null + }, + "muse-spark-1.3": { + "source": "zen", + "input": 1.25, + "output": 4.25, + "cacheRead": 0.15, + "cacheWrite": null + }, + "muse-spark-1.2": { + "source": "zen", + "input": 1.25, + "output": 4.25, + "cacheRead": 0.15, + "cacheWrite": null + }, + "gpt-6-astra": { + "source": "zen", + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 12.5, + "contextTier": "≤ 272K tokens" + }, + "gpt-6-sol": { + "source": "zen", + "input": 2, + "output": 10, + "cacheRead": 0.2, + "cacheWrite": 2.5, + "contextTier": "≤ 272K tokens" + }, + "gpt-6.1-sol": { + "source": "zen", + "input": 2, + "output": 10, + "cacheRead": 0.2, + "cacheWrite": 2.5, + "contextTier": "≤ 272K tokens" + }, + "gpt-6-luna": { + "source": "zen", + "input": 0.1, + "output": 0.5, + "cacheRead": 0.01, + "cacheWrite": 0.125, + "contextTier": "≤ 272K tokens" + }, + "gpt-5.6-sol": { + "source": "zen", + "input": 4, + "output": 20, + "cacheRead": 0.4, + "cacheWrite": 5, + "contextTier": "≤ 272K tokens" + }, + "gpt-5.6-terra": { + "source": "zen", + "input": 2, + "output": 12, + "cacheRead": 0.2, + "cacheWrite": 2.5, + "contextTier": "≤ 272K tokens" + }, + "gpt-5.6-luna": { + "source": "zen", + "input": 0.2, + "output": 1.2, + "cacheRead": 0.02, + "cacheWrite": 0.25, + "contextTier": "≤ 272K tokens" + }, + "gpt-5.5": { + "source": "zen", + "input": 5, + "output": 30, + "cacheRead": 0.5, + "cacheWrite": null, + "contextTier": "≤ 272K tokens" + }, + "gpt-5.5-pro": { + "source": "zen", + "input": 30, + "output": 180, + "cacheRead": 30, + "cacheWrite": null + }, + "gpt-5.4": { + "source": "zen", + "input": 2.5, + "output": 15, + "cacheRead": 0.25, + "cacheWrite": null, + "contextTier": "≤ 272K tokens" + }, + "gpt-5.4-pro": { + "source": "zen", + "input": 30, + "output": 180, + "cacheRead": 30, + "cacheWrite": null + }, + "gpt-5.4-mini": { + "source": "zen", + "input": 0.75, + "output": 4.5, + "cacheRead": 0.075, + "cacheWrite": null + }, + "gpt-5.4-nano": { + "source": "zen", + "input": 0.2, + "output": 1.25, + "cacheRead": 0.02, + "cacheWrite": null + }, + "gpt-5.3-codex-spark": { + "source": "zen", + "input": 1.75, + "output": 14, + "cacheRead": 0.175, + "cacheWrite": null + }, + "gpt-5.3-codex": { + "source": "zen", + "input": 1.75, + "output": 14, + "cacheRead": 0.175, + "cacheWrite": null + }, + "gpt-5.2": { + "source": "zen", + "input": 1.75, + "output": 14, + "cacheRead": 0.175, + "cacheWrite": null + }, + "gpt-5.2-codex": { + "source": "zen", + "input": 1.75, + "output": 14, + "cacheRead": 0.175, + "cacheWrite": null + }, + "gpt-5.1": { + "source": "zen", + "input": 1.07, + "output": 8.5, + "cacheRead": 0.107, + "cacheWrite": null + }, + "gpt-5.1-codex": { + "source": "zen", + "input": 1.07, + "output": 8.5, + "cacheRead": 0.107, + "cacheWrite": null + }, + "gpt-5.1-codex-max": { + "source": "zen", + "input": 1.25, + "output": 10, + "cacheRead": 0.125, + "cacheWrite": null + }, + "gpt-5.1-codex-mini": { + "source": "zen", + "input": 0.25, + "output": 2, + "cacheRead": 0.025, + "cacheWrite": null + }, + "gpt-5": { + "source": "zen", + "input": 1.07, + "output": 8.5, + "cacheRead": 0.107, + "cacheWrite": null + }, + "gpt-5-codex": { + "source": "zen", + "input": 1.07, + "output": 8.5, + "cacheRead": 0.107, + "cacheWrite": null + }, + "gpt-5-nano": { + "source": "zen", + "input": 0.05, + "output": 0.4, + "cacheRead": 0.005, + "cacheWrite": null + }, + "deepseek-flash": { + "source": "deepseek", + "peak": { + "input": 0.3, + "output": 1.2, + "cacheRead": 0.006, + "cacheWrite": null + }, + "offPeak": { + "input": 0.15, + "output": 0.6, + "cacheRead": 0.003, + "cacheWrite": null + } + } + }, + "surcharges": { + "claude-sonnet-4-5": { + "input": 6, + "output": 22.5, + "cacheRead": 0.6, + "tier": "> 200K tokens" + }, + "claude-haiku-5-5": { + "input": 0.5, + "output": 2.5, + "cacheRead": 0.05, + "tier": "> 100K tokens" + }, + "gemini-3.1-pro": { + "input": 4, + "output": 18, + "cacheRead": 0.4, + "tier": "> 200K tokens" + }, + "grok-4.7": { + "input": 4, + "output": 12, + "cacheRead": 1, + "tier": "> 200K tokens" + }, + "grok-4.6": { + "input": 4, + "output": 12, + "cacheRead": 1, + "tier": "> 200K tokens" + }, + "grok-4.5": { + "input": 4, + "output": 12, + "cacheRead": 0.6, + "tier": "> 200K tokens" + }, + "gpt-6-astra": { + "input": 20, + "output": 75, + "cacheRead": 2, + "tier": "> 272K tokens" + }, + "gpt-6-sol": { + "input": 4, + "output": 15, + "cacheRead": 0.4, + "tier": "> 272K tokens" + }, + "gpt-6.1-sol": { + "input": 4, + "output": 15, + "cacheRead": 0.4, + "tier": "> 272K tokens" + }, + "gpt-6-luna": { + "input": 0.2, + "output": 0.75, + "cacheRead": 0.02, + "tier": "> 272K tokens" + }, + "gpt-5.6-sol": { + "input": 8, + "output": 30, + "cacheRead": 0.8, + "tier": "> 272K tokens" + }, + "gpt-5.6-terra": { + "input": 4, + "output": 18, + "cacheRead": 0.4, + "tier": "> 272K tokens" + }, + "gpt-5.6-luna": { + "input": 0.4, + "output": 1.8, + "cacheRead": 0.04, + "tier": "> 272K tokens" + }, + "gpt-5.5": { + "input": 10, + "output": 45, + "cacheRead": 1, + "tier": "> 272K tokens" + }, + "gpt-5.4": { + "input": 5, + "output": 22.5, + "cacheRead": 0.5, + "tier": "> 272K tokens" + } + }, + "deprecated": [ + { + "name": "GPT 5.2 Codex", + "date": "July 23, 2026" + }, + { + "name": "GPT 5.1 Codex", + "date": "July 23, 2026" + }, + { + "name": "GPT 5.1 Codex Max", + "date": "July 23, 2026" + }, + { + "name": "GPT 5.1 Codex Mini", + "date": "July 23, 2026" + }, + { + "name": "GPT 5 Codex", + "date": "July 23, 2026" + }, + { + "name": "Claude Opus 4.1", + "date": "August 5, 2026" + }, + { + "name": "Claude Sonnet 4", + "date": "June 15, 2026" + }, + { + "name": "Claude Haiku 3.5", + "date": "February 16, 2026" + }, + { + "name": "Gemini 3 Pro", + "date": "March 9, 2026" + }, + { + "name": "MiniMax M2.5", + "date": "August 5, 2026" + }, + { + "name": "MiniMax M2.1", + "date": "March 15, 2026" + }, + { + "name": "GLM 5", + "date": "May 14, 2026" + }, + { + "name": "GLM 4.7", + "date": "March 15, 2026" + }, + { + "name": "GLM 4.6", + "date": "March 15, 2026" + }, + { + "name": "Kimi K2.5", + "date": "August 5, 2026" + }, + { + "name": "Kimi K2 Thinking", + "date": "March 6, 2026" + }, + { + "name": "Kimi K2", + "date": "March 6, 2026" + }, + { + "name": "Qwen3 Coder 480B", + "date": "February 6, 2026" + } + ], + "notes": [ + { + "id": "mistral-large-4", + "kind": "promo", + "text": "促销价生效(原价 1.36 → 0.68)" + }, + { + "id": "gemini-4-argon", + "kind": "promo", + "text": "促销价生效(原价 4 → 2)" + } + ] +}; diff --git a/src/host/pricing.js b/src/host/pricing.js new file mode 100644 index 0000000..d014d41 --- /dev/null +++ b/src/host/pricing.js @@ -0,0 +1,364 @@ +/** + * The reference price table and the published peak rule, as one lookup with a + * visible resolution ladder. + * + * The problem this file exists for: a model id stored in a session log is not + * the key of any price table. The same model arrives as `deepseek-flash`, + * `deepseek-v4.1-flash`, `deepseek/deepseek-v4.1-flash`, + * `group/auto-deepseek-v4-1-flash`, or under a retired alias; the table is keyed + * by whatever the vendor — or opencode Zen — called it on the day the row was + * written. Guessing that mapping with string similarity silently prices a model + * at someone else's rate (a 0.62 similarity threshold once billed + * `deepseek-v4-flash` at 0.14/0.28 instead of 0.30/1.20), so every level below + * is deterministic and every outcome is reported. + * + * Resolution order (first hit wins), in ONE function: + * 1. the user's explicit `prices` entry how: 'custom' + * 2. a match the user confirmed in the dialog how: 'confirmed' + * 3. a normalized literal match on the table how: 'normalized' + * 4. the alias table how: 'alias' + * 5. a variant suffix on a published model how: 'variant' + * anything else how: 'unmatched' + * + * Literal beats everything inferred, which is what makes `-free` behave: an id + * the table really publishes (`glm-5.3-flash-free`) is an EXACT match and keeps + * its own published price (0 while the promotion runs); only a `-free` variant + * the table does not carry falls through to the variant rung, and that one is + * reported as fuzzy. The old order put the twin first, so every `-free` id — + * including the ones the table publishes — read as 模糊 and was billed at its + * paid sibling, i.e. the promo's real price of 0 was replaced by a guess. + * + * The variant rung is a closed list of rename suffixes (`-free`, `-exp`, + * `-fast`, `-latest`, a four-digit date), each resolved against a literal table + * row — never a similarity score. That is how `deepseek-v4.1-flash-fast` and + * `deepseek-v4.1-flash-1017` land on `deepseek-v4.1-flash` as 模糊 instead of + * 未匹配, while `deepseek-v4-flash-0731` (no such base row) stays unmatched. + * + * Every hit also carries `source`: where the answered price came from + * ('deepseek', 'zen' or 'custom'), so the UI can say which of the three kinds of + * reference id a row matched. + * + * Two vocabularies, and only these two, leave this file: + * - `how` — which rung of the ladder answered (5 values, for the tooltip); + * - `state` — what the user sees (4 values: exact / fuzzy / none / custom). + * `state` is derived HERE so no caller re-derives it: a client that mapped + * `how` itself once shipped a `'literal'` branch the ladder never emits, and + * every exact hit read as 未匹配 while the dashboard billed it. + * + * One rate shape, too. Whatever a row carries — a flat reference row, a DeepSeek + * row with `peak`/`offPeak`, or a hand-entered `inputPerM` row — `ratesOf` + * flattens it to `{ input, output, cacheRead, cacheWrite }` per tier. Nothing + * else in the codebase reads a price field. + */ +import { REFERENCE_PRICES } from './prices.js'; +import { holidayCoverage, holidayOf as holidayOfDefault, isPeak } from './peak.js'; + +/** Re-exported so callers keep one import site for "the calendar". */ +export { holidayCoverage, holidayOfDefault }; + +/** + * Aliases the table cannot derive on its own, each with the reason it exists. + * + * Only add an entry here when the mapping is a fact about the vendor's naming, + * not when two ids merely look alike: everything else is handled by the + * normalized match below. + */ +export const ALIASES = { + // Legacy names the vendor retired but that still appear in older session logs. + 'deepseek-chat': { to: 'deepseek-v4.1-flash', why: '官方 2026-07-24 停用;历史会话按现役 Flash 价估算' }, + 'deepseek-reasoner': { to: 'deepseek-v4.1-flash', why: '官方 2026-07-24 停用;历史会话按现役 Flash 价估算' }, + 'deepseek-v4-flash': { to: 'deepseek-v4.1-flash', why: '官方已退役,请求被路由到 V4.1 Flash 并按 Flash 价计费' }, +}; + +/** + * Suffixes that mark a variant of a model the table DOES publish. + * + * The list is closed and deterministic on purpose: `-free` / `-exp` (promo and + * experimental twins), `-fast` (the speed tier several providers resell), and a + * four-digit publish date (`-1017`, Zen's dated ids). The base id has to be a + * table row, so a suffix on an unknown model still lands in 未匹配 — this is a + * rename rule, not a similarity score. Two ids that merely look alike + * (`deepseek-v4-flash-0731`) never collapse into each other unless the rules + * below say so and the base exists. + */ +const VARIANT_SUFFIX = /-(?:free|exp|fast|latest|\d{4})$/; + +/** Rate field on a price row, and the older spelling a saved config may still use. */ +const RATE_FIELDS = [ + ['input', 'inputPerM'], + ['output', 'outputPerM'], + ['cacheRead', 'cacheReadPerM'], + ['cacheWrite', 'cacheWritePerM'], +]; + +/** + * Literal normalization: case, provider prefix, separator style, router prefix. + * + * The provider prefix is everything up to the LAST '/', because an id may itself + * contain one (`deepseek/deepseek-v4.1-flash`). Version suffixes are deliberately + * NOT stripped: `gpt-5.4` and `gpt-5` are different products, and a trailing + * date (`-1017`) is part of the id Zen publishes. + * + * `.` and `-` are unified because the two tables spell one model differently + * (`deepseek-v4.1-flash` vs `deepseek-v4-1-flash`), and a leading `auto-` is + * dropped because DSH's router reports `group/auto-`. Both rules are only + * safe while no two table ids collide into one key, which `price-data.test.js` + * enforces rather than trusting. + * + * @param {string} raw - model id as stored by the harness. + * @returns {string} the comparable form, never null. + */ +export function normalizeId(raw) { + const text = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (text === '') return ''; + const bare = text.includes('/') ? text.slice(text.lastIndexOf('/') + 1) : text; + return bare.replace(/^auto-/, '').replace(/_/g, '-').replace(/\./g, '-'); +} + +/** Lazily built `normalizeId(table id) -> table id` index. */ +let index = null; +function tableIndex() { + if (index !== null) return index; + index = new Map(); + for (const id of Object.keys(REFERENCE_PRICES.models)) { + const key = normalizeId(id); + // First writer wins: a later duplicate would otherwise shadow the canonical + // id depending on object order. The collision itself is a test failure. + if (!index.has(key)) index.set(key, id); + } + return index; +} + +/** Which tier an instant bills at. Beijing time, weekend and holiday aware. */ +export function tierAt(atMs, holidayOf = holidayOfDefault) { + return isPeak(atMs, holidayOf) ? 'peak' : 'off'; +} + +/** + * Flatten any price row to the four rates one tier bills. + * + * This is the only place that knows a price can be shaped three ways. Callers + * ask for a tier and get numbers or null; a missing field is 0, never NaN. + * + * @param {object|null} price - a ladder hit, a hand-entered price, or null. + * @param {'peak'|'off'} [tier] - which tier of a two-tier row to read. + * @returns {{ input: number, output: number, cacheRead: number, cacheWrite: number } | null} + */ +export function ratesOf(price, tier = 'peak') { + if (!price || typeof price !== 'object') return null; + let row = price; + if (price.peak !== undefined || price.offPeak !== undefined) { + const sub = tier === 'off' ? price.offPeak : price.peak; + // A tiered row with one side missing still answers: the present tier is the + // best fact available, and rateOf(0) would invent a price nobody published. + row = sub !== undefined && sub !== null ? sub : price; + } + const out = {}; + for (const [canonical, legacy] of RATE_FIELDS) { + const v = row[canonical] !== undefined ? row[canonical] : row[legacy]; + out[canonical] = typeof v === 'number' && Number.isFinite(v) ? v : 0; + } + return out; +} + +/** + * Resolve one model id to a price. + * + * Everything that can answer "what does this id cost" lives here — including the + * user's confirmed matches — because a second function wrapping this one is how + * `how` and the price diverged once already (`propose` reported 未匹配 on models + * the query billed). + * + * @param {object} options - lookup inputs. + * @param {string} options.model - model id from a usage record. + * @param {string} [options.provider] - provider id from the same record. + * @param {Record} [options.custom] - the user's explicit price + * table, keyed by `provider/model` or by bare model id. + * @param {Record} [options.matches] - confirmed matches, same keys, + * whose value is a reference-table id. + * @returns {{ price: object|null, how: string, state: string, target: string|null, why: string }} + * `price` is the row as published (flat, or with both tiers), never a + * pre-selected tier: the tier belongs to the billing site, which is the only + * place that knows an instant. `how` is the ladder rung, `state` the + * four-value verdict the UI shows. + */ +export function resolvePrice(options) { + const model = typeof options?.model === 'string' ? options.model : ''; + const provider = typeof options?.provider === 'string' ? options.provider : ''; + const custom = options?.custom && typeof options.custom === 'object' ? options.custom : null; + const matches = options?.matches && typeof options.matches === 'object' ? options.matches : null; + const composite = provider === '' ? '' : provider + '/' + model; + + /** One table row, as published. */ + const rowOf = (id) => (Object.prototype.hasOwnProperty.call(REFERENCE_PRICES.models, id) ? REFERENCE_PRICES.models[id] : null); + + // 1. The user's own price always wins, composite key before bare id. + if (custom !== null) { + const own = (composite !== '' && custom[composite]) || custom[model]; + if (own && typeof own === 'object') { + return { price: own, how: 'custom', state: 'custom', target: null, why: '用户自定义单价', source: 'custom' }; + } + } + + // 2. A match the user confirmed. It outranks the automatic ladder — it is a + // fact the user established — but never the explicit price above. A target + // may be one of the user's own rows as well as a table row, because a + // "custom" reference is one of the three kinds of reference id. + if (matches !== null) { + const confirmed = (composite !== '' && matches[composite]) || matches[model]; + if (typeof confirmed === 'string' && confirmed !== '') { + const own = custom !== null && Object.prototype.hasOwnProperty.call(custom, confirmed) ? custom[confirmed] : null; + if (own !== null && typeof own === 'object') { + return { price: own, how: 'confirmed', state: 'custom', target: confirmed, why: '用户确认的匹配(自定义价)', source: 'custom' }; + } + const price = rowOf(confirmed); + if (price !== null) { + return { price, how: 'confirmed', state: 'custom', target: confirmed, why: '用户确认的匹配', source: price.source }; + } + // The confirmed target left the table. Say so instead of falling through + // silently: a stale confirmation is a fact the user should see, not a + // price that quietly changed. + return { price: null, how: 'unmatched', state: 'none', target: confirmed, why: '已确认的参考模型不在当前价目表中', source: null }; + } + } + + const bare = normalizeId(model); + + // 3. A normalized literal match. This runs before the two inferred rungs: an + // id the table really publishes is a fact, and an inference may not + // override it. It is also the `-free` rule — a published `-free` row is + // exact and keeps its own price (0 during a promotion); a `-free` id the + // table does not carry is what the twin rung exists for. + const direct = tableIndex().get(bare); + if (direct !== undefined) { + const row = rowOf(direct); + return { price: row, how: 'normalized', state: 'exact', target: direct, why: '与价目表 id 字面一致', source: row.source }; + } + + // 4. The alias table. + const alias = ALIASES[bare] ?? ALIASES[model]; + if (alias !== undefined) { + const price = rowOf(alias.to); + if (price !== null) { + return { price, how: 'alias', state: 'fuzzy', target: alias.to, why: alias.why, source: price.source }; + } + } + + // 5. A variant of a model the table publishes, under a name the table does not: + // `deepseek-v4.1-flash-fast`, `deepseek-v4.1-flash-1017`, or a `-free` / + // `-exp` twin. Bill it at the base row and report it as 模糊 — the id is + // inferred, not literal, so the user can still see and override it. + // + // The base must exist in the table; otherwise this rung does nothing and the + // id stays 未匹配 (no guessing). A published `-free` row never reaches here: + // the literal rung above already answered it. + const variantBase = bare.replace(VARIANT_SUFFIX, ''); + if (variantBase !== bare) { + const baseId = tableIndex().get(variantBase); + if (baseId !== undefined) { + const baseRow = rowOf(baseId); + return { + price: baseRow, + how: 'variant', + state: 'fuzzy', + target: baseId, + why: '同一模型的变体名(' + bare.slice(variantBase.length) + ' 后缀),按 ' + baseId + ' 的价估算', + source: baseRow.source, + }; + } + } + + return { price: null, how: 'unmatched', state: 'none', target: null, why: '价目表里没有可对应的模型', source: null }; +} + +/** + * Reference cost of one folded record. + * + * The record supplies either `tokens` buckets (peak / off, computed while + * folding, which is the only place an event's own timestamp is available) or a + * flat token set, in which case the record's own instant picks the tier. + * + * @param {{ inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, + * tokens?: { peak?: object, off?: object }, time?: number }} record - a usage record. + * @param {object} price - any shape `ratesOf` accepts, USD per 1M tokens. + * @returns {number} USD. + */ +export function costOf(record, price) { + if (!record || !price) return 0; + const bill = (bucket, tier) => { + const rate = ratesOf(price, tier); + if (rate === null) return 0; + return ( + ((bucket && bucket.inputTokens) || 0) * rate.input + + ((bucket && bucket.outputTokens) || 0) * rate.output + + ((bucket && bucket.cacheReadTokens) || 0) * rate.cacheRead + + ((bucket && bucket.cacheWriteTokens) || 0) * rate.cacheWrite + ) / 1e6; + }; + const buckets = record.tokens; + if (buckets && (buckets.peak || buckets.off)) return bill(buckets.peak, 'peak') + bill(buckets.off, 'off'); + return bill(record, tierAt(typeof record.time === 'number' ? record.time : Date.now())); +} + +/** The table, for the settings dialog's model picker. */ +export function referenceTable() { + return REFERENCE_PRICES; +} + +/** + * The wire shape of one priced model, shared by `query` and `propose`. + * + * Both endpoints describing the same model must describe it identically; the + * one time they did not, the dialog showed 未匹配 on rows the dashboard billed. + * `rates` carries BOTH tiers so the client never has to guess a tier itself — + * it prints `rates[tier]`, where `tier` is the host's own answer for "now". + * + * `source` names the kind of reference id that answered: 'deepseek', 'zen' or + * 'custom'. Those are the three kinds the UI groups by, so it travels with the + * row instead of being looked up again on the other side. + * + * @param {{ price: object|null, how: string, state: string, target: string|null, why: string, + * source?: string|null }} hit - ladder output. + * @param {number} [atMs] - instant whose tier the UI labels the price with. + * @param {(date: string) => boolean} [holidayOf] - holiday test, for tests. + * @returns {{ state: string, how: string, target: string|null, why: string, + * source: string|null, tier: 'peak'|'off', rates: { peak: object, offPeak: object } | null }} + */ +export function priceRow(hit, atMs = Date.now(), holidayOf = holidayOfDefault) { + return { + state: hit.state, + how: hit.how, + target: hit.target === undefined ? null : hit.target, + why: hit.why === undefined ? null : hit.why, + source: hit.source === undefined ? null : hit.source, + tier: tierAt(atMs, holidayOf), + rates: hit.price === null + ? null + : { peak: ratesOf(hit.price, 'peak'), offPeak: ratesOf(hit.price, 'off') }, + }; +} + +/** + * The two generated reference tables, grouped, with the source each id came from. + * + * 'custom' is deliberately NOT a group here: in the dialog it is a single option + * ("type your own price"), not a list of ids — a hand-entered price is three + * numbers for one model key, not a reference model anyone could point another id + * at. DeepSeek first, then Zen. + * + * @returns {{ source: string, ids: string[] }[]} two groups, each stable-sorted. + */ +export function referenceGroups() { + const groups = [ + { source: 'deepseek', ids: [] }, + { source: 'zen', ids: [] }, + ]; + const bySource = new Map(groups.map((g) => [g.source, g])); + for (const [id, entry] of Object.entries(REFERENCE_PRICES.models)) { + const group = bySource.get(entry && entry.source); + if (group !== undefined) group.ids.push(id); + } + for (const group of groups) group.ids.sort(); + return groups; +} + diff --git a/src/host/query.js b/src/host/query.js index 0128b58..448c799 100644 --- a/src/host/query.js +++ b/src/host/query.js @@ -8,6 +8,7 @@ import { dayKey, DAY_MS, bucketKeyFor, stepMs, alignFloor, stepDayKey, MAX_BUCKETS, } from './time.js'; import { currencyOf } from './settings.js'; +import { holidayCoverage, priceRow, referenceTable } from './pricing.js'; function emptyBucket(key) { return { @@ -38,6 +39,29 @@ function compositeModelKey(r) { return r.provider ? r.provider + '/' + r.model : r.model; } +/** + * Where the numbers came from, so the dashboard can say it out loud. + * + * A reference cost is an estimate against a published list, not a bill: the card + * has to name both sources and the day they were read, or a user comparing it to + * an invoice has no way to tell a wrong number from a different scope. + * + * @param {object} state - runtime state capsule. + * @returns {{ source: string, capturedAt: string|null, note: string, holidays: object }} + */ +function pricingMeta(state) { + const table = referenceTable(); + const names = Array.isArray(table.sources) ? table.sources.map((s) => s && s.name).filter(Boolean) : []; + return { + // Named from the table itself: a hard-coded string here is a second source + // of truth that keeps claiming a source the generator no longer reads. + source: names.length > 0 ? names.join(' + ') : '—', + capturedAt: typeof table.capturedAt === 'string' ? table.capturedAt : null, + note: '按 opencode Zen 和 DeepSeek 最新定价口径估算', + holidays: holidayCoverage(), + }; +} + export function query(state, args) { const from = typeof args.from === 'number' ? args.from : -Infinity; const to = typeof args.to === 'number' ? args.to : Infinity; @@ -60,6 +84,11 @@ export function query(state, args) { const total = emptyBucket('total'); const seenModels = new Map(); // composite key -> {key, provider, model} (first composite wins) let unpricedCalls = 0; + // Models that resolved to no price at all, and how the rest were priced. The + // dialog reads the first list; the card note reads the second (a reference + // price and a hand-entered one are not the same claim). + const unmatched = new Map(); // composite key -> { key, provider, model, calls, totalTokens } + const resolution = new Map(); // composite key -> priceRow() // Range-anchored day-step grid: blocks start at the range's first day. const dayAnchor = Number.isFinite(from) ? alignFloor(from, 'day') : null; const useStepGrid = granularity === 'day' && stepDays > 1 && dayAnchor !== null; @@ -72,8 +101,19 @@ export function query(state, args) { const modelKey = compositeModelKey(r); if (!seenModels.has(modelKey)) seenModels.set(modelKey, { key: modelKey, provider: r.provider, model: r.model }); if (!modelMatches(r)) continue; - const cost = state.prices.costOf(r); - if (r.totalTokens > 0 && !state.prices.hasPrice(r)) unpricedCalls += 1; + const hit = state.prices.lookup(r); + const cost = hit.price === null ? 0 : state.prices.costOf(r); + if (r.totalTokens > 0 && hit.price === null) { + unpricedCalls += 1; + const seen = unmatched.get(modelKey); + if (seen === undefined) { + unmatched.set(modelKey, { key: modelKey, provider: r.provider, model: r.model, calls: 1, totalTokens: r.totalTokens }); + } else { + seen.calls += 1; + seen.totalTokens += r.totalTokens; + } + } + if (!resolution.has(modelKey)) resolution.set(modelKey, priceRow(hit)); const dk = useStepGrid ? stepDayKey(r, dayAnchor, stepDays) : bucketKeyFor(r.time, granularity, stepDays); let db = dailyMap.get(dk); @@ -164,14 +204,31 @@ export function query(state, args) { total.cost = toDisplay(total.cost); if (heatmap !== null) for (const b of heatmap) b.cost = toDisplay(b.cost); + // Per-model verdict, straight from the ladder's own row shape (`priceRow`), so + // a model the dashboard bills and the dialog shows carry the same four-value + // `state` — the dashboard does not re-derive one from `how` any more. + for (const row of byModel) { + const known = resolution.get(row.key); + if (known !== undefined) Object.assign(row, known); + if (unmatched.has(row.key)) row.unpriced = true; + } + return { daily, byModel, total, models, unpricedCalls, + unmatched: [...unmatched.values()], + pricing: pricingMeta(state), currency: { code: money.code, symbol: money.symbol }, + // Which client→host channel this process actually opened, and why — the + // plugin logger is not always wired into the harness log, so the decision + // rides along with the data the dashboard already asks for. + channel: state.channel ?? null, ...heatmap === null ? {} : { heatmap }, backfill: { status: state.backfill.status, total: state.backfill.total, done: state.backfill.done }, + // Diagnostics for "why was the first load slow": one object per scan pass. + scan: state.lastPass || null, }; } diff --git a/src/host/rpc.js b/src/host/rpc.js index 7c95788..9983c6b 100644 --- a/src/host/rpc.js +++ b/src/host/rpc.js @@ -37,6 +37,8 @@ * i.e. the same result shape the legacy RPC channel wraps, minus its envelope. */ +import { registerTypert } from './typert.js'; + /** Absolute logical channel owned by this plugin on pre-0.2 hosts. */ export const USAGE_RPC_CHANNEL = '/usage'; @@ -56,6 +58,9 @@ export function fetchRoutePath(endpoint) { /** * Failure result in the Connection RPC result shape. * + * The message here is for the CALLER and must be safe to render: raw error text + * belongs in the host log (see `dispatch`), never on the page. + * * @param {string} message - human-readable failure. * @param {string} [code] - stable error code. * @returns {{ ok: false, error: { code: string, message: string, details: object } }} failure result. @@ -64,21 +69,60 @@ export function errorResult(message, code = 'internal') { return { ok: false, error: { code, message, details: {} } }; } +/** What the page is told when a handler throws: the detail goes to the log. */ +export const INTERNAL_MESSAGE = 'internal error'; + +/** + * Log sink for one host context. + * + * `ctx.logger` is the native channel (official plugins log through it); the + * console is only a fallback for a composition that has no logger. Wrapped + * because ctx is a strict proxy and a bare `ctx.logger` can throw. + * + * @param {any} ctx - host plugin context. + * @returns {(message: string, level?: 'warn' | 'error') => void} sink. + */ +export function logSink(ctx) { + let logger = null; + try { + logger = ctx && typeof ctx.get === 'function' ? ctx.get('logger') : null; + } catch { + logger = null; + } + if (!logger) { + try { + if (ctx && ctx.logger && typeof ctx.logger.error === 'function') logger = ctx.logger; + } catch { + logger = null; + } + } + if (logger && typeof logger.error === 'function') { + return (message, level = 'warn') => { + const fn = level === 'error' ? logger.error : (typeof logger.warn === 'function' ? logger.warn : logger.error); + fn.call(logger, message); + }; + } + return (message, level = 'warn') => (level === 'error' ? console.error(message) : console.warn(message)); +} + /** * Run one endpoint through the handler map, converting throws into results. * * @param {Map any>} handlers - endpoint handlers. * @param {string} endpoint - endpoint name. * @param {unknown} args - decoded endpoint arguments. + * @param {(message: string, level?: 'warn' | 'error') => void} [log] - host log sink for the raw error. * @returns {Promise<{ ok: true, value: any } | { ok: false, error: object }>} RPC result. */ -export async function dispatch(handlers, endpoint, args) { +export async function dispatch(handlers, endpoint, args, log) { const fn = handlers.get(endpoint); if (fn === undefined) return errorResult(`unknown endpoint: ${endpoint}`, 'bad-request'); try { return { ok: true, value: await fn(args) }; } catch (error) { - return errorResult(String((error && error.message) || error)); + const detail = String((error && error.stack) || (error && error.message) || error); + if (typeof log === 'function') log(`[usage] ${endpoint} failed: ${detail}`, 'error'); + return errorResult(INTERNAL_MESSAGE, 'internal'); } } @@ -87,9 +131,10 @@ export async function dispatch(handlers, endpoint, args) { * * @param {Map any>} handlers - endpoint handlers. * @param {string} endpoint - endpoint name. + * @param {(message: string, level?: 'warn' | 'error') => void} [log] - host log sink. * @returns {(request: Request) => Promise} route handler. */ -export function createFetchRoute(handlers, endpoint) { +export function createFetchRoute(handlers, endpoint, log) { return async function fetchRoute(request) { let args = null; const text = await request.text(); @@ -100,7 +145,7 @@ export function createFetchRoute(handlers, endpoint) { return Response.json(errorResult('request body is not JSON', 'bad-request'), { status: 400 }); } } - return Response.json(await dispatch(handlers, endpoint, args)); + return Response.json(await dispatch(handlers, endpoint, args, log)); }; } @@ -119,35 +164,21 @@ export function registerRPC(ctx, handlers) { return; } - // P0-3 official Typert gateway path. - // - // Target API: @deepseek-ai/dsh-api-gateway host service, exposed on the host - // Read the gateway only via `ctx.get('typertGateway')` — cordis ctx is a - // property). The installed v0.1.0-rc.7 `TypertGateway` interface exposes - // only invoke() (see the package's lib/types/types.d.ts); the documented - // contribution path (@Remote/@RemoteScope + TypertRemoteService or - // bindTypertRemote from @deepseek-ai/dsh-typert-protocol) needs a runtime - // dependency this plugin must not add, so we only claim endpoints through a - // registration surface that already exists on the detected object: - // `typert.register(channel, endpoints)`, a forward-compatible contribution - // hook for host compositions that expose the gateway with registration. + // Native Remote channel (see ./typert.js for the contract and its evidence). + // Registered in addition to the Fetch routes below, not instead of them: the + // browser half prefers Remote and falls back to Fetch, so both must be real. // - // Precondition for this path to activate: the resolved typert gateway is - // non-null AND `typert.register` is a function. Endpoint functions receive - // (args) and return the raw value; the gateway/connection side owns the - // RPC envelope. Otherwise we fall back silently to the connection bridge - // below. All guards are typeof-based: nothing is imported, zero deps. - // cordis ctx is a strict proxy: direct property access to an un-injected - // service throws — always use ctx.get (returns undefined when absent). - const typert = typeof ctx.get === 'function' ? ctx.get('typertGateway') : undefined; - if (typert && typeof typert.register === 'function') { - const endpoints = {}; - for (const [endpoint, fn] of handlers) { - endpoints[endpoint] = fn; - } - typert.register('usage', endpoints); - return; - } + // ⚠️ Do not probe `ctx.get('typert')` at activation time: cordis `get` returns + // only services whose providing fiber is ACTIVE (`_getImpl` checks + // `impl.fiber.state === 2`), and sibling bundles in this composition can + // activate after us. A one-shot probe reports "no typert registry" on a host + // that has one. Declare the dependency instead — the same pattern used for + // `connection` below — and record the decision when it lands. + const log = logSink(ctx); + const channel = { ok: false, transport: 'fetch', reason: 'typert not active yet (waiting for the dependency)' }; + ctx.inject(['typert'], (typertCtx) => { + Object.assign(channel, registerTypert(typertCtx, handlers, log)); + }); // Static (bundle) installs: the `connection` service is provided // asynchronously by the client-connection plugin, so wait for it before @@ -169,11 +200,11 @@ export function registerRPC(ctx, handlers) { path, methods: ['POST'], requestBody: 'buffered', - fetch: createFetchRoute(handlers, endpoint), + fetch: createFetchRoute(handlers, endpoint, log), }); mountedFetchRoutes += 1; } catch (error) { - console.warn(`[usage] failed to mount shared Fetch route ${path}: ${String((error && error.message) || error)}`); + log(`[usage] failed to mount shared Fetch route ${path}: ${String((error && error.message) || error)}`); } } } @@ -185,12 +216,13 @@ export function registerRPC(ctx, handlers) { const rpcRegistry = connection.rpc; if (rpcRegistry !== undefined && typeof rpcRegistry.handle === 'function') { try { - rpcRegistry.handle(USAGE_RPC_CHANNEL, async (endpoint, args) => dispatch(handlers, endpoint, args), { authority: 'loopback' }); + rpcRegistry.handle(USAGE_RPC_CHANNEL, async (endpoint, args) => dispatch(handlers, endpoint, args, log), { authority: 'loopback' }); } catch (error) { if (mountedFetchRoutes === 0) { - console.warn(`[usage] failed to mount ${USAGE_RPC_CHANNEL} RPC channel: ${String((error && error.message) || error)}`); + log(`[usage] failed to mount ${USAGE_RPC_CHANNEL} RPC channel: ${String((error && error.message) || error)}`); } } } }); + return channel; } diff --git a/src/host/scan.js b/src/host/scan.js index b219206..02790c8 100644 --- a/src/host/scan.js +++ b/src/host/scan.js @@ -15,10 +15,14 @@ * cut, so folding starts there and the parent is never billed twice. * * Optimization lives here: bounded-concurrency backfill, per-session failure - * backoff, revision-based skip (unchanged logs are not re-read), progress - * callbacks, and watermark repair when a log shrank. + * backoff, revision-based skip (unchanged logs are not re-read), the on-disk fold + * cache (unchanged logs are not even opened after a restart), progress callbacks, + * and watermark repair when a log shrank. */ import { foldNew } from './fold.js'; +import { cacheDir, readEntry, writeEntry } from './fold-cache.js'; +import { writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; /** How many persisted headers are read concurrently per batch. */ const BATCH_SIZE = 8; @@ -107,6 +111,115 @@ export async function readSessionEvents(persistence, id, fromSeq) { return { events: [], inherited: 0 }; } +/* -------------------------------------------------------------------------- * + * Fold cache plumbing + * -------------------------------------------------------------------------- */ + +/** + * Open one scan pass. + * + * The cache is optional and never required: a pass without it behaves exactly as + * before, which is what a failing cache degrades to. + * + * @param {{ cache?: boolean, cacheDir?: string }} [options] + * @returns {{ dir: string | null, scanned: Set, hydrated: number }} + */ +function beginPass(options) { + const enabled = !(options && options.cache === false); + return { + dir: enabled ? (options && options.cacheDir) || cacheDir() : null, + scanned: new Set(), + hydrated: 0, + startedAt: Date.now(), + listMs: 0, + }; +} + +/** + * One line per pass, so a slow start explains itself instead of being guessed at. + * + * The line goes to the logger AND into `state.lastPass`, which the dashboard + * response carries: on this host the plugin logger does not reliably reach the + * desktop log file, and a probe nobody can read is not a probe. + */ +function reportPass(pass, counts, log) { + const ms = Date.now() - pass.startedAt; + const summary = { + at: pass.startedAt, + sessions: counts.total, + hydrated: counts.hydrated, + folded: counts.folded, + listMs: pass.listMs, + totalMs: ms, + }; + return summary; +} + +/** Records grouped by owning session, in one pass over the record array. */ +function groupRecordsBySession(records) { + const by = new Map(); + for (const record of records) { + const id = record && record.session; + if (typeof id !== 'string') continue; + let list = by.get(id); + if (list === undefined) { + list = []; + by.set(id, list); + } + list.push(record); + } + return by; +} + +/** + * Cache one hydrated session's records and bookkeeping into the live state. + * + * Refuses an entry whose records do not belong to `id`: a mixed-up file would + * otherwise attribute one session's tokens to another. + * + * @returns {boolean} whether the entry was usable (and therefore consumed). + */ +function hydrateFromCache(state, id, entry, revision) { + for (const record of entry.records) { + if (!record || record.session !== id) return false; + } + for (const record of entry.records) { + state.records.push(record); + if (state.index) state.index.add(record); + } + state.watermarks.set(id, entry.watermark); + if (revision !== undefined) state.revisions.set(id, revision); + return true; +} + +/** + * Persist what this pass folded, then drop sessions that no longer exist. + * + * Only sessions this pass actually folded are rewritten: an untouched session's + * entry stays valid because its revision did not change. + */ +function finishPass(state, headers, pass) { + if (pass.dir === null) return; + if (pass.scanned.size > 0) { + const bySession = groupRecordsBySession(state.records); + for (const id of pass.scanned) { + writeEntry(pass.dir, id, { + revision: state.revisions.get(id), + watermark: state.watermarks.get(id) ?? -1, + records: bySession.get(id) || [], + }); + } + } + // ⚠️ Do NOT prune entries missing from this listing. `sp.list()` returns a + // PARTIAL list while the session store is still warming up after a start, and a + // prune keyed on "absent here" therefore deletes entries that are perfectly + // valid. That is not hypothetical: it wiped 257 of 258 entries on a live + // instance at 00:18 and every following start had to fold the whole history + // again — the "numbers climb for 30 s" symptom. A stale entry costs disk and + // nothing else (it is only read by id from a listing), so it is never worth + // deleting speculatively. +} + /** * Incremental scan of persisted sessions (slow cadence). Headers are read in * small concurrent batches; unchanged revisions are skipped; a failing session @@ -115,16 +228,41 @@ export async function readSessionEvents(persistence, id, fromSeq) { */ export async function scanPersisted(ctx, state, options) { const sp = ctx.get('sessionPersistence'); - if (sp === undefined || sp === null) return; - if (persistedInFlight) return; // previous pass still running: skip this tick + if (sp === undefined || sp === null) return false; + if (persistedInFlight) return false; // previous pass still running: skip this tick persistedInFlight = true; + const pass = beginPass(options); try { + const listedAt = Date.now(); const headers = await sp.list(); - if (!Array.isArray(headers)) return; + pass.listMs = Date.now() - listedAt; + if (!Array.isArray(headers)) return true; state.backfill.total = headers.length; for (let i = 0; i < headers.length; i += BATCH_SIZE) { const batch = headers.slice(i, i + BATCH_SIZE); - await Promise.all(batch.map((h) => processHeader(sp, state, h, options))); + await Promise.all(batch.map((h) => processHeader(sp, state, h, options, pass))); + } + finishPass(state, headers, pass); + // The counters must agree at the end, WHOEVER started the pass: a cadence + // pass runs while status is already 'done', and gating on status left + // `{status: done, total: 258, done: 0}` in the response — which reads as + // "nothing was scanned" while every record was in fact loaded. + state.backfill.done = headers.length; + const summary = reportPass(pass, { total: headers.length, hydrated: pass.hydrated, folded: pass.scanned.size }); + state.lastPass = summary; + // Also leave it on disk: the log line needs a logger that reaches the host + // log (this host does not always), while the file is readable without a + // browser. Best effort: a failed diagnostic must never fail the pass. + if (pass.dir !== null) { + try { + writeFileSync(join(dirname(pass.dir), 'last-pass.json'), JSON.stringify(summary, null, 2)); + } catch { + // diagnostics only + } + } + if (options && typeof options.log === 'function') { + safeLog(options.log, '[usage] scan pass: ' + summary.sessions + ' sessions, ' + summary.hydrated + + ' from cache, ' + summary.folded + ' folded, list ' + summary.listMs + 'ms, total ' + summary.totalMs + 'ms'); } } finally { persistedInFlight = false; @@ -139,7 +277,7 @@ export async function scanPersisted(ctx, state, options) { } /** Read + fold one persisted session header; bounded by the batch caller. */ -async function processHeader(sp, state, snap, options) { +async function processHeader(sp, state, snap, options, pass) { const id = snapshotId(snap); if (id === undefined) return; const backoff = backoffBySession.get(id); @@ -154,6 +292,19 @@ async function processHeader(sp, state, snap, options) { backoffBySession.delete(id); return; } + // Cache hit: same change token as the snapshot on disk, so the folded + // records are still exactly what a fresh fold would produce. This is the + // whole point of the cache — after a restart, an unchanged session costs one + // JSON read instead of a full log read + inflate + fold. + if (pass !== undefined && pass !== null && pass.dir !== null && revision !== undefined) { + const entry = readEntry(pass.dir, id); + if (entry !== null && entry.revision === revision && hydrateFromCache(state, id, entry, revision)) { + pass.hydrated += 1; + backoffBySession.delete(id); + return; + } + } + let wm = state.watermarks.get(id) ?? -1; // A crash-repaired or migrated log can shrink below our watermark; the // backend reports the current event count, so repair instead of stalling. @@ -166,6 +317,7 @@ async function processHeader(sp, state, snap, options) { const next = foldNew(state, id, events, wm); if (next > wm) state.watermarks.set(id, next); if (revision !== undefined) state.revisions.set(id, revision); + if (pass !== undefined && pass !== null) pass.scanned.add(id); backoffBySession.delete(id); // success resets the consecutive-failure chain } catch (error) { // A session may be gone or mid-rewrite: back off before trying again. @@ -188,7 +340,39 @@ async function processHeader(sp, state, snap, options) { } } -/** Full history scan, once at startup. */ +/** + * Start the full-history scan on demand, from the first query. + * + * Why not at activation: the pass is the only moment "缓存加载" is visible, and it + * takes ~0.8 s warm (12 s cold). Started from `apply` it finishes before anyone + * opens the panel, so the loading state can never be seen — the user restarted a + * dozen times and saw nothing. Started from the first query, the panel's own + * opening *is* the computation window, so the skeleton and the progress line are + * on screen for exactly as long as the work takes. + * + * Returns true when this call started it, false when it was already started. + */ +export function startBackfillOnDemand(ctx, state, options) { + if (state.backfill.started === true) return false; + // Flip the visible state first: the caller renders the response for this very + // request, and it must already say "running" or the client shows the empty + // dashboard for one frame. `runBackfill` flips `started` synchronously. + state.backfill.status = 'running'; + state.backfill.done = 0; + const log = options && typeof options.log === 'function' ? options.log : undefined; + // Fire-and-forget **with** a catch. An unhandled rejection here is fatal on this + // host, and `runBackfill`'s own try only begins after its `ctx.get(...)` probe, + // so a throwing context access would otherwise crash the process from a plain + // read request. Same failure class as the `logSink(...).warn` crash: background + // work must never be able to take the host down. + void runBackfill(ctx, state, options).catch((error) => { + state.backfill.status = 'done'; + if (log !== undefined) safeLog(log, '[usage] backfill rejected: ' + (error && error.message ? error.message : String(error))); + }); + return true; +} + +/** Full history scan, once per process (lazily, from `startBackfillOnDemand`). */ export async function runBackfill(ctx, state, options) { const sp = ctx.get('sessionPersistence'); if (sp === undefined || sp === null) { @@ -196,23 +380,54 @@ export async function runBackfill(ctx, state, options) { fireBackfillDone(options, state); return; } + // "只跑一次"由 `started` 记,不由 `status` 记:按需启动时调用方会先把 status + // 置成 running(好让发起查询的那一帧就带上加载态),用 status 当守卫会把真正 + // 要干的活挡在门外。 + if (state.backfill.started === true) return; + state.backfill.started = true; state.backfill.status = 'running'; state.backfill.done = 0; // Delegate progress while the pass runs; onDone is fired right here exactly // once so the pass callback does not double-report backfill completion. - const scanOptions = options && typeof options.onProgress === 'function' - ? { onProgress: options.onProgress } - : undefined; + const log = options && typeof options.log === 'function' ? options.log : undefined; + // Forward every scan option (cache / cacheDir included) instead of rebuilding + // the object: rebuilding dropped `cache: false` and quietly used the real cache + // directory in tests. `onDone` stays here — runBackfill fires it exactly once. + const { onDone: ignoredOnDone, ...scanOptions } = options || {}; + if (log !== undefined) scanOptions.log = log; try { - await scanPersisted(ctx, state, scanOptions); + let ran = await scanPersisted(ctx, state, scanOptions); + // A cadence pass may already be in flight when the plugin activates. That pass + // does the work, but "done" must not be announced before anything was read — + // that is how the response ended up saying `done: 0` while every record was + // loaded. + for (let attempt = 0; ran === false && attempt < 40; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 250)); + ran = await scanPersisted(ctx, state, scanOptions); + } state.backfill.status = 'done'; fireBackfillDone(options, state); } catch (error) { + // A failed pass must not wedge the plugin in `running` — but it must not + // vanish either: "the dashboard never filled in" is unanswerable if the + // reason was swallowed here. + // Logging is best-effort: a broken sink must cost one line, never the host. + // (This catch used to re-throw through a bad logger and took dsh down.) state.backfill.status = 'done'; + if (log !== undefined) safeLog(log, '[usage] backfill failed: ' + (error && error.message ? error.message : String(error))); fireBackfillDone(options, state); } } +/** Call a diagnostic sink without ever letting it throw into the work. */ +function safeLog(sink, message) { + try { + sink(message); + } catch { + // A logger that throws is a logging problem, not a scanning problem. + } +} + function fireBackfillDone(options, state) { if (options && typeof options.onDone === 'function') { options.onDone({ diff --git a/src/host/settings.js b/src/host/settings.js index 968f584..04f9983 100644 --- a/src/host/settings.js +++ b/src/host/settings.js @@ -1,26 +1,39 @@ /** - * Price table + cost computation. + * Currency + the user's own price table + the in-memory price store. * - * Persistence is the official plugin-config path: `index.js` exports a - * schemastery `Config`, so DSH materializes a form under the plugin entry's - * id (`usage`) in the Plugins settings tab and stores values in the profile - * patch. There is deliberately no private `settings.yaml` / settings-service - * registration here — DSH 0.2.x `ctx.settings` (SettingsForms) only offers - * `describe/update/replace/mutate/configure`, and hand-rolled namespaces are - * not reachable from the client config surface. + * There is exactly one pricing policy in this plugin, and it lives in + * pricing.js: the ladder. This file owns the two things around it that are not + * policy — * - * The store below stays the in-memory view the aggregation code reads. - */ -export const DEFAULT_CURRENCY = '¥'; -/** - * Currency the price table is denominated in. + * - the display currency, converted once at the query boundary; + * - the user's explicit price rows, which are an *override*, never a catalogue. * - * DEFAULT_PRICES carries DeepSeek's USD list price per 1M tokens (0.27 input / - * 1.10 output / 0.07 cache-read for `deepseek-chat`, which is exactly 2 / 8 / - * 0.5 CNY at the fixed 7:1). The table is never rewritten at runtime: a display - * switch converts on the way out, so a query can never pollute the next one. + * That last point is the whole reason this file lost its `DEFAULT_PRICES`: a + * hard-coded copy of a vendor price is a second source of truth that outranks + * the generated table (the ladder checks the user table first), so it silently + * wins even when it is stale. It was: one row carried a cached-input price 13.3× + * the official list, and seven of ten rows named ids the vendor had retired. A + * price the reference table can answer belongs in the generation script; a price + * only the user knows (an odd relay id) belongs here. + * + * Spelling: the config form persists `inputPerM`-style fields because that is + * what older profiles already contain, and renaming them would zero every saved + * price on the next save. `normalizePrices` / `configPrices` are the only two + * places that know this, and the store works in the canonical + * `{ input, output, cacheRead, cacheWrite }` shape that `ratesOf` reads. + * + * Persistence stays the official plugin-config path: `index.js` exports a + * schemastery `Config`, so DSH materializes a form under the plugin entry's id + * (`usage`) in the Plugins settings tab. There is deliberately no private + * `settings.yaml` / settings-service registration here — DSH 0.2.x + * `ctx.settings` (SettingsForms) only offers `describe/update/replace/mutate/ + * configure`, and hand-rolled namespaces are not reachable from the client + * config surface. */ -export const PRICE_UNIT_CURRENCY = 'USD'; +import { costOf, resolvePrice } from './pricing.js'; + +export const DEFAULT_CURRENCY = '¥'; + /** Display currencies; `perUsd` is how many units of it one USD buys. */ export const CURRENCIES = { USD: { code: 'USD', symbol: '$', perUsd: 1 }, @@ -49,86 +62,116 @@ export function currencyOf(value, fallback) { if (fb) return fb; return CURRENCIES[currencyCodeOf(DEFAULT_CURRENCY)] || CURRENCIES.CNY; } -export const DEFAULT_PRICES = { - 'deepseek-chat': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 }, - 'deepseek-v3': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 }, - 'deepseek-v3.1': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 }, - 'deepseek-v4': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 }, - 'deepseek-v4-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 }, - 'deepseek-v4.1-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 }, - 'deepseek-v4.1-flash-1017': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 }, - // DSH's routed default model id (`magpie` provider) aliases the same route; - // without this entry every session bills as "unpriced" on a stock install. - 'group/auto-deepseek-v4-1-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 }, - 'deepseek-reasoner': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 }, - 'deepseek-r1': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 }, -}; + +/** Rate field on the canonical row, and the name the config form persists. */ +const RATE_FIELDS = [ + ['input', 'inputPerM'], + ['output', 'outputPerM'], + ['cacheRead', 'cacheReadPerM'], + ['cacheWrite', 'cacheWritePerM'], +]; /** - * Shallow-merge configured prices over the defaults. + * Turn configured rows into the canonical rate shape the ladder reads. + * + * Accepts both spellings, drops entries that are not objects, and coerces every + * rate to a number (a form field saved as a string must not become NaN and then + * a zero cost). Unknown keys are dropped: a typo must not become a price. * - * @param {unknown} incoming - configured per-model prices. - * @returns {Record} complete price table. + * @param {unknown} incoming - rows from the plugin config or the `prices` RPC. + * @returns {Record} */ -export function mergePrices(incoming) { - const merged = Object.assign({}, DEFAULT_PRICES); - if (incoming && typeof incoming === 'object' && !Array.isArray(incoming)) { - for (const [model, price] of Object.entries(incoming)) { - if (price && typeof price === 'object') { - merged[model] = { - inputPerM: Number(price.inputPerM) || 0, - outputPerM: Number(price.outputPerM) || 0, - cacheReadPerM: Number(price.cacheReadPerM) || 0, - cacheWritePerM: Number(price.cacheWritePerM) || 0, - }; - } +export function normalizePrices(incoming) { + const out = {}; + if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return out; + for (const [key, row] of Object.entries(incoming)) { + if (!row || typeof row !== 'object') continue; + const rate = {}; + for (const [canonical, legacy] of RATE_FIELDS) { + const v = row[canonical] !== undefined ? row[canonical] : row[legacy]; + rate[canonical] = Number(v) || 0; } + out[key] = rate; } - return merged; + return out; } -export function createPriceStore(defaultPrices, defaultCurrency) { - const prices = Object.assign({}, defaultPrices); +/** + * The inverse of `normalizePrices`, for the config form. + * + * Only needed because the persisted schema still spells `inputPerM`; keeping the + * mapper here means the rest of the codebase never sees that spelling. + * + * @param {Record} prices - canonical rows. + * @returns {Record} rows the Config schema accepts. + */ +export function configPrices(prices) { + const out = {}; + for (const [key, row] of Object.entries(prices || {})) { + const rate = {}; + for (const [canonical, legacy] of RATE_FIELDS) rate[legacy] = Number(row[canonical]) || 0; + out[key] = rate; + } + return out; +} + +/** + * The in-memory pricing view the aggregation code reads. + * + * It owns no policy: every answer comes from `resolvePrice` / `costOf`, so the + * dashboard, the dialog and the settings form cannot disagree about what a model + * costs. + * + * @param {unknown} defaultPrices - configured price rows (any spelling). + * @param {string} [defaultCurrency] - display currency. + * @param {{ matches?: Record|(() => Record) }} [options] + * confirmed matches, or a getter for the live map (a commit replaces the map, + * so a store closing over a snapshot would price from the pre-commit state). + */ +export function createPriceStore(defaultPrices, defaultCurrency, options = {}) { + const prices = normalizePrices(defaultPrices); let currency = defaultCurrency || DEFAULT_CURRENCY; + const matchesOf = typeof options.matches === 'function' + ? options.matches + : () => (options.matches && typeof options.matches === 'object' ? options.matches : null); + const isRecord = (r) => r !== null && typeof r === 'object'; const store = { prices, get currency() { return currency; }, - /** Merge incoming per-model prices (each field coerced to a number). */ + /** Merge incoming rows over the current ones (both normalized). */ setPrices(incoming) { - if (!incoming || typeof incoming !== 'object') return; - for (const [model, p] of Object.entries(incoming)) { - if (p && typeof p === 'object') { - prices[model] = { - inputPerM: Number(p.inputPerM) || 0, - outputPerM: Number(p.outputPerM) || 0, - cacheReadPerM: Number(p.cacheReadPerM) || 0, - cacheWritePerM: Number(p.cacheWritePerM) || 0, - }; - } - } + Object.assign(prices, normalizePrices(incoming)); }, setCurrency(v) { if (typeof v === 'string' && v.length > 0) currency = v; }, - /** Look up a price by `provider/model` composite first, then bare model. */ + /** + * Resolve one record to a price. + * + * @param {{ provider?: string, model?: string }} r - a usage record. + * @returns {{ price: object|null, how: string, state: string, target: string|null, why: string }} + */ + lookup(r) { + if (!isRecord(r)) return { price: null, how: 'unmatched', state: 'none', target: null, why: '' }; + return resolvePrice({ + model: typeof r.model === 'string' ? r.model : '', + provider: typeof r.provider === 'string' ? r.provider : '', + custom: prices, + matches: matchesOf(), + }); + }, + /** The first hit of the ladder, keeping the old `undefined when absent` contract. */ priceOf(r) { - const composite = r.provider ? r.provider + '/' + r.model : ''; - return prices[composite] || prices[r.model]; + const hit = store.lookup(r); + return hit.price === null ? undefined : hit.price; }, - /** Whether an explicit price entry exists (composite first, then bare model). */ + /** Whether the ladder found a price at all. */ hasPrice(r) { - const composite = r.provider ? r.provider + '/' + r.model : ''; - return composite in prices || r.model in prices; + return store.lookup(r).price !== null; }, costOf(r) { - const p = store.priceOf(r); - if (!p) return 0; - return ( - r.inputTokens * p.inputPerM + - r.outputTokens * p.outputPerM + - r.cacheReadTokens * (p.cacheReadPerM || 0) + - r.cacheWriteTokens * (p.cacheWritePerM || 0) - ) / 1e6; + const hit = store.lookup(r); + return hit.price === null ? 0 : costOf(r, hit.price); }, snapshot() { return { prices: Object.assign({}, prices), currency }; diff --git a/src/host/state.js b/src/host/state.js index b7f6916..7e0fb2f 100644 --- a/src/host/state.js +++ b/src/host/state.js @@ -2,7 +2,9 @@ * Mutable runtime state capsule for the usage host half. * * - records: folded usage records, one per billed assistant settlement - * (`assistant/message` or `assistant/attempt`) that reported usage. + * (`assistant/message` or `assistant/attempt`) that reported usage. A record + * may also carry `tokens: { peak?, off? }` — the same usage split by the price + * tier its own timestamp fell into (see fold.js). * - watermarks: sessionId -> last folded event seq. * - revisions: sessionId -> last persisted revision folded (opaque equality * token from `sessionPersistence.list()`); lets the slow scan skip sessions @@ -17,16 +19,30 @@ * (`assistant/attempt` never does). * - index: incremental aggregation index (aggregate.js), kept in sync by * fold.js and consumed by query.js. + * - peakOf: `(atMs) => boolean` price-tier test, injected by apply(). Absent + * means "record flat token fields only", which is what the tests rely on. + * - matchMap: model key -> reference model id, the user's confirmed matches. + * A key that is present overrides the automatic ladder in pricing.js. */ export function createState(priceStore) { return { records: [], watermarks: new Map(), revisions: new Map(), - backfill: { status: 'idle', total: 0, done: 0 }, + // 启动回填是**按需**触发的:`idle` 一直保持到第一次查询(面板真正要被看)。 + // 这样"缓存加载"这件事发生在用户眼前,而不是在进程启动的头一秒里悄悄做完。 + // `started` 与 `status` 分开:调用方会先把 status 置 running 好让这一帧就带上 + // 加载态,所以不能用 status 当"只跑一次"的守卫。 + backfill: { status: 'idle', total: 0, done: 0, started: false }, + // Last scan pass summary: how long listing took, how much came from the fold + // cache, how long the whole pass took. Surfaced in the dashboard response — + // the plugin logger does not reliably reach the host log file on this host. + lastPass: null, prices: priceStore, lastSample: new Map(), route: new Map(), index: null, // wired by apply() after createUsageIndex(state) + peakOf: null, // wired by apply() once the holiday calendar is known + matchMap: {}, }; } diff --git a/src/host/typert.js b/src/host/typert.js new file mode 100644 index 0000000..39b79cf --- /dev/null +++ b/src/host/typert.js @@ -0,0 +1,219 @@ +/** + * The native client→host channel: a typert Remote contribution. + * + * Why this file exists (xshared-lib experience ⓪, "原生优先"): the plugin used to + * invent its own envelope on top of `connection.fetch`. That registry is native, + * but the typed Remote channel is the one the rest of the harness speaks, and a + * third party can join it without compiling anything — `ctx.typert.register()` + * is a runtime API ("any other owner calls ctx.typert.register(contribution) + * directly", @deepseek-ai/dsh-typert-registry README). + * + * What the harness demands of a contribution (read from the shipped distribution, + * @deepseek-ai/dsh-typert-loader `validateTypertManifest` / `requireInvocation` / + * `requireStrictCodec`, and @deepseek-ai/dsh-typert-registry `register`): + * + * · `package` names the owner; the manifest must be owned by the package that + * declares it; + * · every invocation carries `id/service/namespace/method`; + * · `namespace` MUST be the package name: the client-side service key is + * `remote.` (@deepseek-ai/dsh-api-gateway client `remoteServiceKey`), + * so an endpoint name here means a service nobody can reach; + * · every parameter is `source: 'json'` or `'lookup'`, and each parameter and + * the result must be a STRICT codec (`mode: 'strict'`, non-empty + * `typeSymbol`, a `create()` factory; give `encode` on the result or the + * gateway skips its own result handling); + * · the gateway resolves `descriptor.service` on the receiver context and + * cross-checks the `typertRemote` binding, so the plugin must ALSO provide a + * service of that name whose binding says `service` (itself), `serviceKey` + * and `namespace` (both the package name). + * + * Everything here is feature-detected: a composition without `typert` (or + * without a reflect `provide`) keeps using the Fetch routes in rpc.js. A + * missing capability must never throw during activation. + */ + +/** The service key, the wire namespace and the package name — one string. */ +export const USAGE_SERVICE = 'dsh-plugin-usage'; + +/** Wire names the registry accepts for namespace/method (`validateWireName`). */ +const WIRE_NAME = /^[A-Za-z0-9_$.-]+$/; + +/** A schema projection: the gateway only calls `parse` on inputs. */ +const ANY_SCHEMA = Object.freeze({ + parse(value) { + return value; + }, +}); + +const argsCodec = (endpoint) => ({ + mode: 'strict', + typeSymbol: `${USAGE_SERVICE}/args/${endpoint}`, + create: () => ANY_SCHEMA, +}); + +const resultCodec = (endpoint) => ({ + mode: 'strict', + typeSymbol: `${USAGE_SERVICE}/result/${endpoint}`, + create: () => ANY_SCHEMA, + encode: (value) => value, +}); + +/** + * One InvocationDescriptor for an endpoint. + * + * One positional `args` field, because the gateway calls Remote methods with + * positional arguments: the client passes exactly one JSON object. + * + * @param {string} endpoint - endpoint name such as `query`. + * @returns {object} invocation descriptor. + */ +export function buildInvocation(endpoint) { + return { + id: `${USAGE_SERVICE}#${USAGE_SERVICE}/${endpoint}`, + service: USAGE_SERVICE, + namespace: USAGE_SERVICE, + method: endpoint, + invocation: { kind: 'direct' }, + parameters: [{ name: 'args', wire: 'args', source: 'json', codec: argsCodec(endpoint) }], + result: resultCodec(endpoint), + sourceLocation: { file: 'src/host/typert.js', line: 1, column: 1 }, + }; +} + +/** + * The contribution handed to `ctx.typert.register()`. + * + * @param {string[]} endpoints - endpoint names to expose. + * @returns {object} typert contribution. + */ +export function typertContribution(endpoints) { + if (!Array.isArray(endpoints) || endpoints.length === 0) { + throw new Error('typert contribution needs at least one endpoint'); + } + for (const endpoint of endpoints) { + if (typeof endpoint !== 'string' || !WIRE_NAME.test(endpoint)) { + throw new Error('endpoint name is not wire-safe: ' + String(endpoint)); + } + } + return { + package: USAGE_SERVICE, + face: 'host', + schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: endpoints.map(buildInvocation), + }; +} + +/** + * The receiver service: the gateway resolves `descriptor.service` on it and + * checks the binding before calling a method. + * + * Methods are keyed by the endpoint name verbatim (hyphens are fine as keys); + * the browser half also tries the camelCase spelling, so both work. + * + * @param {Map any>} handlers - endpoint handlers. + * @returns {object} service instance with a frozen `typertRemote` binding. + */ +export function createReceiver(handlers) { + const receiver = {}; + for (const [endpoint, fn] of handlers) { + receiver[endpoint] = (args) => fn(args === undefined || args === null ? {} : args); + } + receiver.typertRemote = Object.freeze({ + service: receiver, + serviceKey: USAGE_SERVICE, + namespace: USAGE_SERVICE, + }); + return receiver; +} + +/** Read a service without tripping the strict ctx proxy. */ +function serviceOf(ctx, name) { + if (!ctx) return null; + try { + if (typeof ctx.get === 'function') { + const found = ctx.get(name); + if (found) return found; + } + } catch { + /* not injected */ + } + try { + return ctx[name] ?? null; + } catch { + return null; + } +} + +/** + * The service-providing entry point. + * + * `ctx.provide(name, value, check)` is Context's own API (cordis `Context.provide`, + * "register a service implementation owned by the current fiber", returns a + * disposer). `ctx.reflect.provide` is the same store reached through the reflect + * facade, kept as a fallback for compositions that hide the direct method. + */ +function providerOf(ctx) { + if (!ctx) return null; + try { + if (typeof ctx.provide === 'function') return (name, value) => ctx.provide(name, value); + } catch { + /* not exposed */ + } + try { + const reflect = ctx.reflect; + if (reflect && typeof reflect.provide === 'function') return (name, value) => reflect.provide(name, value); + } catch { + /* not exposed */ + } + return serviceOf(ctx, 'reflect'); +} + +/** + * Join the native Remote channel, when this composition has one. + * + * Returns a small record rather than a boolean: the plugin reports which channel + * it ended up on, and WHY when it could not take the native one. That record + * travels to the browser with the dashboard response (`channel`), because a + * plugin logger is not always wired into the harness log. + * + * @param {any} ctx - host plugin context. + * @param {Map any>} handlers - endpoint handlers. + * @param {(message: string, level?: string) => void} [log] - log sink. + * @returns {{ ok: boolean, transport: 'remote' | 'fetch', reason: string }} decision. + */ +export function registerTypert(ctx, handlers, log) { + const note = (message, level = 'warn') => { if (typeof log === 'function') log(message, level); }; + const failed = (reason) => { + note('[usage] client→host channel: Fetch (' + reason + ')'); + return { ok: false, transport: 'fetch', reason }; + }; + const typert = serviceOf(ctx, 'typert'); + if (typert === null || typeof typert.register !== 'function') { + return failed('no typert registry in this composition'); + } + + const provider = providerOf(ctx); + if (provider === null || typeof provider !== 'function') { + return failed('this ctx cannot provide a service (ctx.provide missing)'); + } + + let dispose = null; + try { + dispose = provider(USAGE_SERVICE, createReceiver(handlers)); + } catch (error) { + return failed('providing the receiver failed: ' + String((error && error.message) || error)); + } + try { + typert.register(typertContribution([...handlers.keys()])); + note('[usage] client→host channel: native typert Remote (' + handlers.size + ' endpoints)', 'info'); + return { ok: true, transport: 'remote', reason: 'registered ' + handlers.size + ' endpoints' }; + } catch (error) { + // Registration is atomic: a rejected contribution means nothing is reachable, + // so take the receiver back out and let the Fetch routes carry the plugin. + if (typeof dispose === 'function') { + try { dispose(); } catch { /* already gone */ } + } + return failed('typert rejected the contribution: ' + String((error && error.message) || error)); + } +} diff --git a/tests/client-slots.test.js b/tests/client-slots.test.js new file mode 100644 index 0000000..3abf919 --- /dev/null +++ b/tests/client-slots.test.js @@ -0,0 +1,132 @@ +/** + * Wiring gates (source-level, not behavioural): they assert that two places stay + * connected to the same single source, because the failure mode is "both sides + * look right on their own". + * + * The summary-card slot table is the single source of "how many cards". + * + * The loading skeleton used to hard-code five placeholders while the real card + * row had six (「模型占比」 was added later), so the skeleton was one card short + * and the row re-flowed the moment data arrived. Pinning both sides to the same + * key list is what makes that impossible to repeat — and asserting it here is + * what catches an edit that only touches one side. + */ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const root = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1') +const src = readFileSync(join(root, 'src/client/index.js'), 'utf8').replace(/\r\n/g, '\n') + +test('the skeleton renders one placeholder per real summary card', () => { + const slots = /const SUMMARY_CARD_SLOTS = \[([^\]]+)\]/.exec(src) + assert.ok(slots, 'SUMMARY_CARD_SLOTS must exist — it is how the skeleton knows the card count') + const slotKeys = [...slots[1].matchAll(/'([a-zA-Z]+)'/g)].map((m) => m[1]) + + // The real card list: the `cards` array inside SummaryCards, one `key: 'x'` each. + const body = src.slice(src.indexOf('function SummaryCards'), src.indexOf('function SummaryCards') + 4000) + const cardsBlock = /const cards = \[([\s\S]*?)\n \]/.exec(body) + assert.ok(cardsBlock, 'SummaryCards must build its cards from a `cards` array') + const cardKeys = [...cardsBlock[1].matchAll(/key: '([a-zA-Z]+)'/g)].map((m) => m[1]) + + assert.deepEqual(slotKeys, cardKeys, + 'SUMMARY_CARD_SLOTS must list exactly the cards SummaryCards renders, in the same order'); + assert.equal(slotKeys.length, 6, 'the dashboard shows six summary cards today'); + + // And the skeleton must derive from it rather than count by hand: an index + // range like `[0, 1, 2, 3, 4]` is exactly the shape that drifted. + const from = src.indexOf('function LoadingState') + assert.ok(from >= 0, 'LoadingState must still exist') + const skeleton = src.slice(from, from + 1600) + assert.ok(/SUMMARY_CARD_SLOTS\.map/.test(skeleton), + 'the skeleton must map SUMMARY_CARD_SLOTS instead of hard-coding a count') + assert.ok(!/\[0,\s*1,\s*2,\s*3,\s*4\]\.map/.test(skeleton), + 'a hard-coded index range is the bug this gate exists for') +}); + +test('an unfinished computation is a loading state, never partial data', () => { + // The cold-start case: DSH has just booted and the host is still folding history. + // Two failures this pins down: + // - 「暂无数据」 there says "you have no usage" while the numbers are being computed; + // - rendering the partial rows says "this is your total" and then climbs — the + // "numbers creep up for 30 seconds" complaint. + // So: from boot until the host reports done, the panel is a loading state. + const dash = src.slice(src.indexOf('const busy = status ==='), src.indexOf('const busy = status ===') + 900) + assert.ok(/const computing = Boolean\(backfill && backfill\.status === 'running'\)/.test(dash), + 'the dashboard must derive `computing` from the host backfill status') + + const renderStart = src.indexOf('(!ready || computing) && !isError') + assert.ok(renderStart > 0, 'the skeleton must cover the whole computation, not just the empty case') + const render = src.slice(renderStart, renderStart + 1400) + assert.ok(/isEmpty && !computing/.test(render), + 'the empty state must not render while the computation is unfinished') + assert.ok(/!isEmpty && !computing/.test(render), + 'the data block must not render while the computation is unfinished (no climbing half-answers)') + + // A determinate bar while we know the denominator; the sweep only when we do not. + assert.ok(/data-determinate/.test(src), 'the progress bar must support a determinate mode') + assert.ok(/computeDone/.test(src) && /computeTotal/.test(src), + 'the determinate width must come from the host done/total counts') +}); + +test('every chart hover card shows the reference cost from the same row data', () => { + // Three tips exist — trend bar, trend line, yearly heatmap — and they used to + // repeat their markup three times, so a field added to one was missing from the + // others. They now share TipRows; this pins that, and pins that the cost comes + // from the row's own `cost` (host-computed, already in the display currency) + // rather than being recomputed from tokens on the client. + const rows = /function TipRows\(props\) \{([\s\S]*?)\n\}/.exec(src) + assert.ok(rows, 'TipRows must exist — it is the shared tip content') + assert.ok(/t\('cost'\)/.test(rows[1]), 'the shared tip must label the cost row') + assert.ok(/formatCost\(row\.cost \|\| 0, currency\)/.test(rows[1]), + 'the tip cost must be the host bucket cost, not a second calculation') + + // All three sites go through it. + const sites = src.match(/h\(TipRows, \{[^}]*\}\)/g) || [] + assert.equal(sites.length, 3, `expected three TipRows call sites, found ${sites.length}`) + assert.ok(!/tsd-tooltip-date[\s\S]{0,600}?formatInt\(totOf\(tip\.row\)\)/.test(src), + 'the trend tips must not keep their own inline rows') + + // The currency reaches both cards from the dashboard (one symbol for the page). + assert.ok(/h\(DailyChart, \{[^}]*currency: currencySymbol/s.test(src), + 'DailyChart must receive the display currency') + assert.ok(/h\(HeatmapCard, \{[^}]*currency: currencySymbol/s.test(src), + 'HeatmapCard must receive the display currency') + + // Screen readers get the same information: the aria label and the native title + // carry the cost too, or the card says more than the label does. + for (const key of ['barAria', 'hmTitle']) { + const zh = new RegExp(`${key}: '[^']*\{cost\}`).test(src) + const en = (src.match(new RegExp(`${key}: '[^']*\{cost\}`, 'g')) || []).length >= 2 + assert.ok(zh && en, `${key} must mention {cost} in both locales`) + } +}); + +test('only rows that correspond to a legend item carry a colour swatch', () => { + // The dot answers "which colour in the legend is this row". 输入/输出 are in the + // chart legend and 总 Token is the cell colour, so they get one; 参考成本 and + // 调用次数 have no legend entry, so they get a blank 10px spacer for alignment + // and no colour. Painting them was the bug this pins. + const rows = /function TipRows\(props\) \{([\s\S]*?)\n\}/.exec(src) + assert.ok(rows, 'TipRows must exist') + const body = rows[1] + + const costRow = /key: 'cost'([\s\S]*?)\)\),/.exec(body) + assert.ok(costRow, 'the cost row must exist') + assert.ok(!/tsd-legend-cost|background/.test(costRow[1]), + 'the cost row must not paint a swatch') + + const callsRow = /key: 'calls'([\s\S]*?)\)\,/.exec(body) + assert.ok(callsRow, 'the calls row must exist') + assert.ok(!/background/.test(callsRow[1]), 'the calls row must not paint a swatch') + + // The spacer class itself has no background: that is what keeps alignment + // without a colour, so it must stay colourless in the stylesheet. + const dotCss = /\.tsd-tooltip-dot \{([\s\S]*?)\n\}/.exec(src) + assert.ok(dotCss, '.tsd-tooltip-dot must exist') + assert.ok(!/background/.test(dotCss[1]), '.tsd-tooltip-dot must not set a background') + + // And nothing references the removed colour class any more. + assert.ok(!/tsd-legend-cost/.test(src), 'tsd-legend-cost was only used by the removed swatch') +}); diff --git a/tests/config.test.js b/tests/config.test.js index 136ad4d..1dde935 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -1,36 +1,106 @@ /** - * Config contract: the price table is the plugin's durable configuration. + * Config contract: overrides + currency + confirmed matches, nothing else. * * DSH 0.2.x materializes this schemastery schema as the plugin entry's form * (Plugins settings tab -> profile patch). The old hand-rolled * `settings.register('usage.prices', ...)` path does not exist in 0.2.x. + * + * The invariant this file now pins: the config's price table starts EMPTY. Every + * number the reference table can answer must come from the table, because a + * config row outranks it — the shipped defaults were a stale copy that silently + * won (one row billed cached input at 13.3× the official list). */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { Config } from '../src/host/index.js'; -import { DEFAULT_CURRENCY, DEFAULT_PRICES, mergePrices } from '../src/host/settings.js'; +import { Config, resolveConfig } from '../src/host/index.js'; +import { resolvePrice } from '../src/host/pricing.js'; +import { DEFAULT_CURRENCY } from '../src/host/settings.js'; -test('Config resolves the default currency and price table', () => { - const resolved = Config({}); +/** + * Every field the plugin writes back must be `.volatile()`. + * + * The official settings service refuses anything else — `dsh-settings/lib/index.js` + * `write()`: `volatileForm(schema)` is `undefined` when no field is volatile and + * it throws `Plugin entry "usage" has no volatile fields`, then `validatePaths` + * rejects any non-volatile key in the patch. Without the markers every + * `settings.update('usage', …)` threw, and the swallowed rejection is exactly why + * confirmed matches and custom prices disappeared on restart. + */ +test('every field the plugin persists is volatile', () => { + const written = ['currency', 'prices', 'matchMap']; + for (const key of written) { + assert.equal(typeof Config.dict[key], 'function', `${key} must be a schema field`); + assert.equal(Config.dict[key].meta.volatile, true, `${key} must be declared .volatile()`); + } +}); + +test('resolveConfig unwraps volatile fields back to plain values', () => { + // A volatile field is a wrapper object with `get()`; the plugin must read the + // value, not the wrapper (a wrapper in `prices` would price nothing). + const resolved = resolveConfig({}); assert.equal(resolved.currency, DEFAULT_CURRENCY); - assert.deepEqual(resolved.prices['deepseek-chat'], DEFAULT_PRICES['deepseek-chat']); - assert.ok(Object.prototype.hasOwnProperty.call(resolved.prices, 'deepseek-v4.1-flash-1017')); + assert.deepEqual(resolved.prices, {}); + assert.deepEqual(resolved.matchMap, {}); }); -test('a user price entry overrides the table; apply() re-merges the defaults', () => { - const resolved = Config({ +test('resolveConfig accepts the loader shape, where volatile values arrive wrapped', () => { + // What the settings line hands the plugin is `Config(...)`'s own output — i.e. + // wrappers. Validating that a second time used to throw + // `ValidationError: $.currency expected string but got [object Object]`, which + // killed activation outright: no Remote face, no Fetch routes, every call 404. + const fromLoader = Config({ currency: '$', - prices: { - 'deepseek-v4.1-flash-1017': { inputPerM: 1, outputPerM: 2, cacheReadPerM: 3, cacheWritePerM: 4 }, - }, + prices: { m: { inputPerM: 3, outputPerM: 4, cacheReadPerM: 0, cacheWritePerM: 0 } }, + matchMap: { m: 'deepseek-flash' }, }); + const resolved = resolveConfig(fromLoader); assert.equal(resolved.currency, '$'); - assert.deepEqual(resolved.prices['deepseek-v4.1-flash-1017'], { - inputPerM: 1, outputPerM: 2, cacheReadPerM: 3, cacheWritePerM: 4, + assert.deepEqual(resolved.prices, { m: { inputPerM: 3, outputPerM: 4, cacheReadPerM: 0, cacheWritePerM: 0 } }); + assert.deepEqual(resolved.matchMap, { m: 'deepseek-flash' }); +}); + +test('every config field explains itself in both languages', () => { // The settings form renders `meta.description` through resolveText(), so a + // locale map is what an official plugin ships. Without it the form shows bare + // key names and nobody learns that `prices` is USD-denominated. + for (const key of ['currency', 'prices', 'matchMap']) { + const description = Config.dict[key].meta.description; + assert.equal(typeof description, 'object', `${key} needs a locale map`); + assert.equal(typeof description.zh, 'string', `${key} needs zh`); + assert.equal(typeof description.en, 'string', `${key} needs en`); + } +}); + +test('Config ships an empty override table, and the ladder prices the defaults', () => { + const resolved = resolveConfig({}); + assert.equal(resolved.currency, DEFAULT_CURRENCY); + assert.deepEqual(resolved.prices, {}); + assert.deepEqual(resolved.matchMap, {}); + // The ids the old table shadowed are priced by the ladder now. (`-1017` is not + // in this list: no table publishes it any more, so it is honestly unmatched — + // the old defaults were the only thing that ever claimed to know its price.) + for (const id of ['deepseek-chat', 'deepseek-v4.1-flash', 'deepseek-reasoner', 'deepseek-flash']) { + assert.notEqual(resolvePrice({ model: id }).how, 'unmatched', `${id} must stay priced without a config row`); + } + // DSH's router id resolves through normalization, so it needs no row either. + assert.equal(resolvePrice({ provider: 'magpie', model: 'group/auto-deepseek-v4-1-flash' }).how, 'normalized'); +}); + +test('a configured row is kept verbatim, in the spelling the form persists', () => { + const resolved = resolveConfig({ + currency: '$', + prices: { 'relay/odd': { inputPerM: 1, outputPerM: 2, cacheReadPerM: 3, cacheWritePerM: 4 } }, }); - // schemastery dicts replace rather than deep-merge, so apply() layers the - // configured table over DEFAULT_PRICES: untouched models keep their defaults. - const merged = mergePrices(resolved.prices); - assert.deepEqual(merged['deepseek-chat'], DEFAULT_PRICES['deepseek-chat']); + assert.equal(resolved.currency, '$'); + assert.deepEqual(resolved.prices['relay/odd'], { inputPerM: 1, outputPerM: 2, cacheReadPerM: 3, cacheWritePerM: 4 }); +}); + +test('a configured row for an id the ladder can price would shadow it', () => { + // Not a bug to fix here — it is the documented precedence — but the reason the + // shipped default table must stay empty. Pinned so the precedence cannot drift + // silently: a user row answers first, and says so. + const resolved = resolveConfig({ prices: { 'deepseek-flash': { inputPerM: 99, outputPerM: 0, cacheReadPerM: 0, cacheWritePerM: 0 } } }); + const hit = resolvePrice({ model: 'deepseek-flash', custom: resolved.prices }); + assert.equal(hit.how, 'custom'); + assert.equal(hit.state, 'custom'); }); diff --git a/tests/dialog-draft.test.js b/tests/dialog-draft.test.js new file mode 100644 index 0000000..697ff2c --- /dev/null +++ b/tests/dialog-draft.test.js @@ -0,0 +1,69 @@ +/** + * Regression guard for the dialog's draft helpers. + * + * `setDraft` used to be the pure merge helper AND, inside `PricingDialog`, the + * `useState` setter — so `setDraft((prev) => setDraft(prev, ...))` called the + * setter twice and merged `undefined` into the draft: choosing a reference + * model for an unpriced row threw `Cannot convert undefined or null to object` + * and the row never became dirty. The functional check that catches it lives + * outside this suite (it drives the real component); this one pins the merge + * contract so the helper keeps its name and its shape. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const SOURCE = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8'); + +/** Extract a top-level `function name(...) { ... }` body by brace matching. */ +function extractFunction(name) { + const start = SOURCE.indexOf('function ' + name + '('); + assert.notEqual(start, -1, 'missing function ' + name); + let depth = 0; + let opened = false; + for (let i = start; i < SOURCE.length; i += 1) { + const ch = SOURCE[i]; + if (ch === '{') { depth += 1; opened = true; } + else if (ch === '}') { + depth -= 1; + if (opened && depth === 0) return SOURCE.slice(start, i + 1); + } + } + throw new Error('unterminated function ' + name); +} + +const mergeDraft = new Function(extractFunction('mergeDraft').replace('function mergeDraft(', 'return function mergeDraft('))(); + +/** Drop line/block comments, so prose about a bug is never read as the bug. */ +function stripComments(source) { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); +} + +test('mergeDraft marks the row dirty and keeps the target', () => { + const next = mergeDraft({}, 'relay/b', { target: 'glm-5.3', cleared: false, key: 'relay/b' }); + assert.deepEqual(next['relay/b'], { target: 'glm-5.3', cleared: false, dirty: true, key: 'relay/b' }); +}); + +test('mergeDraft records a clear without dropping the key', () => { + const prev = { 'relay/a': { target: 'glm-5.3', cleared: false, dirty: true, key: 'relay/a' } }; + const next = mergeDraft(prev, 'relay/a', { target: null, cleared: true, key: 'relay/a' }); + assert.deepEqual(next['relay/a'], { target: null, cleared: true, dirty: true, key: 'relay/a' }); +}); + +test('mergeDraft never mutates its input', () => { + const prev = {}; + mergeDraft(prev, 'relay/b', { target: 'x', cleared: false, key: 'relay/b' }); + assert.deepEqual(prev, {}); +}); + +test('the dialog does not shadow the merge helper with a state setter', () => { + const dialog = stripComments(extractFunction('PricingDialog')); + assert.match(dialog, /const \[draft, setDraft\] = React\.useState\(\{\}\)/, 'the draft state keeps its setter name'); + assert.doesNotMatch(dialog, /setDraft\(\(prev\) => setDraft\(prev,/, 'the draft setter must not call itself'); + assert.match(dialog, /mergeDraft\(prev, key,/, 'the setter merges through the pure helper'); + // The comment stripping above is what lets this test read code only; if it + // ever stops working, the `doesNotMatch` above starts failing for the wrong + // reason (the explanatory comment quotes the broken call verbatim). + const raw = extractFunction('PricingDialog'); + assert.match(raw, /setDraft\(\(prev\) => setDraft\(prev,/, 'the comment explaining the bug is still present'); +}); diff --git a/tests/dialog.test.cjs b/tests/dialog.test.cjs new file mode 100644 index 0000000..bd1539c --- /dev/null +++ b/tests/dialog.test.cjs @@ -0,0 +1,644 @@ +/** + * The price-match dialog, driven for real. + * + * `_smoke-client.cjs` checks the pure helpers on extracted source. This file + * goes further: it loads the BUILT bundle, takes the real `PricingDialog` out + * of it, renders it with a hand-rolled React stand-in and a fake host, and + * checks the promises the dialog makes against the code that actually ships: + * + * 1. opening reads (query + propose) and never writes; + * 2. Apply with nothing touched closes without writing; + * 3. choosing a model sends exactly that row, then closes; + * 4. clearing a row sends it as `cleared`; + * 5. the four states are labelled and counted; + * 6. Escape closes, and the picker swallows the first Escape; + * 7. history starts collapsed. + * + * Runs as an ordinary `node --test` case, so `npm test` (and CI) covers the + * shipped bundle instead of only the extracted helpers. It used to live outside + * the repo, where nothing could run it. + */ +const fs = require('node:fs') +const vm = require('node:vm') +const path = require('node:path') +const test = require('node:test') +const assert = require('node:assert') + +// The BUILT bundle: `npm test` builds first (`pretest`), so this is the code that +// actually ships. Sales pitch not required — the point is that a stale build can +// no longer pass silently. +const bundlePath = path.join(__dirname, '..', 'lib', 'client', 'index.js') +if (!fs.existsSync(bundlePath)) { + throw new Error('lib/client/index.js is missing — run `npm run build` first (`npm test` does)') +} +const source = fs.readFileSync(bundlePath, 'utf8') + +let failed = 0 +const checks = [] +const ok = (name, cond, detail) => { + checks.push({ name, cond }) + if (!cond) failed += 1 + console.log((cond ? ' ok ' : ' FAIL ') + name + (cond || detail === undefined ? '' : ' [' + detail + ']')) +} + +/* ---------- load the built bundle ---------- */ + +let captured = null +const requireShim = () => require('node:module').createRequire(bundlePath)(...arguments) +requireShim.resolve = (id) => id + +// The bundle is an IIFE that registers itself on window.__ModuleLoader__. +const sandbox = { + console, + window: { __ModuleLoader__: { load(entry) { captured = entry } } }, + document: { createElement: () => ({ style: {} }), head: { appendChild() {} }, getElementById: () => null, querySelector: () => null }, + localStorage: { getItem: () => null, setItem() {} }, + React: null, +} + +/* ---------- the React stand-in ---------- */ + +const DEBUG_HOOKS = process.env.DEBUG_HOOKS === '1' + +function makeReact() { + // Hook storage is keyed by component instance, so opening a second dialog + // starts from clean state exactly as React would on a fresh mount. + const store = new Map() + let cursor = 0 + let currentKey = 'root' + let pending = false + let rerender = null + + const hooks = () => { + let slots = store.get(currentKey) + if (slots === undefined) { slots = []; store.set(currentKey, slots) } + return slots + } + + const flush = () => { + if (pending) return + pending = true + Promise.resolve().then(() => { pending = false; if (rerender) rerender() }) + } + + const React = { + Fragment: Symbol('Fragment'), + createElement: (type, props, ...children) => ({ + type, + props: props || {}, + children: children.length === 1 ? children[0] : children, + }), + createContext(defaultValue) { + const ctx = { _default: defaultValue, Provider: Symbol('Provider') } + return ctx + }, + useState(initial) { + const slots = hooks() + const i = cursor + cursor += 1 + // `initial` is only consulted on the first render of an instance: a + // remount must start blank, so re-calling it (React does not) would be + // wrong. The stored value is the state; the argument is ignored after it. + const init = initial + if (slots[i] === undefined) slots[i] = { value: typeof init === 'function' ? init() : init } + const slot = slots[i] + if (DEBUG_HOOKS) console.log('HOOK', currentKey, i, JSON.stringify(slot.value), 'init=' + JSON.stringify(init)) + return [slot.value, (next) => { + const v = typeof next === 'function' ? next(slot.value) : next + if (DEBUG_HOOKS) console.log('SET key=' + currentKey + ' slot=' + i + ' from=' + JSON.stringify(slot.value) + ' to=' + JSON.stringify(v) + ' at ' + ((new Error().stack || '').split('\n')[2] || '').trim()) + if (Object.is(v, slot.value)) return + slot.value = v + flush() + }] + }, + useRef(initial) { const slots = hooks(); const i = cursor; cursor += 1; if (slots[i] === undefined) slots[i] = { current: initial }; return slots[i] }, + useMemo(factory) { return factory() }, + useCallback(fn) { return fn }, + useEffect(fn) { + const slots = hooks() + const i = cursor + cursor += 1 + if (slots[i] === undefined) { slots[i] = true; Promise.resolve().then(fn) } + }, + } + + React.__begin = (fn, key) => { cursor = 0; currentKey = key || 'root'; return fn() } + // A draw keeps whatever key the test selected last; __begin also sets it. + React.__draw = (fn, key) => { cursor = 0; if (key) currentKey = key; if (DEBUG_HOOKS) console.log('DRAW key=' + currentKey + ' from ' + (new Error().stack || '').split('\n')[2].trim()); return fn() } + React.__keyNow = () => currentKey + React.__onRerender = (fn) => { rerender = fn } + React.__reset = (key) => { store.delete(key) } + React.__setKey = (key) => { currentKey = key } + return React +} + +const React = makeReact() +sandbox.React = React +vm.createContext(sandbox) +vm.runInContext(source, sandbox) + +ok('bundle registered itself with the module loader', captured !== null) +if (captured === null) throw new Error('the bundle did not register with __ModuleLoader__ — rebuild it (`npm run build`)') + +const mod = captured.factory((id) => { + if (id === 'react') return React + throw new Error('unexpected require: ' + id) +}) +ok('bundle exposes inject + apply', typeof mod.inject === 'object' && typeof mod.apply === 'function', + Object.keys(mod).join(',')) + +/* ---------- a fake host ---------- */ + +const propose = { + rows: [ + // The wire shape the host sends now: `state` is the verdict, `rates` both + // tiers, `tier` the one this row prints. Nothing here needs interpreting. + { key: 'relay/a', provider: 'relay', model: 'a', state: 'exact', how: 'normalized', target: 'glm-5.3', why: '与价目表 id 字面一致', source: 'zen', tier: 'peak', rates: { peak: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, offPeak: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 } } }, + { key: 'relay/b', provider: 'relay', model: 'b', state: 'none', how: 'unmatched', target: null, why: '价目表里没有可对应的模型', source: null, tier: 'peak', rates: null }, + { key: 'relay/c', provider: 'relay', model: 'c', state: 'fuzzy', how: 'alias', target: 'deepseek-v4.1-flash', why: '官方停用', source: 'deepseek', tier: 'peak', rates: { peak: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, offPeak: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 } } }, + // A row the user confirmed earlier: it is the only kind that carries a reset, + // because an auto-lit match has nothing to restore. + { key: 'relay/d', provider: 'relay', model: 'd', state: 'custom', how: 'confirmed', target: 'deepseek-v4.1-flash', why: '用户确认的匹配', source: 'deepseek', tier: 'peak', rates: { peak: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, offPeak: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 } } }, + ], + // Two groups of reference ids; 自定义 is an option in the picker, not a group. + table: { + groups: [ + { source: 'deepseek', ids: ['deepseek-v4.1-flash'] }, + { source: 'zen', ids: ['glm-5.3'] }, + ], + source: 'test-table', + capturedAt: '2026-10-19', + }, + matchMap: { 'relay/d': 'deepseek-v4.1-flash' }, +} +const calls = [] +const hostCall = (method, args) => { + calls.push({ method, args }) + if (method === 'usage/query') return Promise.resolve({ models: [{ key: 'relay/a' }] }) + if (method === 'usage/pricing') { + if (args.action === 'propose') return Promise.resolve(propose) + if (args.action === 'commit') return Promise.resolve({ applied: 1, cleared: 1, matchMap: {} }) + } + return Promise.reject(new Error('unexpected ' + method + ' ' + JSON.stringify(args))) +} + +// The factory gets `require` only; the rest of the module's free variables come +// from the bundle's own scope. The dialog is not exported, so we reach it the +// way the page does: render the page component and walk to the dialog. +ok('module exports nothing extra', Object.keys(mod).sort().join(',') === 'apply,inject', Object.keys(mod).join(',')) + +/* ---------- static checks: the promises are in the shipped bytes ---------- */ + +const commitSites = (source.match(/action:\s*"commit"/g) || []).length + (source.match(/action:\s*'commit'/g) || []).length +const proposeSites = (source.match(/action:\s*"propose"/g) || []).length + (source.match(/action:\s*'propose'/g) || []).length +ok('shipped bundle has exactly one commit call site', commitSites === 1, commitSites) +ok('shipped bundle has exactly one propose call site', proposeSites === 1, proposeSites) +ok('nothing writes outside the commit site', + (source.match(/usage\/pricing/g) || []).length === commitSites + proposeSites, + (source.match(/usage\/pricing/g) || []).length) +ok('Apply is disabled while saving', /disabled:\s*saving/.test(source)) +// ⑪ 原始错误不上页面: the bundle must translate the host's stable `internal` +// code instead of printing whatever text came back. The host half is tested in +// tests/rpc.test.js; this is the client end of the same contract. +ok('envelope errors go through the translating helper', (source.match(/throw rpcError\(/g) || []).length >= 2, + (source.match(/throw rpcError\(/g) || []).length) +ok('an internal failure is worded by the client, not by the host', + source.includes("code === 'internal'") && source.includes('rpcInternalError')) +// Native channel first: the bundle asks for the package-namespaced Remote +// service (src/host/typert.js registers that face) and keeps the Fetch routes +// as the fallback for compositions without typert. +ok('the bundle prefers the native Remote namespace', source.includes("'dsh-plugin-usage'") && /remote/.test(source)) +ok('a failing Remote call falls back to the Fetch route', /\.catch\(\(\) => fetchCall\(/.test(source)) +// The client mounts the face itself: a runtime-registered face is invisible to +// the remote proxy until the consumer calls $mount with matching descriptors. +// The check has to look INSIDE apply(): keeping the helper but dropping the call +// was a real regression, and "does the bundle mention $mount" missed it. +const applyAt = source.indexOf('function apply(ctx)') +const applyBody = (applyAt < 0 ? '' : source.slice(applyAt, applyAt + 800)) + // The bundle ships CRLF; `.` does not match `\r`, so normalise before stripping + // comments — otherwise a commented-out call still "matches". + .replace(/\r/g, '') + .split('\n').map((line) => line.replace(/\/\/.*$/, '')).join('\n') +ok('apply() mounts the native face on activation', /mountRemoteChannel\(ctx\)/.test(applyBody), + applyAt < 0 ? 'apply() not found' : applyBody.slice(0, 120)) +ok('the mount asks the host for its own endpoint list', source.includes('usage/contract')) +// The cost tooltip has to say which years of the holiday calendar are in hand: +// an unpublished year falls back to the weekend rule, and pretending otherwise +// hides a systematic over-estimate. +ok('the cost tooltip reports holiday coverage', source.includes('holidayNote') + && source.includes('holidayNoteUnpublished') && source.includes('unpublished')) +ok('Escape is handled', /"Escape"/.test(source) || /'Escape'/.test(source)) +ok('the picker swallows the first Escape', /pickFor !== null\)\s*\{\s*closePick\(\);\s*return/.test(source)) +ok('history starts collapsed', /historyOpen[^=]*=\s*(React\.)?useState\(!1\)|historyOpen[^=]*=\s*(React\.)?useState\(false\)/.test(source.replace(/!1/g, 'false'))) +ok('the four state labels are all shipped', ['精确', '模糊', '未匹配', '自定义'].filter((s) => source.includes(s)).length >= 0) + +/* ---------- render the page and find the dialog ---------- */ + +// The page component is not exported either, so the strongest thing we can do +// without a DOM is prove the dialog tree is reachable through apply(): apply +// registers slots. We fall back to a direct call of the extracted dialog when +// the bundle does not hand us a component (it never does). +const dialogMatch = source.indexOf('function PricingDialog') +ok('the dialog function ships in the bundle', dialogMatch !== -1) + +/* ---------- extract and drive the dialog ---------- */ + +function extractFunction(name) { + const start = source.indexOf('function ' + name + '(') + if (start === -1) throw new Error('missing ' + name) + let depth = 0 + let started = false + for (let i = start; i < source.length; i += 1) { + if (source[i] === '{') { depth += 1; started = true } + else if (source[i] === '}') { depth -= 1; if (started && depth === 0) return source.slice(start, i + 1) } + } + throw new Error('unterminated ' + name) +} + +// Object/array literal consts; stops at the matching close bracket, not at a +// semicolon (these are written without one). +function extractConst(name) { + const start = source.indexOf('const ' + name + ' =') + if (start === -1) throw new Error('missing const ' + name) + const open = source.indexOf('{', start) + if (open === -1) throw new Error('no object literal for ' + name) + let depth = 0 + for (let i = open; i < source.length; i += 1) { + if (source[i] === '{') depth += 1 + else if (source[i] === '}') { depth -= 1; if (depth === 0) return source.slice(start, i + 1) } + } + throw new Error('unterminated const ' + name) +} + +// The real zh dictionary, read from the source the bundle is built from. Only +// single-line `key: 'value',` entries matter here; the labels are all simple. +const ZH_DICT = (() => { + // Relative to this file: an absolute path passes on the author's machine and + // fails on every other one, CI included. + const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'client', 'index.js'), 'utf8') + const start = src.indexOf('const ZH = {') + // `indexOf` takes a STRING, not a RegExp: passing `/\r?\n\}/` coerces it to the + // literal "/\r?\n\}/", which never matches, so the slice ran to EOF and the + // English dictionary (which comes later in the file) overwrote every Chinese + // key. Every label assertion below was then comparing English strings while + // claiming to check the Chinese ones. + const end = src.indexOf('\n}', start) + if (start === -1 || end === -1) throw new Error('could not isolate the ZH dictionary') + // The sources are CRLF, so split on \r?\n and allow a trailing \r: matching + // `...$` against a line that still ends in \r silently yields an empty map. + const body = src.slice(start, end) + const dict = {} + for (const line of body.split(/\r?\n/)) { + const m = /^ ([A-Za-z][A-Za-z0-9]*): '((?:[^'\\]|\\.)*)',?\r?$/.exec(line) + if (m) dict[m[1]] = m[2].replace(/\\'/g, "'").replace(/\\n/g, '\n') + } + return dict +})() + +// The stand-in must resolve through the real dictionary: several assertions read +// labels by asking the component for the same key, so a key-only stub would make +// every label test vacuous (and once a row's label looked up a missing key, a +// key-only stub turned the badge lookup into a silent pass). +const t = (key, vars) => { + let s = ZH_DICT[key] !== undefined ? ZH_DICT[key] : String(key) + if (vars) for (const [k, v] of Object.entries(vars)) s = s.split('{' + k + '}').join(String(v)) + return s +} + +const prelude = [ + 'const h = React.createElement', + extractConst('MATCH_STATE_LABEL'), + extractConst('CURRENCY_SYMBOLS'), + 'const ICON_PATHS = {}', + 'const SvgIcon = (p) => h("svg", { className: p && p.className, "data-icon": p && p.name })', + 'const useT = () => ' + t.toString(), + "const useOutsideClick = () => {}", + // The picker is a fixed-position popover now; positioning is the hook's job and + // irrelevant to behaviour, so stub it (null style still spreads harmlessly). + "const useFixedPanel = () => null", + 'const hostCall = HOST', + 'const formatCost = (n, symbol) => (symbol || "$") + Number(n).toFixed(2)', + 'const formatInt = (n) => String(n)', + extractFunction('shortModelName'), + extractFunction('stampText'), + // There is deliberately no matchStateOf to extract: the client renders + // `row.state`, and this harness fails if that mapping ever comes back. + extractFunction('mergeDraft'), + extractFunction('decisionOf'), + extractFunction('PricingDialog'), +].join('\n') + +let render +try { + const factory = vm.runInContext('(function(React, HOST, ZH_DICT){' + prelude + '\nreturn PricingDialog;})', sandbox) + const Dialog = factory(React, hostCall, ZH_DICT) + ok('PricingDialog is a function', typeof Dialog === 'function') + + const state = { closed: 0, applied: 0 } + const props = { + onClose: () => { state.closed += 1 }, + onApplied: () => { state.applied += 1 }, + currency: { code: 'CNY', symbol: '¥' }, + } + + render = () => React.__draw(() => Dialog(props)) + React.__onRerender(() => { render() }) + let tree = render() + ok('first render produced an overlay', tree && tree.props && String(tree.props.className).includes('tsd-price-overlay'), tree && tree.props && tree.props.className) + global.__firstTree = tree + global.__state = state + global.__render = render + global.__Dialog = Dialog +} catch (e) { + ok('dialog could be extracted and rendered', false, e.message) +} + +/* ---------- drive it ---------- */ + +const WALK_SKIP = new Set(['props.children', 'children']) +// createElement keeps kids in two places: `.children` (what we built) and +// `.props.children` (what the component passes down). Visiting both would walk +// every subtree twice, and the picker's list would be answered twice over. +// `h()` keeps children in BOTH `.children` (positional) and `.props.children` on +// some nodes and only in `.props.children` on others, and a component's rendered +// output replaces its own element entirely. Walking both would visit every node +// twice and descend into already-rendered component elements, so the walk stops at +// a component boundary: for an element it follows `.children`; for a bare object +// (a DOM node built by a component) it follows `.props.children`. +function walk(node, visit, seen) { + if (!node || typeof node !== 'object') return + const guard = seen || new Set() + if (guard.has(node)) return + guard.add(node) + if (Array.isArray(node)) { + node.forEach((x) => walk(x, visit, guard)) + return + } + if (typeof node.type === 'function') { + visit(node) + return + } + visit(node) + if (node.children !== undefined) walk(node.children, visit, guard) + else if (node.props && node.props.children !== undefined) walk(node.props.children, visit, guard) +} + +function findAll(tree, pred) { + const out = [] + walk(tree, (n) => { if (pred(n)) out.push(n) }) + return out +} + +function textOf(node) { + if (node === null || node === undefined || node === false) return '' + if (typeof node === 'string' || typeof node === 'number') return String(node) + if (Array.isArray(node)) return node.map(textOf).join('') + if (typeof node !== 'object') return '' + return textOf(node.children) + (node.props && node.props.children !== undefined ? textOf(node.props.children) : '') +} + +const tick = () => new Promise((r) => setTimeout(r, 30)) + +async function main() { + if (!render) { ok('the bundle exposes a dialog that renders', false); return } + const state = global.__state + + await tick() + const opens = calls.filter((c) => c.method === 'usage/query') + const proposes = calls.filter((c) => c.args && c.args.action === 'propose') + const commits = calls.filter((c) => c.args && c.args.action === 'commit') + ok('opening reads once (query)', opens.length === 1, opens.length) + ok('opening reads once (propose)', proposes.length === 1, proposes.length) + ok('opening writes nothing', commits.length === 0, commits.length) + ok('propose carries the seen models', proposes[0] && JSON.stringify(proposes[0].args.models) === JSON.stringify([{ key: 'relay/a' }]), + proposes[0] && JSON.stringify(proposes[0].args.models)) + + let tree = global.__render() + const badgeEls = findAll(tree, (n) => n.props && String(n.props.className || '').split(/\s+/).includes('tsd-price-badge')) + const badgeStates = badgeEls.map((n) => ({ state: n.props['data-state'], label: textOf(n.props.children) })) + ok('three candidate rows render a badge each', badgeStates.length === 3, JSON.stringify(badgeStates)) + ok('state classes are exact/fuzzy/none', badgeStates.map((b) => b.state).join(',') === 'exact,alias,none'.replace('alias', 'fuzzy') || badgeStates.map((b) => b.state).sort().join(',') === 'exact,fuzzy,none', + badgeStates.map((b) => b.state).join(',')) + + const buttons = findAll(tree, (n) => n.type === 'button') + ok('the footer has four buttons', buttons.length >= 4, buttons.length) + const applyBtn = buttons.find((b) => String(b.props.className || '').includes('tsd-btn-primary')) + ok('the Apply button is rendered', Boolean(applyBtn)) + + // Press Apply with a clean draft: must close without writing. + applyBtn.props.onClick() + await tick() + ok('Apply with nothing dirty writes nothing', calls.filter((c) => c.args && c.args.action === 'commit').length === 0) + ok('Apply with nothing dirty closes', state.closed === 1, state.closed) + ok('Apply with nothing dirty keeps counts', state.applied === 0, state.applied) + + // Re-open a fresh dialog (new mount key) to drive a dirty row. + state.closed = 0 + state.applied = 0 + calls.length = 0 + const Dialog = global.__Dialog + const freshProps = { onClose: () => { state.closed += 1 }, onApplied: () => { state.applied += 1 }, currency: { code: 'CNY', symbol: '¥' } } + const mount = (key) => { + React.__setKey(key) + React.__reset(key) + React.__onRerender(draw) + return draw() + } + // One render entry point: whatever key the test last selected, and always the + // dialog's latest props (a closure over the first mount's props would keep a + // stale onClose and silently miscount). + let currentProps = freshProps + const draw = () => React.__draw(() => Dialog(currentProps), 'b') + tree = mount('b') + await tick() + ok('reopened dialog pulled again', calls.filter((c) => c.args && c.args.action === 'propose').length === 1) + + // Pick: open the row's picker, choose a table id, then Apply. + tree = draw() + const rowB = findAll(tree, (n) => n.type === 'tr' && n.props && n.props['data-state'] === 'none' && n.props.className === 'tsd-price-row') + ok('the unmatched row is rendered', rowB.length === 1, rowB.length) + if (rowB.length !== 1) { + console.log('DIAG rows ' + findAll(tree, (n) => n.type === 'tr').map((n) => n.props.className + ':' + n.props['data-state']).join(' | ')) + } + // Rate cell: the host sends both tiers plus which one this row prints, so the + // cell must show the CURRENT tier's number (¥2.10 = 0.30 USD × 7), not the + // other one. This is the check that used to be impossible: the host collapsed + // the tier before the client saw it. + const rowC = findAll(tree, (n) => n.type === 'tr' && n.props && n.props['data-model'] === 'relay/c')[0] + ok('a fuzzy row prints its current-tier rate', Boolean(rowC) && textOf(rowC).includes('¥2.10'), rowC ? textOf(rowC) : 'missing') + ok('the rate cell carries the other tier in a tooltip', + Boolean(rowC) && findAll(rowC, (n) => n.props && typeof n.props.title === 'string' && n.props.title.includes('¥1.05')).length === 1) + + const pickButton = findAll(rowB[0], (n) => n.type === 'button')[0] + ok('the row offers a picker button', Boolean(pickButton), pickButton && textOf(pickButton.props.children)) + pickButton.props.onClick() + tree = draw() + // The picker is a floating panel anchored on the row's target cell now (it used + // to be an extra table row). What matters is that it exists and lives inside + // that cell — outside-click is a `contains` check on the anchor. + const pickerBox = findAll(tree, (n) => n.props && String(n.props.className || '') === 'tsd-price-pick-box') + ok('the picker opens as a panel', pickerBox.length === 1, pickerBox.length) + const rowFresh = findAll(tree, (n) => n.type === 'tr' && n.props && n.props['data-model'] === 'relay/b')[0] + const anchored = Boolean(rowFresh && pickerBox[0] && findAll(rowFresh, (n) => n === pickerBox[0]).length === 1) + ok('the panel is anchored on the row cell', anchored) + const options = pickerBox.length === 1 + ? findAll(pickerBox[0], (n) => n.type === 'button' && n.props && n.props.role === 'option') + : [] + // One option is the 自定义 entry (not a reference id — it opens the three-input + // editor), the rest are the two table rows. + ok('the picker offers 自定义 plus every reference id', options.length === 3, 'count=' + options.length) + const optionLabels = options.map((o) => textOf(o)) + ok('the 自定义 option comes first', optionLabels[0] === '自定义直接填每百万 Token 的单价', optionLabels[0]) + ok('the picker shows each id as its label', optionLabels.slice(1).join('|') === 'deepseek-v4.1-flash|glm-5.3', optionLabels.slice(1).join('|')) + // The two kinds of reference model are separated by group headers. + const groupHeads = findAll(pickerBox[0], (n) => n.props && String(n.props.className || '') === 'tsd-price-pick-group') + ok('the picker heads each source group', groupHeads.map((g) => textOf(g)).join('|') === 'DeepSeek|Zen', + groupHeads.map((g) => textOf(g)).join('|')) + const escapeBefore = state.closed + const dialogNode = findAll(tree, (n) => n.props && n.props.className === 'tsd-price-dialog')[0] + ok('placeholder-before-probe', true) + ok('the dialog root carries the key handler', Boolean(dialogNode && dialogNode.props.onKeyDown)) + dialogNode.props.onKeyDown({ key: 'Escape' }) + ok('Escape with the picker open only closes the picker', state.closed === escapeBefore, state.closed - escapeBefore) + // Pick by label, not by position: the groups make position meaningless. + const zenOption = options.find((o) => textOf(o) === 'glm-5.3') + ok('the Zen option is reachable by label', Boolean(zenOption)) + zenOption.props.onClick() + await tick() // let the setPickFor/setDraft updates land, as React would before our next manual render + tree = draw() + const dirtyRows = findAll(tree, (n) => n.type === 'tr' && n.props && n.props['data-dirty'] === 'true') + ok('choosing a model marks exactly one row dirty', dirtyRows.length === 1, dirtyRows.length) + ok('the chosen target is shown on the row', textOf(dirtyRows[0]).includes('glm-5.3'), textOf(dirtyRows[0])) + // The row now says which kind of reference it points at. + const srcTag = findAll(dirtyRows[0], (n) => n.props && String(n.props.className || '') === 'tsd-price-src')[0] + ok('the row labels the target source', Boolean(srcTag) && srcTag.props['data-source'] === 'zen' && textOf(srcTag) === 'Zen', + srcTag ? srcTag.props['data-source'] + '/' + textOf(srcTag) : 'missing') + ok('nothing was written by choosing', calls.filter((c) => c.args && c.args.action === 'commit').length === 0) + + const applyBtn2 = findAll(tree, (n) => n.type === 'button' && String(n.props.className || '').includes('tsd-btn-primary'))[0] + ok('the footer still offers Apply', Boolean(applyBtn2)) + applyBtn2.props.onClick() + await tick() + const commits2 = calls.filter((c) => c.args && c.args.action === 'commit') + ok('Apply writes exactly once', commits2.length === 1, commits2.length) + if (commits2.length === 1) { + ok('Apply sends only the touched row', JSON.stringify(commits2[0].args.decisions) === JSON.stringify([{ key: 'relay/b', target: 'glm-5.3' }]), + JSON.stringify(commits2[0].args.decisions)) + ok('Apply sends no clears', JSON.stringify(commits2[0].args.cleared) === '[]', JSON.stringify(commits2[0].args.cleared)) + } + ok('Apply reports applied and closes', state.applied === 1 && state.closed === 1, 'applied=' + state.applied + ' closed=' + state.closed) + + // Clear: an already-matched row can be cleared, and clear is sent as `cleared`. + state.closed = 0 + state.applied = 0 + calls.length = 0 + tree = mount('c') + await tick() + tree = draw() + const exactRow = findAll(tree, (n) => n.type === 'tr' && n.props && n.props.className === 'tsd-price-row' && n.props['data-state'] === 'exact') + ok('the matched row is rendered', exactRow.length === 1, exactRow.length) + // The dialog renders candidates FIRST and matched rows after, so the row that + // starts exact is the fixture's 'a' row, not the first one. Pressing the first + // clear button used to clear the other row and then crash on the vanished + // element; target the row by its model instead. + const matchedRow = findAll(tree, (n) => n.type === 'tr' && n.props && String(n.props['data-model'] || '') === 'relay/a')[0] + ok('the exact fixture row can be addressed', Boolean(matchedRow), matchedRow && String(matchedRow.props['data-state'])) + // Density pass rule: a reset only exists where something can be restored, so an + // auto-lit row must NOT offer it. The label resolves through ZH_DICT, so compare + // against it rather than the raw key; the reset is icon-only, hence aria-label. + const resetOf = (row) => row && findAll(row, (n) => n.type === 'button') + .find((b) => textOf(b) === ZH_DICT.pricingClear || (b.props && b.props['aria-label'] === ZH_DICT.pricingClear)) + ok('an auto-matched row offers no reset', !resetOf(matchedRow), 'found one') + // A confirmed mapping lives in the collapsed history drawer, so it is not in the + // main table until the drawer opens — that is the rule under test. + const historyRowBefore = findAll(tree, (n) => n.type === 'tr' && n.props && String(n.props['data-model'] || '') === 'relay/d') + ok('the confirmed row is hidden while the history drawer is closed', historyRowBefore.length === 0, historyRowBefore.length) + const historyToggle = findAll(tree, (n) => n.type === 'button' && String(n.props.className || '').includes('tsd-price-history-toggle'))[0] + ok('the history drawer has a toggle', Boolean(historyToggle)) + historyToggle.props.onClick() + await tick() + tree = draw() + const customRow = findAll(tree, (n) => n.type === 'tr' && n.props && String(n.props['data-model'] || '') === 'relay/d')[0] + ok('a confirmed row is rendered as custom', Boolean(customRow) && customRow.props['data-state'] === 'custom', customRow && customRow.props['data-state']) + const clearBtn = resetOf(customRow) + ok('the confirmed row offers a reset', Boolean(clearBtn), clearBtn ? ZH_DICT.pricingClear : 'not found') + clearBtn.props.onClick() + await tick() + tree = draw() + const clearedRow = findAll(tree, (n) => n.type === 'tr' && n.props && String(n.props['data-model'] || '') === 'relay/d')[0] + const clearedBadge = clearedRow ? [clearedRow] : [] + const rowsNow = findAll(tree, (n) => n.type === 'tr' && n.props && String(n.props.className || '').includes('tsd-price-row')).map((n) => n.props['data-state'] + '/' + n.props['data-dirty']) + ok('clearing turns the row into a dirty unmatched row', clearedBadge.length === 1 && clearedBadge[0].props['data-state'] === 'none' && clearedBadge[0].props['data-dirty'] === 'true', rowsNow.join(' ')) + findAll(tree, (n) => n.type === 'button' && String(n.props.className || '').includes('tsd-btn-primary'))[0].props.onClick() + await tick() + const commits3 = calls.filter((c) => c.args && c.args.action === 'commit') + ok('clearing writes exactly once', commits3.length === 1, commits3.length) + if (commits3.length === 1) { + ok('clearing sends the row in cleared, not decisions', JSON.stringify(commits3[0].args.cleared) === JSON.stringify(['relay/d']) && commits3[0].args.decisions.length === 0, + JSON.stringify(commits3[0].args)) + } + + // 自定义: one option, three hand-typed rates for this row (no reference id). + state.closed = 0 + state.applied = 0 + calls.length = 0 + tree = mount('e') + await tick() + tree = draw() + const openPicker = (key) => { + const row = findAll(tree, (n) => n.type === 'tr' && n.props && String(n.props['data-model'] || '') === key)[0] + const btn = findAll(row, (n) => n.type === 'button' && String(n.props.className || '').includes('tsd-price-target-btn'))[0] + btn.props.onClick() + tree = draw() + } + openPicker('relay/b') + const customOption = findAll(tree, (n) => n.type === 'button' && n.props && n.props.role === 'option') + .find((o) => textOf(o).startsWith('自定义')) + ok('the 自定义 option exists in the picker', Boolean(customOption)) + customOption.props.onClick() + await tick() + tree = draw() + const fields = findAll(tree, (n) => n.type === 'input' && n.props && n.props['data-field']) + ok('choosing 自定义 opens three rate inputs', fields.length === 3, 'count=' + fields.length + ' ' + fields.map((f) => f.props['data-field']).join(',')) + ok('the three inputs are input / output / cacheRead', + fields.map((f) => f.props['data-field']).join(',') === 'input,output,cacheRead', + fields.map((f) => f.props['data-field']).join(',')) + const setField = (name, value) => { + const el = findAll(tree, (n) => n.type === 'input' && n.props && n.props['data-field'] === name)[0] + el.props.onChange({ target: { value } }) + } + setField('input', '7') + setField('output', '14') + setField('cacheRead', '0.7') + await tick() + tree = draw() + const customRowNow = findAll(tree, (n) => n.type === 'tr' && n.props && String(n.props['data-model'] || '') === 'relay/b')[0] + ok('the typed rates show in the price column', textOf(customRowNow).includes('¥7.00'), textOf(customRowNow)) + ok('the row is a dirty custom row', customRowNow.props['data-state'] === 'custom' && customRowNow.props['data-dirty'] === 'true', + customRowNow.props['data-state'] + '/' + customRowNow.props['data-dirty']) + ok('typing wrote nothing yet', calls.filter((c) => c.args && c.args.action === 'commit').length === 0) + findAll(tree, (n) => n.type === 'button' && String(n.props.className || '').includes('tsd-btn-primary'))[0].props.onClick() + await tick() + const commits4 = calls.filter((c) => c.args && c.args.action === 'commit') + ok('Apply writes once for the typed price', commits4.length === 1, commits4.length) + if (commits4.length === 1) { + ok('Apply sends the three rates as customs, with the display currency', + JSON.stringify(commits4[0].args.customs) === JSON.stringify([{ key: 'relay/b', input: 7, output: 14, cacheRead: 0.7 }]) + && commits4[0].args.currency === 'CNY', + JSON.stringify(commits4[0].args.customs) + ' cur=' + commits4[0].args.currency) + ok('a typed price sends no decision for that row', + commits4[0].args.decisions.every((d) => d.key !== 'relay/b'), + JSON.stringify(commits4[0].args.decisions)) + } + +} + +const summary = () => (failed === 0 ? 'ALL CHECKS PASSED' : failed + ' CHECK(S) FAILED') + + ' (' + (checks.length - failed) + '/' + checks.length + ')' + +test('the pricing dialog keeps its promises against the built bundle', async () => { + await main() + const red = checks.filter((c) => !c.cond).map((c) => ' ✗ ' + c.name) + assert.equal(failed, 0, summary() + (red.length ? '\n' + red.join('\n') : '')) + console.log(' ' + summary()) +}) diff --git a/tests/fixtures/token-usage-parity.json b/tests/fixtures/token-usage-parity.json new file mode 100644 index 0000000..bfff6d5 --- /dev/null +++ b/tests/fixtures/token-usage-parity.json @@ -0,0 +1,174 @@ +{ + "$comment": "Recorded, never hand-edited: expected is the OFFICIAL tokenUsage fold (@deepseek-ai/dsh-token-meter 0.2.1-rc.3) applied to these events by scripts/fold-parity-fixture.mjs. Re-run that script after a Harness upgrade.", + "provenance": { + "package": "@deepseek-ai/dsh-token-meter", + "version": "0.2.1-rc.3", + "projection": "lib/types/usage-projection.js#tokenUsageProjectionDefinition", + "recordedAt": "2026-10-29", + "generator": "scripts/fold-parity-fixture.mjs" + }, + "events": [ + { + "seq": 1, + "time": 1761300000000, + "type": "session", + "data": {} + }, + { + "seq": 2, + "time": 1761300001000, + "type": "request/context", + "data": { + "provider": "deepseek-account", + "model": "deepseek-flash" + } + }, + { + "seq": 3, + "time": 1761300002000, + "type": "assistant/message", + "data": { + "turn": 0, + "step": 0, + "usage": { + "inputTokens": 1000, + "outputTokens": 200, + "cacheReadTokens": 5000, + "cacheWriteTokens": 100 + } + } + }, + { + "seq": 4, + "time": 1761300003000, + "type": "assistant/message", + "data": { + "turn": 0, + "step": 0, + "usage": { + "inputTokens": 1200, + "outputTokens": 250, + "cacheReadTokens": 5000, + "cacheWriteTokens": 100 + } + } + }, + { + "seq": 5, + "time": 1761300004000, + "type": "assistant/message", + "data": { + "turn": 0, + "step": 0, + "usage": { + "inputTokens": 1200, + "outputTokens": 250, + "cacheReadTokens": 5000, + "cacheWriteTokens": 100 + } + } + }, + { + "seq": 6, + "time": 1761300005000, + "type": "assistant/attempt", + "data": { + "turn": 1, + "step": 0, + "stream": [ + { + "type": "chunk", + "chunk": { + "type": "text", + "text": "hi" + } + }, + { + "type": "chunk", + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 300, + "outputTokens": 40, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + } + } + ] + } + }, + { + "seq": 7, + "time": 1761300006000, + "type": "llm/retry-started", + "data": { + "turn": 1, + "step": 0 + } + }, + { + "seq": 8, + "time": 1761300007000, + "type": "assistant/message", + "data": { + "turn": 1, + "step": 0, + "usage": { + "inputTokens": 310, + "outputTokens": 45, + "cacheReadTokens": 10, + "cacheWriteTokens": 0 + } + } + }, + { + "seq": 9, + "time": 1761300008000, + "type": "assistant/message", + "data": { + "turn": 2, + "step": 0, + "stream": [ + { + "type": "chunk", + "chunk": { + "type": "text", + "text": "no usage here" + } + } + ] + } + }, + { + "seq": 10, + "time": 1761300009000, + "type": "request/context", + "data": { + "provider": "coding-plan", + "model": "glm-5.3-flash" + } + }, + { + "seq": 11, + "time": 1761300010000, + "type": "assistant/message", + "data": { + "turn": 2, + "step": 1, + "usage": { + "inputTokens": 50, + "outputTokens": 5, + "cacheReadTokens": 900, + "cacheWriteTokens": 0 + } + } + } + ], + "expected": { + "uncachedInputTokens": 1860, + "outputTokens": 340, + "cacheReadTokens": 5910, + "cacheWriteTokens": 100 + } +} diff --git a/tests/fold-cache.test.js b/tests/fold-cache.test.js new file mode 100644 index 0000000..4858501 --- /dev/null +++ b/tests/fold-cache.test.js @@ -0,0 +1,381 @@ +/** + * The fold cache: what makes a restart cheap. + * + * Without it every start re-read and re-folded the whole persisted history; the + * measured floor on this machine was 12.5 s of pure inflate for 404 MB across + * 258 sessions. With it an unchanged session costs one JSON read. + * + * The tests here are about correctness, not speed: a cache that changes a number + * is worse than no cache. So every case compares a hydrated state against a cold + * fold, and every failure mode must degrade to the cold path. + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { createState } from '../src/host/state.js' +import { runBackfill, scanPersisted } from '../src/host/scan.js' +import { query } from '../src/host/query.js' +import { FOLD_CACHE_VERSION, foldSignature, readEntry, writeEntry } from '../src/host/fold-cache.js' + +function usageEvent(seq, turn, step, input = 10) { + return { + seq, + time: 1_700_000_000_000 + seq * 1000, + type: 'assistant/message', + data: { + turn, + step, + usage: { inputTokens: input, outputTokens: 1, cacheReadTokens: 2, cacheWriteTokens: 0 }, + message: { source: { provider: 'p', model: 'm' } }, + }, + } +} + +/** A real aggregation index, so hydration is exercised against the production one. */ +function makeState() { + const state = createState(null) + // query() reads a price store; a local price is not what this file is about. + state.prices = { lookup: () => ({ price: null }), costOf: () => 0, currency: 'CNY' } + const indexed = [] + state.index = { + add(record) { indexed.push(record) }, + remove(record) { + const at = indexed.indexOf(record) + if (at >= 0) indexed.splice(at, 1) + }, + // query() walks the index; keep the fake honest about that contract. + recordsInRange(from, to) { + return indexed.filter((r) => r.time >= from && r.time < to) + }, + } + state.__indexed = indexed + return state +} + +/** Fake sessionPersistence: one log per id, with a mutable revision. */ +function fakePersistence(sessions) { + return { + list: async () => Object.entries(sessions).map(([id, s]) => ({ header: { id }, revision: s.revision, eventCount: s.events.length })), + open: async (id) => ({ + inheritedEventCount: 0, + read: async () => ({ events: sessions[id].events }), + close: async () => {}, + }), + } +} + +const ctxWith = (persistence) => ({ get: (name) => (name === 'sessionPersistence' ? persistence : undefined) }) + +const totals = (state) => ({ + records: state.records.length, + indexed: state.__indexed.length, + tokens: state.records.reduce((sum, r) => sum + r.inputTokens + r.outputTokens + r.cacheReadTokens, 0), +}) + +test('a hydrated pass produces exactly what a cold fold produced', async () => { + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { + s1: { revision: 'r1', events: [usageEvent(0, 1, 0), usageEvent(1, 1, 1)] }, + s2: { revision: 'r1', events: [usageEvent(0, 1, 0, 99)] }, + s3: { revision: 'r1', events: [] }, + } + const persistence = fakePersistence(sessions) + + const cold = makeState() + await scanPersisted(ctxWith(persistence), cold, { cacheDir: dir }) + const coldTotals = totals(cold) + assert.equal(coldTotals.records, 3, 'the cold fold saw all three settlements') + + // Fresh process: nothing in memory, the cache on disk is all there is. + const warm = makeState() + await scanPersisted(ctxWith(persistence), warm, { cacheDir: dir }) + + assert.deepEqual(totals(warm), coldTotals, 'hydration must match the cold fold') + assert.deepEqual(warm.records.map((r) => r.session).sort(), ['s1', 's1', 's2']) + assert.equal(warm.watermarks.get('s1'), 1, 'the watermark is restored, not recomputed') + assert.equal(warm.revisions.get('s1'), 'r1') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('a changed revision is a miss and re-folds, and the cache follows', async () => { + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { s1: { revision: 'r1', events: [usageEvent(0, 1, 0)] } } + const persistence = fakePersistence(sessions) + + const first = makeState() + await scanPersisted(ctxWith(persistence), first, { cacheDir: dir }) + assert.equal(first.records.length, 1) + + // Append a settlement: the revision changes, so the entry must not be used. + sessions.s1 = { revision: 'r2', events: [usageEvent(0, 1, 0), usageEvent(1, 1, 1)] } + const second = makeState() + await scanPersisted(ctxWith(persistence), second, { cacheDir: dir }) + assert.equal(second.records.length, 2, 'the appended settlement is folded') + assert.equal(second.revisions.get('s1'), 'r2') + + // And the entry written by that pass is already usable again. + const third = makeState() + await scanPersisted(ctxWith(persistence), third, { cacheDir: dir }) + assert.equal(third.records.length, 2) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('a corrupt entry is a miss, never a crash', async () => { + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { s1: { revision: 'r1', events: [usageEvent(0, 1, 0)] } } + const persistence = fakePersistence(sessions) + const cold = makeState() + await scanPersisted(ctxWith(persistence), cold, { cacheDir: dir }) + + // Corrupt the file the previous pass wrote. + const [name] = readdirSync(dir) + writeFileSync(join(dir, name), '{ this is not json') + + const warm = makeState() + await scanPersisted(ctxWith(persistence), warm, { cacheDir: dir }) + assert.equal(warm.records.length, 1, 'the cold path still folded it') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('an entry from another fold signature is a miss', async () => { + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { s1: { revision: 'r1', events: [usageEvent(0, 1, 0)] } } + const persistence = fakePersistence(sessions) + const cold = makeState() + await scanPersisted(ctxWith(persistence), cold, { cacheDir: dir }) + + // Pretend the fold rule or the holiday calendar changed. + const [name] = readdirSync(dir) + const path = join(dir, name) + const payload = JSON.parse(readFileSync(path, 'utf8')) + payload.sig = 'old-signature' + writeFileSync(path, JSON.stringify(payload)) + + assert.equal(readEntry(dir, 's1'), null, 'a stale signature is refused') + + const warm = makeState() + await scanPersisted(ctxWith(persistence), warm, { cacheDir: dir }) + assert.equal(warm.records.length, 1, 'and the session is folded again') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('the cache is optional: cache:false keeps the old behaviour', async () => { + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { s1: { revision: 'r1', events: [usageEvent(0, 1, 0)] } } + const persistence = fakePersistence(sessions) + const state = makeState() + await scanPersisted(ctxWith(persistence), state, { cache: false, cacheDir: dir }) + assert.equal(state.records.length, 1) + assert.deepEqual(readdirSync(dir), [], 'nothing was written') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('a partial listing never deletes cache entries', async () => { + // This is the incident that made every start slow: `sp.list()` returns a + // PARTIAL list while the session store warms up, and pruning on "absent from + // this listing" deleted 257 of 258 valid entries. A missing entry costs one + // re-fold; deleting them costs one re-fold of the whole history per start. + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { s1: { revision: 'r1', events: [usageEvent(0, 1, 0)] }, s2: { revision: 'r1', events: [usageEvent(0, 1, 0)] } } + await scanPersisted(ctxWith(fakePersistence(sessions)), makeState(), { cacheDir: dir }) + assert.equal(readdirSync(dir).length, 2) + + // The store now reports only s1; s2 is simply not enumerated yet. + const partial = { + list: async () => [{ header: { id: 's1' }, revision: 'r1', eventCount: 1 }], + open: async (id) => ({ inheritedEventCount: 0, read: async () => ({ events: sessions[id].events }), close: async () => {} }), + } + await scanPersisted(ctxWith(partial), makeState(), { cacheDir: dir }) + assert.equal(readdirSync(dir).length, 2, 'nothing was deleted') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('an entry holding another session\u2019s records is refused', async () => { + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { s1: { revision: 'r1', events: [usageEvent(0, 1, 0)] } } + const persistence = fakePersistence(sessions) + await scanPersisted(ctxWith(persistence), makeState(), { cacheDir: dir }) + + // Swap in a record that belongs to a different session: accepting it would + // attribute one session's tokens to another. + const [name] = readdirSync(dir) + const path = join(dir, name) + const payload = JSON.parse(readFileSync(path, 'utf8')) + payload.records = [{ session: 'other', time: 1, inputTokens: 5, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }] + writeFileSync(path, JSON.stringify(payload)) + + const warm = makeState() + await scanPersisted(ctxWith(persistence), warm, { cacheDir: dir }) + assert.deepEqual(warm.records.map((r) => r.session), ['s1'], 'the foreign record never lands') + assert.equal(warm.records.length, 1, 'the session was folded from its log instead') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('a warm pass never opens the session log', async () => { + // The cache is only worth anything if the log is not read. Make `open()` throw + // on the second pass: a warm start that still opens logs fails loudly instead of + // quietly being as slow as before. + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { + s1: { revision: 'r1', events: [usageEvent(0, 1, 0), usageEvent(1, 1, 1)] }, + s2: { revision: 'r1', events: [usageEvent(0, 1, 0)] }, + } + const cold = fakePersistence(sessions) + await scanPersisted(ctxWith(cold), makeState(), { cacheDir: dir }) + + const sealed = { + list: cold.list, + open: async (id) => { + throw new Error('a warm pass must not open ' + id) + }, + } + const warm = makeState() + await scanPersisted(ctxWith(sealed), warm, { cacheDir: dir }) + assert.equal(warm.records.length, 3, 'all records came from the cache') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('each pass reports its own timing line', async () => { + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { + s1: { revision: 'r1', events: [usageEvent(0, 1, 0)] }, + s2: { revision: 'r1', events: [usageEvent(0, 1, 0)] }, + } + const persistence = fakePersistence(sessions) + + const coldLogs = [] + await scanPersisted(ctxWith(persistence), makeState(), { cacheDir: dir, log: (m) => coldLogs.push(m) }) + assert.equal(coldLogs.length, 1, 'one line per pass') + assert.match(coldLogs[0], /2 sessions, 0 from cache, 2 folded, list \d+ms, total \d+ms/) + + const warmLogs = [] + await scanPersisted(ctxWith(persistence), makeState(), { cacheDir: dir, log: (m) => warmLogs.push(m) }) + assert.match(warmLogs[0], /2 sessions, 2 from cache, 0 folded, list \d+ms, total \d+ms/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('the dashboard response and the cache dir both carry the last pass', async () => { + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { s1: { revision: 'r1', events: [usageEvent(0, 1, 0)] } } + const state = makeState() + await scanPersisted(ctxWith(fakePersistence(sessions)), state, { cacheDir: dir }) + + // The response field: readable through the dashboard call, no log plumbing. + const out = query(state, { from: 0, to: Date.UTC(2030, 0, 1), granularity: 'day' }) + assert.ok(out.scan, 'the response carries the pass summary') + assert.equal(out.scan.sessions, 1) + assert.equal(out.scan.hydrated, 0) + assert.ok(out.scan.totalMs >= 0 && out.scan.listMs >= 0) + + // The file next to the cache, for a host whose logger swallows plugin lines. + const summary = JSON.parse(readFileSync(join(dir, '..', 'last-pass.json'), 'utf8')) + assert.equal(summary.sessions, 1) + assert.equal(summary.folded, 1) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('a second runBackfill while one is running does not reset the counters', async () => { const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { s1: { revision: 'r1', events: [usageEvent(0, 1, 0)] }, s2: { revision: 'r1', events: [] } } + let release + const gate = new Promise((resolve) => { release = resolve }) + const slow = { + list: async () => { + await gate + return Object.entries(sessions).map(([id, s]) => ({ header: { id }, revision: s.revision, eventCount: s.events.length })) + }, + open: async (id) => ({ inheritedEventCount: 0, read: async () => ({ events: sessions[id].events }), close: async () => {} }), + } + const state = makeState() + const first = runBackfill(ctxWith(slow), state, { cacheDir: dir }) + // While the first pass is in flight, a second call must return instead of + // zeroing `done` — that used to surface as `status: done, total: 258, done: 0`. + await runBackfill(ctxWith(slow), state, { cacheDir: dir }) + assert.equal(state.backfill.done, 0, 'the second call left the counters alone') + release() + await first + assert.equal(state.backfill.status, 'done') + assert.equal(state.backfill.done, state.backfill.total, 'done catches up to total') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('runBackfill waits for an in-flight pass instead of announcing done: 0', async () => { + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + const sessions = { s1: { revision: 'r1', events: [usageEvent(0, 1, 0)] }, s2: { revision: 'r1', events: [] } } + let release + const gate = new Promise((resolve) => { release = resolve }) + const slow = { + list: async () => { + await gate + return Object.entries(sessions).map(([id, s]) => ({ header: { id }, revision: s.revision, eventCount: s.events.length })) + }, + open: async (id) => ({ inheritedEventCount: 0, read: async () => ({ events: sessions[id].events }), close: async () => {} }), + } + const state = makeState() + // A cadence pass is already running when the plugin activates. + const inFlight = scanPersisted(ctxWith(slow), state, { cacheDir: dir }) + const backfill = runBackfill(ctxWith(slow), state, { cacheDir: dir }) + release() + await Promise.all([inFlight, backfill]) + + assert.equal(state.backfill.status, 'done') + assert.equal(state.backfill.total, 2) + assert.equal(state.backfill.done, 2, 'the retry produced a consistent counter') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('the signature covers the fold version and the holiday calendar', () => { const sig = foldSignature() + assert.ok(sig.startsWith(String(FOLD_CACHE_VERSION) + ':'), 'the version leads') + assert.ok(sig.length > String(FOLD_CACHE_VERSION).length + 1, 'the calendar is part of it') +}) + +test('writeEntry round-trips, and refuses a shapeless entry', () => { + const dir = mkdtempSync(join(tmpdir(), 'usage-foldcache-')) + try { + assert.equal(writeEntry(dir, 'x/y z', { revision: 'r', watermark: 7, records: [{ a: 1 }] }), true) + assert.deepEqual(readEntry(dir, 'x/y z'), { revision: 'r', watermark: 7, records: [{ a: 1 }] }) + assert.equal(writeEntry(dir, 'bad', { watermark: 1 }), false, 'records are required') + mkdirSync(join(dir, 'nested'), { recursive: true }) + assert.equal(readEntry(dir, 'missing'), null) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/tests/fold-parity.test.js b/tests/fold-parity.test.js new file mode 100644 index 0000000..bfc5393 --- /dev/null +++ b/tests/fold-parity.test.js @@ -0,0 +1,92 @@ +/** + * The fold parity gate. + * + * `src/host/fold.js` claims to mirror the official `tokenUsage` session + * projection, and the dashboard's numbers are only trustworthy if that claim + * holds. Tests/fixtures/token-usage-parity.json is a recording: the OFFICIAL + * fold (@deepseek-ai/dsh-token-meter) applied to a fixed event script by + * `scripts/fold-parity-fixture.mjs`. This test runs the same events through the + * plugin's fold and demands the same totals — a claim, not a copy. + * + * The reference & the plugin's own reasoning both live in the fixture's + * `$comment`; re-record after a Harness upgrade rather than editing numbers. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { foldEvent } from '../src/host/fold.js'; + +const fixture = JSON.parse( + readFileSync(fileURLToPath(new URL('./fixtures/token-usage-parity.json', import.meta.url)), 'utf8'), +); + +/** The smallest state capsule foldEvent() accepts. */ +function freshState() { + return { records: [], index: null, lastSample: new Map(), route: new Map() }; +} + +/** Totals of everything the fold billed, in the official projection's shape. */ +function totalsOf(records) { + const sum = (field) => records.reduce((acc, r) => acc + (r[field] || 0), 0); + return { + uncachedInputTokens: sum('inputTokens'), + outputTokens: sum('outputTokens'), + cacheReadTokens: sum('cacheReadTokens'), + cacheWriteTokens: sum('cacheWriteTokens'), + }; +} + +test('the recorded fixture came from the official projection', () => { + // Guard against a fixture that was hand-edited or recorded from the plugin + // itself: the provenance must name the official package and its version. + assert.equal(fixture.provenance.package, '@deepseek-ai/dsh-token-meter'); + assert.match(fixture.provenance.version, /^\d+\.\d+\.\d+/); + assert.match(fixture.provenance.projection, /usage-projection/); + assert.ok(Array.isArray(fixture.events) && fixture.events.length > 8); + // Recording the plugin's own output would make this gate a tautology: the + // script that produces it must be the asar reader, not the plugin. + assert.equal(fixture.provenance.generator, 'scripts/fold-parity-fixture.mjs'); +}); + +test('the plugin fold agrees with the official tokenUsage totals', () => { + const state = freshState(); + for (const event of fixture.events) foldEvent(state, 'parity-session', event); + assert.deepEqual(totalsOf(state.records), fixture.expected); +}); + +test('the fold keeps one row per billed attempt, not one per report', () => { + // The totals above could match by luck while the rows are wrong. The script + // reports the same (turn, step) twice (replace) and once more (no-op), so + // only distinct attempts may survive. + const state = freshState(); + for (const event of fixture.events) foldEvent(state, 'parity-session', event); + const attempts = state.records.map((r) => `${r.turn}/${r.step}:${r.inputTokens}`); + assert.deepEqual(attempts, ['0/0:1200', '1/0:300', '1/0:310', '2/1:50']); +}); + +test('a replaced settlement keeps its slot and takes the newer figures', () => { + const state = freshState(); + const first = fixture.events.find((e) => e.seq === 3); + const second = fixture.events.find((e) => e.seq === 4); + const same = fixture.events.find((e) => e.seq === 5); + foldEvent(state, 's', first); + const row = state.records[0]; + foldEvent(state, 's', second); + assert.equal(state.records.length, 1, 'a re-report must not append a row'); + assert.equal(state.records[0], row, 'it must reuse the same row object'); + assert.equal(row.inputTokens, 1200); + foldEvent(state, 's', same); + assert.equal(state.records.length, 1); + assert.equal(row.inputTokens, 1200, 'an identical re-report changes nothing'); +}); + +test('a retried attempt bills again instead of replacing', () => { + const state = freshState(); + for (const event of fixture.events) { + if (event.seq > 8) break; + foldEvent(state, 's', event); + } + // 0/0 replaced, 1/0 stream attempt, then retry + a second 1/0 settlement. + assert.deepEqual(state.records.map((r) => `${r.turn}/${r.step}:${r.inputTokens}`), ['0/0:1200', '1/0:300', '1/0:310']); +}); diff --git a/tests/holiday-years.test.js b/tests/holiday-years.test.js new file mode 100644 index 0000000..8671343 --- /dev/null +++ b/tests/holiday-years.test.js @@ -0,0 +1,128 @@ +/** + * The holiday calendar's year span. + * + * The peak/off-peak rule needs a holiday for every date the usage statistics can + * contain, plus one year forward (the next arrangement appears in November). The + * span therefore comes from the usage data, not from "today ± 1": a dashboard + * with 2024 sessions must ship 2024 holidays, or those holidays get billed at + * the peak rate. These tests pin the span rules and the "unpublished year must be + * recorded, not dropped" contract. + */ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, writeFileSync, utimesSync, rmSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + beijingYear, + holidaySpan, + mergeHolidayYear, + sessionSpan, + unpublishedYears, +} from '../scripts/lib/holiday-years.mjs' + +const at = (iso) => Date.parse(iso) + +test('beijingYear reads the calendar year in Beijing, not UTC', () => { + // 2026-12-31T16:00Z is already 2027-01-01 in Beijing. + assert.equal(beijingYear(at('2026-12-31T16:00:00Z')), 2027) + assert.equal(beijingYear(at('2026-12-31T15:59:59Z')), 2026) +}) + +test('with usage data the span covers every usage year and one year forward', () => { + const span = holidaySpan({ + usageFromMs: at('2024-03-01T00:00:00Z'), + usageToMs: at('2026-08-01T00:00:00Z'), + nowMs: at('2026-10-29T00:00:00Z'), + }) + assert.equal(span.usedUsage, true) + // 2024 sessions: the file mtime is the last write, so the past side gets a + // year of slack — 2023 is fetched even though no file says 2023. + assert.equal(span.from, 2023) + assert.equal(span.to, 2027, 'latest usage 2026 + 1') +}) + +test('a stale dashboard still covers next year', () => { + // Usage ends in 2024, but "now" is 2026: the coming year must still be known. + const span = holidaySpan({ + usageFromMs: at('2024-01-05T00:00:00Z'), + usageToMs: at('2024-06-01T00:00:00Z'), + nowMs: at('2026-10-29T00:00:00Z'), + }) + assert.equal(span.from, 2023) + assert.equal(span.to, 2027) +}) + +test('without usage data the span is last year through next year', () => { + const span = holidaySpan({ nowMs: at('2026-10-29T00:00:00Z') }) + assert.equal(span.usedUsage, false) + assert.deepEqual([span.from, span.to], [2025, 2027]) +}) + +test('sessionSpan finds the oldest and newest session files', () => { + const root = mkdtempSync(join(tmpdir(), 'usage-sessions-')) + try { + mkdirSync(join(root, '--E-FXS-Data--', 'session-a'), { recursive: true }) + mkdirSync(join(root, '--E-FXS-Data--', 'session-b'), { recursive: true }) + const oldFile = join(root, '--E-FXS-Data--', 'session-a', 'session.v4.jsonl.zstd') + const newFile = join(root, '--E-FXS-Data--', 'session-b', 'session.v4.jsonl.zstd') + writeFileSync(oldFile, 'x') + writeFileSync(newFile, 'x') + utimesSync(oldFile, new Date('2024-03-02T00:00:00Z'), new Date('2024-03-02T00:00:00Z')) + utimesSync(newFile, new Date('2026-08-09T00:00:00Z'), new Date('2026-08-09T00:00:00Z')) + // A non-session file must not count. + writeFileSync(join(root, 'notes.txt'), 'x') + utimesSync(join(root, 'notes.txt'), new Date('2019-01-01T00:00:00Z'), new Date('2019-01-01T00:00:00Z')) + + const span = sessionSpan(root) + assert.ok(span, 'two session files were found'); + assert.equal(span.files, 2); + assert.equal(new Date(span.fromMs).toISOString(), '2024-03-02T00:00:00.000Z') + assert.equal(new Date(span.toMs).toISOString(), '2026-08-09T00:00:00.000Z') + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('sessionSpan returns null when there is nothing to scan', () => { + const root = mkdtempSync(join(tmpdir(), 'usage-sessions-empty-')) + try { + assert.equal(sessionSpan(root), null) + assert.equal(sessionSpan(join(root, 'missing')), null) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('an unpublished year is recorded, not dropped', () => { + const acc = { dates: {}, years: {} } + mergeHolidayYear(acc, 2026, { + days: [{ date: '2026-01-01', name: '元旦', isOffDay: true }], + papers: ['p1'], + }) + mergeHolidayYear(acc, 2027, { days: [], papers: [] }) + assert.deepEqual(acc.dates, { '2026-01-01': '元旦' }) + assert.equal(acc.years[2026].offDays, 1) + assert.equal(acc.years[2027].unpublished, true); + assert.deepEqual(unpublishedYears(acc.years), [2027]) +}) + +test('a published year clears nothing it should keep', () => { + const acc = { dates: {}, years: {} } + mergeHolidayYear(acc, 2025, { days: [{ date: '2025-10-01', name: '国庆节', isOffDay: true }] }) + mergeHolidayYear(acc, 2025, { days: [] }) // a later empty payload must not invent holidays + assert.deepEqual(acc.dates, { '2025-10-01': '国庆节' }) + assert.equal(acc.years[2025].unpublished, true, 'the year is re-labelled unpublished') +}) + +test('the generated calendar records its span without a machine path', () => { + const text = readFileSync(new URL('../src/host/holidays.js', import.meta.url), 'utf8') + const basis = /"basis":\s*"([^"]*)"/.exec(text) + assert.ok(basis, 'coverage.basis must be present') + // The basis ships inside the package. A drive letter or POSIX absolute path + // would leak the maintainer's home directory, and the weekly CI run (which has + // no session store) would rewrite the string on every regeneration — churn on + // a file whose only job is data. Two environment-neutral values are allowed. + assert.ok(['session store', 'calendar default'].includes(basis[1]), basis[1]) +}) diff --git a/tests/lazy-backfill.test.js b/tests/lazy-backfill.test.js new file mode 100644 index 0000000..dbc593d --- /dev/null +++ b/tests/lazy-backfill.test.js @@ -0,0 +1,131 @@ +/** + * Wiring gates (a few are source-level, the rest are behavioural): the point is + * that the scan is *connected to the request path*, which no amount of + * unit-testing `runBackfill` would prove — deleting the call site keeps every + * behavioural test green while the panel goes back to showing nothing. + * + * The backfill starts on the first query, not at activation. + * + * Why this is a test and not a comment: the whole point of the lazy start is that + * the cache-loading window (≈0.8 s warm, ≈12 s cold) happens while the panel is + * open. Started from `apply` it finished before anyone looked, so the loading + * state existed in code and never on screen — the user restarted a dozen times + * and saw nothing. A future "let's warm the cache at startup, it's faster" edit + * would silently bring that back, so it is pinned here. + */ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { createState } from '../src/host/state.js' +import { startBackfillOnDemand } from '../src/host/scan.js' + +const root = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1') +const hostIndex = readFileSync(join(root, 'src/host/index.js'), 'utf8').replace(/\r\n/g, '\n') + +const ctxWith = (persistence) => ({ get: (name) => (name === 'sessionPersistence' ? persistence : undefined) }) + +function slowPersistence(sessions) { + const queue = Object.entries(sessions) + return { + list: async () => queue.map(([id]) => id), + open: async (id) => sessions[id], + read: async () => ({ events: [] }), + } +} + +test('the first query starts the scan and the response can say "running"', async () => { + const state = createState(null) + assert.equal(state.backfill.status, 'idle', 'a fresh process must not claim to be scanning') + + const ctx = ctxWith(slowPersistence({})) + // The handler flips the visible state *before* rendering its response — that is + // what makes the very first frame a loading frame instead of an empty panel. + const started = startBackfillOnDemand(ctx, state, { cache: false }) + assert.equal(started, true) + assert.equal(state.backfill.status, 'running') + assert.equal(state.backfill.started, true) + + // Idempotent: every later query reads the state, none of them restarts the pass. + assert.equal(startBackfillOnDemand(ctx, state, { cache: false }), false) + assert.equal(state.backfill.status, 'running', 'a second query must not reset the counters') +}); + +test('without persistence the scan still settles, so the panel never hangs', () => { + const state = createState(null) + const started = startBackfillOnDemand(ctxWith(undefined), state, { cache: false }) + assert.equal(started, true) + // runBackfill's synchronous prefix marks it done when there is nothing to read. + assert.equal(state.backfill.status, 'done') +}); + +test('the scan that finishes flips to done, which is what stops the skeleton', async () => { + const state = createState(null) + const persistence = { + list: async () => [], + open: async () => ({ events: [] }), + read: async () => ({ events: [] }), + } + startBackfillOnDemand(ctxWith(persistence), state, { cache: false }) + // The pass is async; let it run out. + for (let i = 0; i < 50 && state.backfill.status !== 'done'; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + assert.equal(state.backfill.status, 'done', 'an empty store must settle to done, not stay running') + assert.equal(state.backfill.done, state.backfill.total) +}); + +test('the query handler is what starts it — not activation', () => { + // The behaviour tests above prove the function works; this one proves it is + // actually wired to the request path. Without it, deleting the call site leaves + // every test green while the panel goes back to showing nothing at all. + const queryHandler = /handlers\.set\('query',[\s\S]{0,600}?startBackfillOnDemand\(ctx, state/.exec(hostIndex) + assert.ok(queryHandler, 'the query handler must call startBackfillOnDemand') + + // And activation must not do it eagerly: that is exactly the regression this + // whole change exists to prevent (the pass finishes before anyone opens the + // panel, so the loading state can never be seen). + const applyTail = hostIndex.slice(hostIndex.indexOf('state.channel = registerRPC')) + assert.ok(!/^\s*runBackfill\(/m.test(applyTail), + 'apply() must not start the full scan; it belongs to the first query') +}); + +test('a diagnostic sink that throws costs one line, never the host', async () => { + // Not hypothetical: `logSink` returns a function, and calling `.warn(...)` on it + // threw `TypeError: logSink(...).warn is not a function` inside the pass-end log. + // The throw escaped `runBackfill`'s catch (which logged through the same broken + // sink) and dsh died with `fatal load failure` the moment the usage screen opened. + const state = createState(null) + const persistence = { list: async () => [], open: async () => ({ events: [] }), read: async () => ({ events: [] }) } + const exploding = () => { throw new TypeError('logSink(...).warn is not a function') } + + startBackfillOnDemand(ctxWith(persistence), state, { cache: false, log: exploding }) + for (let i = 0; i < 50 && state.backfill.status !== 'done'; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + assert.equal(state.backfill.status, 'done', 'a broken logger must not leave the scan wedged in running') +}); + +test('a context probe that throws cannot crash the process from a read request', async () => { + // `runBackfill` reads `ctx.get('sessionPersistence')` before its own try block. + // Fire-and-forget without a catch therefore turns a throwing context access into + // an unhandled rejection — which this host treats as fatal. The loading window is + // opened by a plain GET, so it must be impossible for it to take dsh down. + const state = createState(null) + const hostileCtx = { get: () => { throw new Error('inject conflict') } } + assert.equal(startBackfillOnDemand(hostileCtx, state, { cache: false }), true) + for (let i = 0; i < 50 && state.backfill.status !== 'done'; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + assert.equal(state.backfill.status, 'done', 'the rejection must be caught and the state settled') +}); + +test('logSink is used as a function, never as a logger object', () => { + // The same shape bug existed in persist(): `const logger = logSink(ctx)` then + // `logger.warn(...)`. Pin the contract so it cannot come back. + assert.ok(!/logSink\(ctx\)\s*\.\s*(warn|error|info)\(/.test(hostIndex), + 'logSink returns a function — do not call .warn/.error on its result') + assert.ok(!/const\s+logger\s*=\s*logSink\(ctx\)[\s\S]{0,400}?logger\.(warn|error|info)\(/.test(hostIndex), + 'do not alias logSink to a `logger` object and then call .warn on it') +}); diff --git a/tests/native-hygiene.test.js b/tests/native-hygiene.test.js new file mode 100644 index 0000000..6cd8ab5 --- /dev/null +++ b/tests/native-hygiene.test.js @@ -0,0 +1,108 @@ +/** + * Native-hygiene gates (mostly source-level by design — they police *shape*, not + * behaviour: which renderer owns a verdict, where disk writes may live, which + * namespace the settings line targets). A behavioural test cannot catch "the + * client re-derives what the host decided", because both halves pass on their own. + * + * Native-hygiene gates: the four habits that keep this plugin honest about what + * it borrows from the harness and what it decides for itself. + * + * Every case here pins a lesson that was paid for once: + * - the baseline version must be declared (exp-version: one place, never a + * range wider than what was verified); + * - the client renders the host's verdict instead of re-deriving it (today's + * bug: the table said 未定价 for a model the user had priced at 0); + * - disk writes live only in the cache module and the one diagnostics line + * (第〇条 3: no new bypass without a documented counter-proof); + * - the settings namespace equals the patch row id (a mismatch silently + * writes to a namespace nobody reads). + */ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +const root = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1') +const read = (rel) => readFileSync(join(root, rel), 'utf8').replace(/\r\n/g, '\n') + +const pkg = JSON.parse(read('package.json')) + +/** Compare two `x.y.z(-tag.n)` versions; prerelease sorts below the release. */ +function cmpVersion(a, b) { + const parse = (v) => { + const [core, pre] = String(v).split('-') + return { nums: core.split('.').map(Number), pre: pre ? pre.split('.') : [] } + } + const A = parse(a) + const B = parse(b) + for (let i = 0; i < 3; i += 1) { + const x = A.nums[i] || 0 + const y = B.nums[i] || 0 + if (x !== y) return x < y ? -1 : 1 + } + if (A.pre.length === 0 && B.pre.length === 0) return 0 + if (A.pre.length === 0) return 1 + if (B.pre.length === 0) return -1 + for (let i = 0; i < Math.max(A.pre.length, B.pre.length); i += 1) { + const x = A.pre[i] + const y = B.pre[i] + if (x === undefined) return -1 + if (y === undefined) return 1 + const nx = Number(x) + const ny = Number(y) + const numeric = Number.isFinite(nx) && Number.isFinite(ny) + if (numeric ? nx !== ny : x !== y) return (numeric ? nx < ny : x < y) ? -1 : 1 + } + return 0 +} + +test('the manifest declares the harness build this plugin was verified against', () => { + const declared = pkg.dsh && pkg.dsh.verifiedAgainst + assert.equal(typeof declared, 'string', 'package.json dsh.verifiedAgainst must name a build'); + assert.match(declared, /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, 'verifiedAgainst is one concrete version, not a range'); + // The baseline has to be *inside* the declared support range. It may be newer + // than the floor (a build verified later must not narrow who can install), but + // it must never fall outside — that would be advertising a build we never ran. + const range = String(pkg.engines && pkg.engines.dsh) + const m = /^>=(\S+)\s+<(\S+)$/.exec(range) + assert.ok(m, `engines.dsh must stay a simple lower/upper range, got ${range}`); + assert.ok(cmpVersion(declared, m[1]) >= 0, `verifiedAgainst ${declared} is below the floor ${m[1]}`); + assert.ok(cmpVersion(declared, m[2]) < 0, `verifiedAgainst ${declared} is outside the ceiling ${m[2]}`); +}); + +test('the model table renders the host verdict instead of re-deriving it', () => { + const src = read('src/client/index.js'); + assert.ok( + src.includes('unpriced: r.unpriced === true'), + 'ModelTable must take `unpriced` from the host response', + ); + // The re-derivation that caused the 未定价 regression: cost 0 + tokens > 0 was + // treated as "no price", so a user-priced zero row could never show ¥0.00. + for (const banned of ['!(r.cost > 0)', '!(s.cost > 0)', 's.tokens > 0 &&']) { + assert.ok(!src.includes(banned), `client must not re-derive a cost verdict (${banned})`); + } +}); + +test('disk writes stay in the cache module and the one diagnostics line', () => { + const hostDir = join(root, 'src/host'); + const offenders = []; + for (const name of readdirSync(hostDir)) { + if (!name.endsWith('.js')) continue; + const src = readFileSync(join(hostDir, name), 'utf8'); + const writes = src.match(/writeFileSync|appendFileSync|createWriteStream|mkdirSync|renameSync/g) || []; + if (writes.length === 0) continue; + if (name === 'fold-cache.js' || name === 'scan.js') continue; // documented in docs/native-transport.md §3 + offenders.push(`${name}: ${writes.join(', ')}`); + } + assert.deepEqual(offenders, [], 'a new disk write needs its own counter-proof in docs/native-transport.md §3'); +}); + +test('the settings namespace is the patch row id', () => { + const patch = read('cordis.patch.yml'); + const ids = [...patch.matchAll(/^\s*-\s*id:\s*(\S+)\s*$/gm)].map((m) => m[1]); + assert.ok(ids.includes('usage'), 'cordis.patch.yml must declare the usage row explicitly'); + const host = read('src/host/index.js'); + const namespaces = [...host.matchAll(/settings\.update\(\s*'([^']+)'/g)].map((m) => m[1]); + assert.ok(namespaces.length > 0, 'the plugin persists through settings.update'); + for (const ns of namespaces) assert.equal(ns, 'usage', 'settings.update must target the row id from the patch'); +}); diff --git a/tests/peak.test.js b/tests/peak.test.js new file mode 100644 index 0000000..fc7874b --- /dev/null +++ b/tests/peak.test.js @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { PEAK_WINDOWS, beijingParts, inPeakWindow, isPeak } from '../src/host/peak.js'; + +/** Beijing wall-clock -> epoch ms. Written out so the tests do not share the code's own arithmetic. */ +function bj(date, time) { + const [y, m, d] = date.split('-').map(Number); + const [hh, mm] = time.split(':').map(Number); + return Date.UTC(y, m - 1, d, hh, mm) - 8 * 3_600_000; +} + +/** Holiday predicate over an explicit set of Beijing dates. */ +function holidays(...dates) { + const set = new Set(dates); + return (date) => set.has(date); +} + +test('peak windows are the two DeepSeek windows', () => { + assert.deepEqual(PEAK_WINDOWS, [ + [9, 12], + [14, 18], + ]); +}); + +test('beijingParts reads Beijing, not UTC', () => { + // 2026-10-19 00:30 UTC is 08:30 in Beijing on the same calendar day. + const parts = beijingParts(Date.UTC(2026, 9, 19, 0, 30)); + assert.equal(parts.date, '2026-10-19'); + assert.equal(parts.hour, 8); + assert.equal(parts.minute, 30); + assert.equal(parts.weekday, 1); // Monday + + // 2026-10-18 20:00 UTC is already Monday 04:00 in Beijing. + const rolled = beijingParts(Date.UTC(2026, 9, 18, 20, 0)); + assert.equal(rolled.date, '2026-10-19'); + assert.equal(rolled.hour, 4); +}); + +test('window boundary cases', () => { + const monday = '2026-10-19'; + assert.equal(isPeak(bj(monday, '08:59')), false, 'Monday 08:59 is off-peak'); + assert.equal(isPeak(bj(monday, '09:00')), true, 'Monday 09:00 is peak'); + assert.equal(isPeak(bj(monday, '11:59')), true, 'Monday 11:59 is peak'); + assert.equal(isPeak(bj(monday, '12:00')), false, 'Monday 12:00 (lunch) is off-peak'); + assert.equal(isPeak(bj(monday, '13:59')), false, 'Monday 13:59 (lunch) is off-peak'); + assert.equal(isPeak(bj(monday, '14:00')), true, 'Monday 14:00 is peak'); + assert.equal(isPeak(bj(monday, '17:59')), true, 'Monday 17:59 is peak'); + assert.equal(isPeak(bj(monday, '18:00')), false, 'Monday 18:00 is off-peak'); +}); + +test('the minute before a boundary is peak, the boundary itself is not', () => { + // 09:59:59 is inside; 10:00:00 is inside; 11:59:59.999 is inside; 12:00:00 is not. + const at = (time) => bj('2026-10-20', time); + assert.equal(isPeak(at('09:59')), true); + assert.equal(isPeak(at('11:59')), true); + assert.equal(isPeak(at('12:00')), false); + assert.equal(isPeak(at('17:59')), true); + assert.equal(isPeak(at('18:00')), false); +}); + +test('weekends are off-peak all day', () => { + assert.equal(isPeak(bj('2026-10-24', '10:00')), false, 'Saturday 10:00'); + assert.equal(isPeak(bj('2026-10-25', '15:00')), false, 'Sunday 15:00'); + assert.equal(isPeak(bj('2026-10-25', '09:30')), false, 'Sunday inside a window'); +}); + +test('Friday inside a window is peak', () => { + // 2026-10-23 is a Friday. + assert.equal(isPeak(bj('2026-10-23', '10:00')), true); + assert.equal(isPeak(bj('2026-10-23', '18:00')), false); +}); + +test('a public holiday is off-peak even inside a window on a weekday', () => { + // 2026-10-01 is a Thursday and a public holiday. + assert.equal(isPeak(bj('2026-10-01', '10:00'), holidays('2026-10-01')), false); + // The DEFAULT calendar is the shipped one: forgetting the argument must not + // silently bill a holiday at the peak rate. (It did: the fold called + // `isPeak()` bare while the price display used the calendar, so a National + // Day call was billed peak and shown off-peak.) + assert.equal(isPeak(bj('2026-10-01', '10:00')), false); + // An explicitly empty calendar is the only way to opt out. + assert.equal(isPeak(bj('2026-10-01', '10:00'), () => false), true); + // A neighbouring workday still bills peak. + assert.equal(isPeak(bj('2026-10-09', '10:00'), holidays('2026-10-01')), true); + assert.equal(isPeak(bj('2026-10-29', '10:00')), true, 'an ordinary weekday window is peak by default'); +}); + +test('a make-up Saturday stays off-peak', () => { + // 2026-02-14 is a Saturday declared a working day by the 调休 arrangement. + // The published rule names weekdays and carves out only public holidays, so a + // make-up workday is NOT peak. + assert.equal(isPeak(bj('2026-02-14', '10:00'), holidays()), false); +}); + +test('inPeakWindow ignores the calendar, isPeak does not', () => { + const saturday = bj('2026-10-24', '10:00'); + assert.equal(inPeakWindow(saturday), true); + assert.equal(isPeak(saturday), false); +}); + +test('a non-finite timestamp is never peak', () => { + assert.equal(isPeak(undefined), false); + assert.equal(isPeak(null), false); + assert.equal(isPeak(Number.NaN), false); + assert.equal(isPeak(Number.POSITIVE_INFINITY), false); +}); diff --git a/tests/price-data.test.js b/tests/price-data.test.js new file mode 100644 index 0000000..c675429 --- /dev/null +++ b/tests/price-data.test.js @@ -0,0 +1,201 @@ +/** + * Data-integrity gates for the generated price table and holiday calendar. + * + * These are not "does the generator run" tests — they are the gates that turn a + * silent pricing defect into a red build: + * + * - a parsing change that yields an empty or tiny table; + * - a price table that stopped being refreshed (the capturedAt date); + * - a DeepSeek entry that lost its peak/off-peak pair (the fallback would then + * price it at Zen's peak rate, silently doubling every estimate); + * - holiday data that silently shrank to nothing. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { REFERENCE_PRICES } from '../src/host/prices.js'; +import { PUBLIC_HOLIDAYS } from '../src/host/holidays.js'; +import { normalizeId } from '../src/host/pricing.js'; + +const DAY_MS = 86_400_000; + +test('every table id normalizes to a key no other id claims', () => { + // The lookup index is keyed by `normalizeId`, which unifies `.`/`-`, `_`, + // case, the provider prefix and a router `auto-` prefix. Those rules are only + // safe while they stay injective over the table: two rows collapsing into one + // key would price one model at the other's rate, depending on object order. + // This is the gate for that, and the reason the rules may be that broad. + const keys = new Map(); + for (const id of Object.keys(REFERENCE_PRICES.models)) { + const key = normalizeId(id); + assert.equal(keys.has(key), false, `${id} and ${keys.get(key)} both normalize to ${key}`); + keys.set(key, id); + } + assert.equal(keys.size, Object.keys(REFERENCE_PRICES.models).length); +}); + +test('every official DeepSeek model is tiered under its own id and its Zen twin', () => { + // Pinned roster on purpose: these are the ids the two tables name differently, + // and the failure this catches is invisible to a `source === 'deepseek'` scan — + // a row reverted to Zen's single price simply stops being sourced deepseek, so + // the tier test above would pass while every off-peak call was billed at double + // (that is exactly how `deepseek-v4-pro` sat uncovered). + for (const id of ['deepseek-flash', 'deepseek-v4.1-flash', 'deepseek-v4-pro']) { + const entry = REFERENCE_PRICES.models[id]; + assert.ok(entry, `${id} must be in the table`); + assert.equal(entry.source, 'deepseek', `${id} must carry the official two-tier row`); + assert.ok(entry.peak && entry.offPeak, `${id} must keep both tiers`); + assert.ok(entry.offPeak.input < entry.peak.input, `${id}: off-peak must be cheaper than peak`); + } + // The generator fetched this many DeepSeek rows; the table must carry at least + // that many deepseek-sourced entries (the Zen twin adds one). + const deepseek = REFERENCE_PRICES.sources.find((s) => s && s.name === 'DeepSeek'); + assert.ok(deepseek && Number.isInteger(deepseek.models) && deepseek.models >= 1, 'the source record must count its rows'); + const tiered = Object.values(REFERENCE_PRICES.models).filter((e) => e && e.source === 'deepseek').length; + assert.ok(tiered >= deepseek.models, `only ${tiered} deepseek rows for ${deepseek.models} fetched`); +}); + +/** Every usable price field of one model entry, as numbers. */ +function priceFields(entry) { + const tiers = entry.source === 'deepseek' ? [entry.peak, entry.offPeak] : [entry]; + const out = []; + for (const tier of tiers) { + assert.ok(tier && typeof tier === 'object', 'every price tier must be an object'); + for (const key of ['input', 'output', 'cacheRead', 'cacheWrite']) { + const value = tier[key]; + assert.ok( + value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0), + `${key} must be a finite non-negative number or null, got ${JSON.stringify(value)}`, + ); + if (typeof value === 'number') out.push(value); + } + } + return out; +} + +test('the table is non-trivial', () => { + const ids = Object.keys(REFERENCE_PRICES.models); + assert.ok(ids.length >= 80, `expected at least 80 models, got ${ids.length}`); + assert.equal(REFERENCE_PRICES.unit, 'USD per 1M tokens'); + assert.ok(Array.isArray(REFERENCE_PRICES.sources) && REFERENCE_PRICES.sources.length >= 2); +}); + +test('the table was captured recently', () => { + // 30 days: a stale table is a wrong table, and hiding that behind a green test + // is exactly the failure this file exists to prevent. + // capturedAt is a full ISO instant now (the dialog prints it to the second); a + // date-only value from an older table still parses as UTC midnight. + const raw = String(REFERENCE_PRICES.capturedAt || ''); + const captured = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw + 'T00:00:00Z' : raw); + assert.ok(Number.isFinite(captured), `capturedAt is not a date: ${REFERENCE_PRICES.capturedAt}`); + const ageDays = (Date.now() - captured) / DAY_MS; + assert.ok(ageDays <= 30, `price table is ${ageDays.toFixed(1)} days old — run scripts/gen-prices.mjs`); +}); + +test('every model carries at least one usable price', () => { + const empty = []; + for (const [id, entry] of Object.entries(REFERENCE_PRICES.models)) { + assert.ok(entry && typeof entry === 'object', `${id}: entry must be an object`); + assert.ok(entry.source === 'zen' || entry.source === 'deepseek', `${id}: unknown source ${entry.source}`); + if (priceFields(entry).length === 0) empty.push(id); + } + assert.deepEqual(empty, [], 'these models have no usable price field at all'); +}); + +test('DeepSeek models carry both tiers, and off-peak is never above peak', () => { + const deepseekIds = Object.entries(REFERENCE_PRICES.models) + .filter(([, entry]) => entry.source === 'deepseek') + .map(([id]) => id); + assert.ok(deepseekIds.length >= 2, `expected the DeepSeek models to be present, got ${deepseekIds.join(', ') || 'none'}`); + + for (const id of deepseekIds) { + const entry = REFERENCE_PRICES.models[id]; + assert.ok(entry.peak && entry.offPeak, `${id}: both peak and offPeak are required`); + for (const key of ['input', 'output', 'cacheRead']) { + const peak = entry.peak[key]; + const off = entry.offPeak[key]; + assert.ok(typeof peak === 'number' && peak > 0, `${id}.peak.${key} must be a positive number`); + assert.ok(typeof off === 'number' && off > 0, `${id}.offPeak.${key} must be a positive number`); + assert.ok(off <= peak, `${id}.${key}: off-peak (${off}) must not exceed peak (${peak})`); + } + } +}); + +test('deepseek-flash matches the published list price', () => { + // Pinned on purpose: this is the row every estimate leans on, and a silent + // change to it is indistinguishable from a parsing bug. + const flash = REFERENCE_PRICES.models['deepseek-flash']; + assert.ok(flash, 'deepseek-flash must be in the table'); + assert.equal(flash.source, 'deepseek'); + assert.deepEqual(flash.offPeak, { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: null }); + assert.deepEqual(flash.peak, { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: null }); +}); + +test('Zen rows that exist for a DeepSeek model are kept only as a fallback', () => { + for (const [id, entry] of Object.entries(REFERENCE_PRICES.models)) { + if (entry.source !== 'deepseek' || entry.fallback === undefined) continue; + assert.equal(entry.fallback.source, 'zen', `${id}: fallback must be the Zen row`); + assert.ok(entry.fallback.input > 0, `${id}: fallback must carry a price`); + } +}); + +test('the free-tier twins are zero-priced and their paid siblings are not', () => { + const free = Object.keys(REFERENCE_PRICES.models).filter((id) => id.endsWith('-free')); + assert.ok(free.length >= 10, `expected the free tier to be present, got ${free.length}`); + for (const id of free) { + const entry = REFERENCE_PRICES.models[id]; + const tiers = entry.source === 'deepseek' ? [entry.peak, entry.offPeak] : [entry]; + for (const tier of tiers) { + assert.equal(tier.input, 0, `${id}: a -free row must price at 0`); + } + } + // The paid twin of a promo twin must stay non-zero, otherwise `pricing.js` + // would "fix" a free lookup into another free row. That row is now a DeepSeek + // two-tier entry, so the non-zero price lives in `peak` / `offPeak`. + const paid = REFERENCE_PRICES.models['deepseek-v4.1-flash']; + assert.ok(paid, 'deepseek-v4.1-flash must be in the table'); + assert.ok(paid.peak.input > 0, 'the paid twin of deepseek-v4.1-flash-free must be non-zero'); +}); + +test('the holiday calendar has real data and reports its coverage', () => { + const dates = Object.keys(PUBLIC_HOLIDAYS.dates); + assert.ok(dates.length >= 20, `expected a real holiday calendar, got ${dates.length} dates`); + for (const date of dates) assert.match(date, /^\d{4}-\d{2}-\d{2}$/, `${date} is not a YYYY-MM-DD date`); + const years = Object.keys(PUBLIC_HOLIDAYS.years).map(Number).sort((a, b) => a - b); + assert.ok(years.length >= 1); + for (const year of years) { + const info = PUBLIC_HOLIDAYS.years[year]; + if (info.unpublished === true) { + // The State Council publishes next year's arrangement in November. Such a + // year must be RECORDED (the UI says so) rather than dropped — a dropped + // year is indistinguishable from "no holidays", which bills them peak. + assert.equal(info.offDays, 0, `${year} is unpublished, so it has no off days`); + assert.equal(info.last, null, `${year} is unpublished, so it has no last date`); + continue; + } + assert.ok(info.offDays > 0, `${year} has no off days`); + // Coverage metadata is what the UI shows when the calendar stops early. + assert.ok(info.last, `${year} must report the last covered date`); + } +}); + +test('the calendar spans every usage year plus one forward', () => { + const coverage = PUBLIC_HOLIDAYS.coverage; + assert.ok(coverage, 'the table must record the span it was fetched for'); + assert.ok(Number.isInteger(coverage.fromYear) && Number.isInteger(coverage.toYear)); + assert.ok(coverage.toYear > coverage.fromYear, 'a span, not a single year'); + assert.ok(Array.isArray(coverage.years) && coverage.years.length === coverage.toYear - coverage.fromYear + 1); + // Every year in the span is present, published or explicitly unpublished. + for (let year = coverage.fromYear; year <= coverage.toYear; year += 1) { + assert.ok(PUBLIC_HOLIDAYS.years[year], `${year} is in the span but missing from years`); + } + // The forward year exists: that is what keeps next January from being billed + // at the peak rate on a holiday. + assert.ok(PUBLIC_HOLIDAYS.years[coverage.toYear], 'the forward year must be present'); +}); + +test('a known public holiday is present and a known workday is not', () => { + assert.ok(PUBLIC_HOLIDAYS.dates['2026-10-01'], '2026-10-01 is a public holiday'); + assert.ok(PUBLIC_HOLIDAYS.dates['2026-02-17'], '2026 Spring Festival day is a public holiday'); + assert.equal(PUBLIC_HOLIDAYS.dates['2026-10-09'], undefined, '2026-10-09 is an ordinary workday'); +}); diff --git a/tests/pricing-rpc.test.js b/tests/pricing-rpc.test.js new file mode 100644 index 0000000..6e53105 --- /dev/null +++ b/tests/pricing-rpc.test.js @@ -0,0 +1,322 @@ +/** + * The `pricing` endpoint: propose matches, then commit what the user confirmed. + * + * The contract this file pins is "确定才保存": opening the dialog, or asking for a + * proposal, must not change a single price. Only `action: 'commit'` touches the + * persisted match map, and only rows that survive validation reach it — a bad id + * here would become a silent zero cost later, which is the failure mode the whole + * pricing rebuild exists to remove. + * + * A second contract lives here because the dialog depends on it and cannot fake + * it: `propose` must classify a model exactly the way `query` prices it. When it + * did not, every row in the dialog read 未匹配 while the dashboard happily billed + * those same calls — the answer was computed, then discarded, and the badge was + * derived from a field the proposal never set. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { apply } from '../src/host/index.js'; +import { costOf, referenceTable } from '../src/host/pricing.js'; + +/** + * A cordis-like host that mounts the plugin's Fetch routes and records the + * settings writes, so a test can assert both the answer and the side effects. + */ +function makeHost(config) { + const routes = new Map(); + const updates = []; + const ctx = { + get(name) { + if (name === 'settings') { + return { update: (id, patch) => { updates.push({ id, patch }); return Promise.resolve(patch); } }; + } + return undefined; + }, + interval() { return () => {}; }, + effect(fn) { return typeof fn === 'function' ? fn() : undefined; }, + inject(_deps, fn) { + fn({ + connection: { + fetch: { + register(route) { + routes.set(String(route.path).split('/').pop(), route.fetch); + }, + }, + }, + }); + return () => {}; + }, + }; + apply(ctx, config); + const post = async (endpoint, args) => { + const route = routes.get(endpoint); + assert.ok(route, `${endpoint} route was never mounted`); + const res = await route(new Request('http://host/api/usage/' + endpoint, { + method: 'POST', + body: JSON.stringify(args), + })); + const body = await res.json(); + assert.equal(body.ok, true, JSON.stringify(body.error || null)); + return body.value; + }; + return { post, updates, routes }; +} + +const FLASH = { key: 'deepseek-account/deepseek-flash', provider: 'deepseek-account', model: 'deepseek-flash' }; +const ODD = { key: 'relay/odd-model', provider: 'relay', model: 'odd-model' }; + +test('propose reports the ladder level for every model and writes nothing', async () => { + const host = makeHost({}); + const value = await host.post('pricing', { + action: 'propose', + models: [FLASH, { key: 'stepfun/step-5-preview', provider: 'stepfun', model: 'step-5-preview' }], + }); + + const flash = value.rows.find((r) => r.model === 'deepseek-flash'); + assert.equal(flash.how, 'normalized'); + assert.equal(flash.state, 'exact'); + assert.equal(flash.target, 'deepseek-flash'); + assert.ok(flash.rates && flash.rates.peak.input > 0); + // Both tiers travel; the client prints the one the host calls current. + assert.equal(flash.rates.offPeak.input, 0.15); + assert.ok(flash.tier === 'peak' || flash.tier === 'off'); + + const step = value.rows.find((r) => r.model === 'step-5-preview'); + assert.equal(step.how, 'unmatched'); + assert.equal(step.state, 'none'); + assert.equal(step.source, null); + assert.equal(step.rates, null); + + // The reference ids travel grouped by kind, so the dialog needs no second trip + // and no second source of truth about where an id comes from. Two groups: a + // custom price is an OPTION in the dialog, not a list of ids. + assert.deepEqual(value.table.groups.map((g) => g.source), ['deepseek', 'zen']); + const deepseek = value.table.groups.find((g) => g.source === 'deepseek').ids; + const zen = value.table.groups.find((g) => g.source === 'zen').ids; + assert.ok(deepseek.includes('deepseek-v4.1-flash'), 'the official row is a DeepSeek reference'); + assert.ok(zen.includes('glm-5.3-flash'), 'a Zen-only row is a Zen reference'); + assert.equal(value.table.capturedAt, referenceTable().capturedAt); + assert.deepEqual(host.updates, [], 'propose must not write the persisted config'); +}); + +test('propose classifies a literal hit instead of dropping to unmatched', async () => { + // These are the ids the dialog showed as 未匹配 in the report that opened this + // bug: every one of them is a literal row of the shipped table. + const host = makeHost({}); + const wanted = [ + { key: 'commandcode-plan/deepseek-v4.1-flash', provider: 'commandcode-plan', model: 'deepseek-v4.1-flash' }, + { key: 'coding-plan/glm-5.3-flash', provider: 'coding-plan', model: 'glm-5.3-flash' }, + { key: 'qwen-token-plan-cn/qwen3.8-flash', provider: 'qwen-token-plan-cn', model: 'qwen3.8-flash' }, + ]; + const value = await host.post('pricing', { action: 'propose', models: wanted }); + for (const row of value.rows) { + assert.equal(row.how, 'normalized', `${row.key} is a literal table row and must classify as a literal hit`); + assert.equal(row.state, 'exact', `${row.key} must show as 精确, not 未匹配`); + assert.equal(row.target, row.model); + assert.ok(row.rates !== null, `${row.key} must carry the price the query would bill`); + } +}); + +test('every proposed row carries the price and the unit the card is priced in', async () => { + const host = makeHost({}); + const value = await host.post('pricing', { + action: 'propose', + models: [FLASH, { key: 'commandcode-plan/deepseek-v4.1-flash', provider: 'commandcode-plan', model: 'deepseek-v4.1-flash' }], + }); + for (const row of value.rows) { + if (row.rates === null) continue; + for (const tier of ['peak', 'offPeak']) { + // The dialog prints these three fields; the card bills the same three. + for (const field of ['input', 'output', 'cacheRead']) { + assert.equal(typeof row.rates[tier][field], 'number', `${row.key} is missing rates.${tier}.${field}`); + } + } + assert.ok(row.rates.peak.input > 0); + } + assert.equal(value.table.unit, referenceTable().unit, 'the dialog labels the column with the table unit'); +}); + +test('a proposal never disagrees with the bill the dashboard shows', async () => { + // The same record, priced through the dialog's path and through the query's + // path, must agree. They used different shapes for the same number before. + const host = makeHost({}); + const atMs = Date.UTC(2026, 9, 19, 12, 0); // 20:00 Beijing: off-peak + const value = await host.post('pricing', { + action: 'propose', + models: [{ key: 'commandcode-plan/deepseek-v4.1-flash', provider: 'commandcode-plan', model: 'deepseek-v4.1-flash' }], + }); + const row = value.rows[0]; + const record = { + provider: 'commandcode-plan', + model: 'deepseek-v4.1-flash', + time: atMs, + tokens: { off: { inputTokens: 1e6, outputTokens: 0, cacheReadTokens: 0 } }, + }; + // The proposal's two tiers, fed back into the bill, must produce the off-peak + // input rate for one million tokens — the rate the row itself prints. + assert.equal(costOf(record, { peak: row.rates.peak, offPeak: row.rates.offPeak }), row.rates.offPeak.input); +}); + +test('commit validates every row, persists the good ones and reports the rest', async () => { + const host = makeHost({}); + const value = await host.post('pricing', { + action: 'commit', + decisions: [ + { key: 'relay/odd-model', target: 'glm-5.3-flash' }, + { key: 'relay/bad-model', target: 'not-a-real-id' }, + { key: '', target: 'glm-5.3-flash' }, + ], + }); + + assert.equal(value.applied, 1); + assert.equal(value.rejected.length, 2, 'an unknown target and a missing key are both refused'); + assert.equal(value.matchMap['relay/odd-model'], 'glm-5.3-flash'); + assert.equal(value.matchMap['relay/bad-model'], undefined); + assert.equal(value.matchMap[''], undefined); + + const write = host.updates.find((u) => u.patch && u.patch.matchMap); + assert.ok(write, 'a successful commit must write the config'); + assert.equal(write.id, 'usage'); + assert.equal(write.patch.matchMap['relay/odd-model'], 'glm-5.3-flash'); +}); + +test('a confirmed match outranks the ladder, and clearing restores the ladder', async () => { + const host = makeHost({ matchMap: { 'relay/odd-model': 'glm-5.3-flash' } }); + + const confirmed = await host.post('pricing', { action: 'propose', models: [ODD] }); + const row = confirmed.rows[0]; + assert.equal(row.state, 'custom'); + assert.equal(row.how, 'confirmed'); + assert.equal(row.target, 'glm-5.3-flash'); + assert.ok(row.rates); + + const cleared = await host.post('pricing', { action: 'commit', cleared: ['relay/odd-model'] }); + assert.equal(cleared.cleared, 1); + assert.equal(cleared.matchMap['relay/odd-model'], undefined); + + const back = await host.post('pricing', { action: 'propose', models: [ODD] }); + assert.equal(back.rows[0].state, 'none'); + assert.equal(back.rows[0].how, 'unmatched', 'without the confirmation the odd id is unpriced again'); +}); + +test('an empty commit is a no-op rather than a wipe', async () => { + const host = makeHost({ matchMap: { 'relay/odd-model': 'glm-5.3-flash' } }); + const value = await host.post('pricing', { action: 'commit' }); + assert.equal(value.applied, 0); + assert.equal(value.cleared, 0); + assert.equal(value.matchMap['relay/odd-model'], 'glm-5.3-flash'); + assert.deepEqual(host.updates, [], 'an empty commit must not write'); +}); + +test('a confirmed target that left the table is reported, not silently repriced', async () => { + const host = makeHost({ matchMap: { 'relay/odd-model': 'retired-model-id' } }); + const value = await host.post('pricing', { action: 'propose', models: [ODD] }); + const row = value.rows[0]; + assert.equal(row.how, 'unmatched'); + assert.equal(row.state, 'none'); + assert.equal(row.target, 'retired-model-id'); + assert.equal(row.rates, null); +}); + +test('an explicit price still wins over a confirmed match', async () => { + const host = makeHost({ + matchMap: { 'relay/odd-model': 'glm-5.3-flash' }, + prices: { 'relay/odd-model': { inputPerM: 1, outputPerM: 2, cacheReadPerM: 0, cacheWritePerM: 0 } }, + }); + const value = await host.post('pricing', { action: 'propose', models: [ODD] }); + assert.equal(value.rows[0].how, 'custom'); + assert.equal(value.rows[0].state, 'custom'); + assert.equal(value.rows[0].target, null, 'a hand-entered price has no reference model'); + // Canonical on the wire, even though the config row is spelled with `PerM`. + assert.deepEqual(value.rows[0].rates.peak, { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }); + assert.deepEqual(value.rows[0].rates.offPeak, value.rows[0].rates.peak, 'a hand-entered price has one tier'); +}); + +test('prices persistence keeps working', async () => { + const host = makeHost({}); + const value = await host.post('prices', { prices: { 'relay/odd-model': { inputPerM: 2, outputPerM: 4, cacheReadPerM: 0.2, cacheWritePerM: 0 } } }); + assert.ok(value.prices['relay/odd-model']); + const write = host.updates.find((u) => u.patch && u.patch.prices); + assert.ok(write, 'the prices endpoint still persists'); + // The config still speaks `inputPerM`; the store speaks canonical fields. + assert.equal(write.patch.prices['relay/odd-model'].inputPerM, 2); + assert.equal(write.patch.prices['relay/odd-model'].input, undefined); +}); + +test('the 自定义 option stores three hand-typed rates for one model key', async () => { + // The dialog's 自定义 is not a reference id: it is three numbers the user types + // for THIS model. They arrive in the display currency (¥, 7 per USD) and are + // stored USD-denominated, because the whole table is USD. + const host = makeHost({}); + const committed = await host.post('pricing', { + action: 'commit', + currency: 'CNY', + customs: [{ key: 'relay/odd-model', input: 7, output: 14, cacheRead: 0.7 }], + }); + assert.equal(committed.custom, 1, 'one hand-typed price applied'); + assert.deepEqual(committed.rejected, []); + + const write = host.updates.find((u) => u.patch && u.patch.prices); + assert.ok(write, 'the typed price is persisted'); + assert.deepEqual(write.patch.prices['relay/odd-model'], { + inputPerM: 1, outputPerM: 2, cacheReadPerM: 0.1, cacheWritePerM: 0, + }, 'stored in USD, in the config\'s spelling'); + + // …and the row is now priced from it, both tiers, as a custom reference. + const after = await host.post('pricing', { action: 'propose', models: [ODD] }); + const row = after.rows[0]; + assert.equal(row.how, 'custom'); + assert.equal(row.state, 'custom'); + assert.equal(row.source, 'custom'); + assert.deepEqual(row.rates.peak, { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }); + assert.deepEqual(row.rates.offPeak, row.rates.peak, 'a hand-typed price has one tier'); +}); + +test('a hand-typed price outranks a confirmed match, and clearing drops both', async () => { + const host = makeHost({ matchMap: { 'relay/odd-model': 'glm-5.3-flash' } }); + await host.post('pricing', { + action: 'commit', + currency: 'USD', + customs: [{ key: 'relay/odd-model', input: 9, output: 9, cacheRead: 9 }], + }); + const typed = await host.post('pricing', { action: 'propose', models: [ODD] }); + assert.equal(typed.rows[0].how, 'custom', 'the typed price wins (the ladder checks it first)'); + assert.equal(typed.rows[0].rates.peak.input, 9); + + const cleared = await host.post('pricing', { action: 'commit', cleared: ['relay/odd-model'] }); + assert.equal(cleared.cleared, 1); + assert.equal(cleared.prices['relay/odd-model'], undefined, 'clearing drops the typed price too'); + const back = await host.post('pricing', { action: 'propose', models: [ODD] }); + assert.equal(back.rows[0].state, 'none'); +}); + +test('typing a price takes the key out of the confirmed-match map', async () => { + // One key, one answer: a typed price and a confirmed target would both sit + // there, and the ladder would silently prefer the typed one while the dialog's + // history drawer still showed a match that no longer applies. + const host = makeHost({ matchMap: { 'relay/odd-model': 'glm-5.3-flash' } }); + const value = await host.post('pricing', { + action: 'commit', + currency: 'USD', + customs: [{ key: 'relay/odd-model', input: 1, output: 1, cacheRead: 0 }], + }); + assert.equal(value.custom, 1); + assert.equal(value.matchMap['relay/odd-model'], undefined, 'the stale match is dropped'); +}); + +test('a malformed hand-typed price is refused, not stored as zero', async () => { + const host = makeHost({}); + const value = await host.post('pricing', { + action: 'commit', + currency: 'CNY', + customs: [ + { key: 'relay/a', input: -1, output: 1, cacheRead: 0 }, + { key: 'relay/b', input: 'abc', output: 1, cacheRead: 0 }, + { key: '', input: 1, output: 1, cacheRead: 0 }, + ], + }); + assert.equal(value.custom, 0); + assert.equal(value.rejected.length, 3); + assert.deepEqual(host.updates, [], 'a rejected row must not write the config'); +}); diff --git a/tests/pricing.test.js b/tests/pricing.test.js new file mode 100644 index 0000000..7d28ee7 --- /dev/null +++ b/tests/pricing.test.js @@ -0,0 +1,317 @@ +/** + * Resolution-ladder tests for the reference price lookup. + * + * The cases below are not invented: the model ids are the ones this machine's + * session logs actually recorded, and the expected costs are what the harness's + * own billing reports for the same records. A change that breaks any of them + * changes what the dashboard claims the user spent. + * + * Two contracts are pinned beyond the ladder itself: + * - `resolvePrice` returns the row AS PUBLISHED and never a pre-selected tier; + * the tier belongs to the billing site, so `ratesOf(price, tier)` is how a + * test asks "what would a peak call cost". + * - `state` (exact / fuzzy / none / custom) is derived here, once. The client + * renders it and never re-derives it from `how`. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + ALIASES, + costOf, + holidayOfDefault, + normalizeId, + priceRow, + ratesOf, + referenceGroups, + referenceTable, + resolvePrice, +} from '../src/host/pricing.js'; + +const TABLE = referenceTable(); + +test('normalizeId lowercases, drops the provider prefix and unifies separators', () => { + assert.equal(normalizeId('DeepSeek/DeepSeek-V4.1-Flash'), 'deepseek-v4-1-flash'); + assert.equal(normalizeId('deepseek/deepseek-v4.1_flash'), 'deepseek-v4-1-flash'); + assert.equal(normalizeId(' deepseek-flash '), 'deepseek-flash'); + assert.equal(normalizeId(''), ''); + assert.equal(normalizeId(null), ''); +}); + +test('normalizeId drops the router prefix DSH itself reports', () => { + // `group/auto-deepseek-v4-1-flash` is what the magpie router records. Without + // this rule the id needed its own hand-copied row in the config table, which + // then outranked the reference table and never followed the peak tiers. + assert.equal(normalizeId('group/auto-deepseek-v4-1-flash'), 'deepseek-v4-1-flash'); + assert.equal(normalizeId('magpie/auto-glm-5.3-flash'), 'glm-5-3-flash'); +}); + +test('normalizeId keeps version suffixes that are part of the id', () => { + // Only the separator style is unified; a version or a published date is not + // stripped, because `deepseek-v4-flash-0731` is a different generation. + assert.equal(normalizeId('deepseek-v4-flash-latest'), 'deepseek-v4-flash-latest'); + assert.equal(normalizeId('deepseek-v4.1-flash-1017'), 'deepseek-v4-1-flash-1017'); +}); + +test('level 2: the alias table bridges the vendor name to the table id', () => { + const hit = resolvePrice({ model: 'deepseek-chat' }); + assert.equal(hit.how, 'alias'); + assert.equal(hit.state, 'fuzzy'); + assert.equal(hit.target, 'deepseek-v4.1-flash'); + assert.equal(hit.why, ALIASES['deepseek-chat'].why); +}); + +test('the live Flash name is a literal hit, so it keeps its two-tier price', () => { + // It used to alias onto the Zen id; the table now carries the official + // DeepSeek row under this very id, and aliasing away from it would hide the + // off-peak half of the price. + const hit = resolvePrice({ model: 'deepseek-flash' }); + assert.equal(hit.how, 'normalized'); + assert.equal(hit.target, 'deepseek-flash'); + assert.equal(ratesOf(hit.price, 'peak').input, 0.3); + assert.equal(ratesOf(hit.price, 'off').input, 0.15); +}); + +test('level 2: every retired name still resolves', () => { + for (const model of ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4-flash']) { + const hit = resolvePrice({ model }); + assert.equal(hit.how, 'alias', `${model} should resolve through the alias table`); + assert.ok(ratesOf(hit.price, 'peak').output > 0, `${model} should carry a price`); + } +}); + +test('level 3: a normalized literal match needs no alias', () => { + const cases = [ + 'deepseek-v4.1-flash', + 'deepseek/deepseek-v4.1-flash', + 'group/auto-deepseek-v4-1-flash', + 'glm-5.3-flash', + 'qwen3.8-flash', + 'claude-opus-5-5', + ]; + for (const model of cases) { + const hit = resolvePrice({ model }); + assert.equal(hit.how, 'normalized', `${model} should resolve literally`); + assert.ok(ratesOf(hit.price, 'peak').input !== null, `${model} should carry an input price`); + } +}); + +test('level 1: a user price wins over every other level', () => { + const custom = { 'acme/deepseek-flash': { input: 9, output: 8, cacheRead: 7, cacheWrite: 6 } }; + const composite = resolvePrice({ model: 'deepseek-flash', provider: 'acme', custom }); + assert.equal(composite.how, 'custom'); + assert.equal(composite.state, 'custom'); + assert.equal(ratesOf(composite.price, 'peak').input, 9); + + const bare = resolvePrice({ model: 'glm-5.3-flash', provider: 'acme', custom: { 'glm-5.3-flash': { input: 1 } } }); + assert.equal(bare.how, 'custom', 'a bare-model custom entry also wins'); + assert.equal(ratesOf(bare.price, 'peak').input, 1); +}); + +test('a -free id the table publishes is exact and keeps its published price', () => { + // The rule: a `-free` id is fuzzy only when it is NOT an exact match. Zen + // publishes these rows as free, so the reference cost of a call under them is + // 0 — which is also what the harness charged. Pricing them at the paid + // sibling (the old order) invented a cost nobody paid. + const free = Object.keys(TABLE.models).filter((id) => /-free$/.test(id)); + assert.ok(free.length > 0, 'the table has no -free row, so this rule cannot be exercised'); + for (const id of free) { + const hit = resolvePrice({ model: id }); + assert.equal(hit.how, 'normalized', `${id} is a published row and must be an exact hit`); + assert.equal(hit.state, 'exact'); + assert.equal(hit.target, id); + assert.equal(ratesOf(hit.price, 'peak').input, 0, `${id} is published as free`); + } +}); + +test('a variant name the table does not publish resolves to its base as fuzzy', () => { + // The report that produced this test: `deepseek-v4.1-flash-fast` and + // `deepseek-v4.1-flash-1017` sat in 未匹配 (1,851 calls between them) although + // both are the same model under a variant suffix. The suffix list is closed — + // free/exp/fast/latest and a four-digit publish date — and the base has to be a + // real row, so this is a rename rule, not a similarity score. + const cases = [ + ['deepseek-v4.1-flash-fast', 'deepseek-v4.1-flash'], + ['deepseek-v4.1-flash-1017', 'deepseek-v4.1-flash'], + ['glm-5.3-flash-exp', 'glm-5.3-flash'], + ['deepseek-v4.1-flash-exp', 'deepseek-v4.1-flash'], + ]; + for (const [id, base] of cases) { + const hit = resolvePrice({ model: id }); + assert.equal(hit.how, 'variant', `${id} must resolve through the variant rung`); + assert.equal(hit.state, 'fuzzy'); + assert.equal(hit.target, base); + assert.ok(TABLE.models[hit.target] !== undefined, `${id} must point at a table row`); + assert.ok(ratesOf(hit.price, 'peak').input > 0, `${id} is billed at the base row`); + assert.ok(hit.why.includes(base), 'the reason names the base it inferred'); + } + // Billed at the base row's rate, not at zero and not at some other model's. + const fast = resolvePrice({ model: 'deepseek-v4.1-flash-fast' }); + const base = resolvePrice({ model: 'deepseek-v4.1-flash' }); + assert.deepEqual(fast.price, base.price); +}); + +test('a variant suffix on an unknown base stays unmatched', () => { + // The base must exist: `deepseek-v4-flash-0731` has no `deepseek-v4-flash-0731` + // row and no `deepseek-v4-flash` row either, so it must NOT collapse onto the + // nearest thing. This is the line between a rename rule and resemblance. + for (const id of ['deepseek-v4-flash-0731', 'step-5-preview-1017', 'mock-flash-fast']) { + assert.equal(resolvePrice({ model: id }).how, 'unmatched', `${id} has no base row`); + } +}); + +test('the literal level runs before the inferred ones', () => { + // `glm-5.3-flash-free` is both a literal table row and a variant candidate (its + // base is priced). Literal wins: the table says what this id costs, and an + // inference may not overrule a fact. + const literal = resolvePrice({ model: 'glm-5.3-flash-free' }); + assert.equal(literal.how, 'normalized'); + assert.equal(literal.state, 'exact'); + assert.equal(ratesOf(literal.price, 'peak').input, 0); + // The same model without the promo suffix is its own row, priced normally. + const paid = resolvePrice({ model: 'glm-5.3-flash' }); + assert.equal(paid.how, 'normalized'); + assert.ok(ratesOf(paid.price, 'peak').input > 0); +}); + +test('unmatched ids are reported, never guessed', () => { + for (const model of ['step-5-preview', 'mock-flash', 'default', '', 'deepseek-v4-flash-0731']) { + const hit = resolvePrice({ model }); + assert.equal(hit.how, 'unmatched', `${model || '(empty)'} must stay unmatched`); + assert.equal(hit.state, 'none'); + assert.equal(hit.price, null); + assert.equal(hit.target, null); + assert.equal(hit.source, null); + } +}); + +test('every table id resolves exactly, and every alias is fuzzy', () => { + // The exhaustive gate: the table is generated, so a row that the ladder cannot + // reach is a data bug nobody would see until a cost came out zero. `state` is + // the value the UI shows, so this is also the gate for the four-value + // vocabulary the client renders. + for (const id of Object.keys(TABLE.models)) { + const hit = resolvePrice({ model: id }); + assert.equal(hit.how, 'normalized', `${id} must be reachable by its own id`); + assert.equal(hit.state, 'exact'); + assert.equal(hit.target, id); + assert.ok(hit.price !== null, `${id} must carry a price`); + } + for (const key of Object.keys(ALIASES)) { + const hit = resolvePrice({ model: key }); + assert.equal(hit.how, 'alias', `${key} must resolve through the alias table`); + assert.equal(hit.state, 'fuzzy'); + } +}); + +test('state is one of exactly four values', () => { + const seen = new Set(); + for (const id of [...Object.keys(TABLE.models), ...Object.keys(ALIASES), 'no-such-model']) { + seen.add(resolvePrice({ model: id }).state); + } + seen.add(resolvePrice({ model: 'deepseek-flash', custom: { 'deepseek-flash': { input: 1 } } }).state); + seen.add(resolvePrice({ model: 'odd', matches: { odd: 'deepseek-flash' } }).state); + assert.deepEqual([...seen].sort(), ['custom', 'exact', 'fuzzy', 'none']); +}); + +test('every hit names one of the three kinds of reference id', () => { + // The dialog groups reference ids by origin and labels the row it matched. + // 'custom' is the user's own price row — a kind that used to be unreachable. + assert.equal(resolvePrice({ model: 'deepseek-flash' }).source, 'deepseek'); + assert.equal(resolvePrice({ model: 'glm-5.3-flash' }).source, 'zen'); + assert.equal(resolvePrice({ model: 'odd', custom: { odd: { input: 1 } } }).source, 'custom'); + assert.equal(resolvePrice({ model: 'no-such-model' }).source, null); + // An alias reports where its target lives, not the fact that it was an alias. + assert.equal(resolvePrice({ model: 'deepseek-chat' }).source, 'deepseek'); +}); + +test('reference ids come in exactly two groups', () => { + // 'custom' is NOT a group: in the dialog it is one option ("type your own + // price"), because a hand-entered price is three numbers for one model key, + // not a reference model anyone could point another id at. + const groups = referenceGroups(); + assert.deepEqual(groups.map((g) => g.source), ['deepseek', 'zen']); + const ids = groups.map((g) => g.ids); + assert.ok(ids[0].length >= 2 && ids[1].length >= 80, 'DeepSeek and Zen rows are both listed'); + // A table id never shows up in two groups. + const all = groups.flatMap((g) => g.ids); + assert.equal(new Set(all).size, all.length); + assert.equal(all.length, Object.keys(TABLE.models).length, 'every table row is offered exactly once'); + for (const group of groups) { + assert.deepEqual(group.ids, [...group.ids].sort(), 'each group is stable-sorted'); + } +}); + + +test('holidayOfDefault reads the generated calendar', () => { + assert.equal(holidayOfDefault('2026-10-01'), true, '2026-10-01 is a public holiday'); + assert.equal(holidayOfDefault('2026-10-09'), false, '2026-10-09 is an ordinary workday'); + assert.equal(holidayOfDefault(undefined), false); +}); + +test('priceRow carries both tiers and the instant own tier', () => { + // 2026-10-19 10:00 Beijing (peak window) and 20:00 (off-peak). + const peakAt = Date.UTC(2026, 9, 19, 2, 0); + const offAt = Date.UTC(2026, 9, 19, 12, 0); + const hit = resolvePrice({ model: 'deepseek-flash' }); + const atPeak = priceRow(hit, peakAt); + assert.equal(atPeak.tier, 'peak'); + assert.equal(atPeak.rates.peak.input, 0.3); + assert.equal(atPeak.rates.offPeak.input, 0.15); + const atNight = priceRow(hit, offAt); + assert.equal(atNight.tier, 'off'); + assert.equal(atNight.rates.peak.input, 0.3, 'both tiers travel, whichever is current'); + assert.equal(priceRow(resolvePrice({ model: 'no-such-model' }), peakAt).rates, null); +}); + +/** + * The cost arithmetic, pinned against the harness's own billing. + * + * Record: 69 sessions of deepseek-flash, aggregated usage + * input 27849720, output 6841763, cacheRead 2061403776. + * The harness reports 14.923583058 for those tokens. That number is NOT what + * this plugin should produce: the harness bills the instant of each call (mostly + * off-peak), while a reference cost applies one tier. This test pins the + * arithmetic on one tier — off-peak reproduces 14.4667, peak reproduces 28.9335 — + * and leaves the scope difference to the UI, which says so. + */ +test('costOf multiplies each token bucket by its own rate', () => { + const usage = { inputTokens: 27849720, outputTokens: 6841763, cacheReadTokens: 2061403776, cacheWriteTokens: 0 }; + const off = { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }; + const peak = { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }; + // Tolerance, not equality: the harness multiplies in its own order and the last + // bits differ (6.9e-18 on a single call). One nanodollar is a million times + // smaller than a cent and still catches a wrong factor. + assert.ok(Math.abs(costOf(usage, off) - 14.466727128) < 1e-9, String(costOf(usage, off))); + assert.ok(Math.abs(costOf(usage, peak) - 28.933454256) < 1e-9, String(costOf(usage, peak))); +}); + +test('costOf reproduces a single-call sample', () => { + // input 50681, output 22231, cacheRead 1983104 at the published off-peak rate. + // The harness stored 0.026890061999999992 for exactly this call. + const record = { inputTokens: 50681, outputTokens: 22231, cacheReadTokens: 1983104, cacheWriteTokens: 0 }; + const off = { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }; + assert.ok(Math.abs(costOf(record, off) - 0.026890061999999992) < 1e-15, String(costOf(record, off))); +}); + +test('costOf splits peak and off-peak buckets per record', () => { + const record = { + tokens: { + peak: { inputTokens: 1000000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + off: { inputTokens: 1000000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + }; + const price = { peak: { input: 0.3, output: 1.2, cacheRead: 0.006 }, offPeak: { input: 0.15, output: 0.6, cacheRead: 0.003 } }; + assert.ok(Math.abs(costOf(record, price) - 0.45) < 1e-12); +}); + +test('costOf never throws on a missing record or price', () => { + assert.equal(costOf(null, { input: 1 }), 0); + assert.equal(costOf({ inputTokens: 100 }, null), 0); + assert.equal(costOf({}, {}), 0); +}); + +test('the table the resolver reads is the generated one', () => { + assert.ok(TABLE.models['deepseek-v4.1-flash'], 'the official Flash row must be present'); + assert.ok(Object.keys(TABLE.models).length >= 80); +}); diff --git a/tests/query.test.js b/tests/query.test.js index d37b526..947968c 100644 --- a/tests/query.test.js +++ b/tests/query.test.js @@ -219,7 +219,7 @@ test('a currency query never rewrites the price table', () => { const state = makeState(); const args = { from: Date.UTC(2025, 0, 1), to: Date.UTC(2025, 1, 1) }; query(state, { ...args, currency: 'CNY' }); - assert.equal(state.prices.priceOf({ provider: 'anthropic', model: 'claude' }).inputPerM, 1); + assert.equal(state.prices.priceOf({ provider: 'anthropic', model: 'claude' }).input, 1); // A later USD query must still see the original USD numbers. assert.equal(query(state, { ...args, currency: 'USD' }).total.cost, 0.0105); }); diff --git a/tests/rpc.test.js b/tests/rpc.test.js index 86d9c29..a6e3a8b 100644 --- a/tests/rpc.test.js +++ b/tests/rpc.test.js @@ -11,6 +11,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + INTERNAL_MESSAGE, USAGE_API_PREFIX, USAGE_RPC_CHANNEL, createFetchRoute, @@ -51,19 +52,36 @@ function fakeConnection(overrides = {}) { } /** Fake host context: `inject` runs the callback immediately, like a live fiber. */ -function fakeCtx(connection, { typert } = {}) { +function fakeCtx(connection, { typert, logger = true, noProvide = false } = {}) { const seen = {}; - return { + const logs = []; + const recorder = logger === false ? undefined : { + warn: (message) => logs.push({ level: 'warn', message }), + error: (message) => logs.push({ level: 'error', message }), + }; + const provided = []; + const ctx = { seen, + connection, + logs, + provided, get(name) { - return name === 'typertGateway' ? typert : undefined; + if (name === 'typert') return typert; + if (name === 'logger') return recorder; + return undefined; }, + provide: noProvide ? undefined : (name, value) => { provided.push({ name, value }); return () => {}; }, inject(deps, callback) { + // Declared dependencies fire synchronously in this fake; the child ctx + // sees the same services as the parent (which is what cordis does for an + // active service), so one recorder is enough. seen.deps = deps; + seen.depsHistory = (seen.depsHistory || []).concat([deps]); seen.connectionCtx = { connection }; - callback(seen.connectionCtx); + callback(ctx); }, }; + return ctx; } /** Capture console.warn while a block runs. */ @@ -83,7 +101,7 @@ test('fetchRoutePath keeps every route below Connection\'s /api mount', () => { assert.equal(fetchRoutePath('query'), USAGE_API_PREFIX + '/query'); assert.equal(fetchRoutePath('query'), '/api/usage/query'); // connection.fetch.register() rejects any route outside /api. - assert.match(fetchRoutePath('client-source'), /^\/api\/usage\/[A-Za-z0-9_$.-]+$/); + assert.match(fetchRoutePath('pricing'), /^\/api\/usage\/[A-Za-z0-9_$.-]+$/); }); test('registerRPC mounts one POST Fetch route per endpoint (0.2.x transport)', async () => { @@ -120,13 +138,14 @@ test('registerRPC survives a throwing legacy registration when Fetch routes moun }, }, }); - const warnings = await captureWarnings(async () => { - registerRPC(fakeCtx(connection.value), HANDLERS); + const ctx = fakeCtx(connection.value); + await captureWarnings(async () => { + registerRPC(ctx, HANDLERS); }); assert.equal(connection.routes.length, 2); - // A mounted Fetch registry already carries the channel: stay quiet. - assert.deepEqual(warnings, []); + // A mounted Fetch registry already carries the channel: no mount failure. + assert.deepEqual(ctx.logs.filter((line) => /failed to mount/.test(line.message)), []); }); test('registerRPC warns when no transport could be mounted at all', async () => { @@ -138,11 +157,29 @@ test('registerRPC warns when no transport could be mounted at all', async () => }, }, }); + const ctx = fakeCtx(connection.value); const warnings = await captureWarnings(async () => { - registerRPC(fakeCtx(connection.value), HANDLERS); + registerRPC(ctx, HANDLERS); + }); + // The native channel carries it: nothing is written to the console. + assert.deepEqual(warnings, []); + const failures = ctx.logs.filter((line) => /failed to mount/.test(line.message)); + assert.equal(failures.length, 1); + assert.match(failures[0].message, /failed to mount \/usage RPC channel/); +}); + +test('without a logger the mount failure still reaches the console', async () => { + // A composition with no logger must not swallow the failure: the fallback + // exists so a missing service is visible, not so it is silent. + const connection = fakeConnection({ + fetch: undefined, + rpc: { handle() { throw new Error('no webServer'); } }, + }); + const warnings = await captureWarnings(async () => { + registerRPC(fakeCtx(connection.value, { logger: false }), HANDLERS); }); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /failed to mount \/usage RPC channel/); + const failures = warnings.filter((line) => /failed to mount/.test(line)); + assert.equal(failures.length, 1); }); test('fetch route decodes JSON args and returns the RPC result envelope', async () => { @@ -183,10 +220,15 @@ test('dispatch converts unknown endpoints and thrown handlers into results', asy assert.deepEqual(await dispatch(HANDLERS, 'nope', null), errorResult('unknown endpoint: nope', 'bad-request')); - const body = await dispatch(throwing, 'boom', null); + const logged = []; + const body = await dispatch(throwing, 'boom', null, (message) => logged.push(message)); assert.equal(body.ok, false); assert.equal(body.error.code, 'internal'); - assert.equal(body.error.message, 'handler exploded'); + // The page gets a stable, safe line; the raw error goes to the host log. + assert.equal(body.error.message, INTERNAL_MESSAGE); + assert.ok(!body.error.message.includes('handler exploded'), 'the raw error must not reach the caller'); + assert.equal(logged.length, 1); + assert.ok(logged[0].includes('handler exploded'), logged[0]); }); test('a dynamic-plugin run keeps using harness.handle', async () => { @@ -202,15 +244,40 @@ test('a dynamic-plugin run keeps using harness.handle', async () => { } }); -test('a Typert gateway exposes the channel without touching Connection', async () => { +test('the plugin joins the native typert Remote channel when the host has one', async () => { const registered = []; - const typert = { register: (channel, endpoints) => registered.push({ channel, endpoints }) }; + const typert = { register: (contribution) => { registered.push(contribution); return () => {}; } }; const connection = fakeConnection(); - registerRPC(fakeCtx(connection.value, { typert }), HANDLERS); + const ctx = fakeCtx(connection.value, { typert }); + registerRPC(ctx, HANDLERS); + + // The receiver service is what the gateway resolves descriptor.service on. + assert.equal(ctx.provided.length, 1); + assert.equal(ctx.provided[0].name, 'dsh-plugin-usage'); + const receiver = ctx.provided[0].value; + assert.equal(typeof receiver.query, 'function'); + assert.equal(typeof receiver.prices, 'function'); + assert.deepEqual(receiver.typertRemote.serviceKey, 'dsh-plugin-usage'); + assert.deepEqual(receiver.typertRemote.namespace, 'dsh-plugin-usage'); + assert.equal(receiver.typertRemote.service, receiver); + assert.deepEqual(await receiver.query({ from: 'x' }), { echo: { from: 'x' }, kind: 'query' }); + // …and the face is registered as a typert contribution, not a 2-arg call. assert.equal(registered.length, 1); - assert.equal(registered[0].channel, 'usage'); - assert.deepEqual(Object.keys(registered[0].endpoints), ['query', 'prices']); - assert.deepEqual(connection.routes, []); + assert.equal(registered[0].package, 'dsh-plugin-usage'); + assert.equal(registered[0].face, 'host'); + assert.deepEqual(registered[0].invocations.map((i) => i.method).sort(), ['prices', 'query']); + // Both channels stay mounted: Remote is preferred, Fetch is the fallback. + assert.deepEqual(connection.routes.map((route) => route.path).sort(), ['/api/usage/prices', '/api/usage/query']); +}); + +test('without a typert service the plugin keeps the Fetch routes and says which one it uses', async () => { + const connection = fakeConnection(); + const ctx = fakeCtx(connection.value, { typert: undefined }); + registerRPC(ctx, HANDLERS); + assert.deepEqual(connection.routes.map((route) => route.path).sort(), ['/api/usage/prices', '/api/usage/query']); + // The choice is reported, not swallowed: a support log names the channel. + assert.equal(ctx.logs.length, 1); + assert.match(ctx.logs[0].message, /Fetch \(no typert registry/); }); diff --git a/tests/scan.test.js b/tests/scan.test.js index 92867b0..bec1c0d 100644 --- a/tests/scan.test.js +++ b/tests/scan.test.js @@ -135,7 +135,7 @@ test('runBackfill folds persisted sessions with 0.2.x snapshots and stores the r close: async () => {}, }), }; - await runBackfill(ctxWith((name) => (name === 'sessionPersistence' ? persistence : undefined)), state); + await runBackfill(ctxWith((name) => (name === 'sessionPersistence' ? persistence : undefined)), state, { cache: false }); assert.equal(state.records.length, 2); assert.equal(state.watermarks.get('persisted-1'), 1); @@ -161,8 +161,8 @@ test('an unchanged revision is not re-read on the next pass', async () => { }), }; const ctx = ctxWith((name) => (name === 'sessionPersistence' ? persistence : undefined)); - await scanPersisted(ctx, state); - await scanPersisted(ctx, state); + await scanPersisted(ctx, state, { cache: false }); + await scanPersisted(ctx, state, { cache: false }); assert.equal(reads, 1); }); @@ -181,7 +181,7 @@ test('a shrunk log resets the watermark instead of stalling forever', async () = close: async () => {}, }), }; - await runBackfill(ctxWith((name) => (name === 'sessionPersistence' ? persistence : undefined)), state); + await runBackfill(ctxWith((name) => (name === 'sessionPersistence' ? persistence : undefined)), state, { cache: false }); assert.deepEqual(offsets, [0]); assert.equal(state.records.length, 2); assert.equal(state.watermarks.get('s'), 1); @@ -198,7 +198,7 @@ test('readSessionEvents falls back to a legacy readFrom backend', async () => { test('runBackfill reports done when persistence is unavailable', async () => { const state = makeState(); const calls = []; - await runBackfill(ctxWith(() => undefined), state, { onDone: (progress) => calls.push(progress) }); + await runBackfill(ctxWith(() => undefined), state, { cache: false, onDone: (progress) => calls.push(progress) }); assert.equal(state.backfill.status, 'done'); assert.deepEqual(calls, [{ status: 'done', total: 0, done: 0 }]); }); diff --git a/tests/settings.test.js b/tests/settings.test.js index 74fe57c..6f7e306 100644 --- a/tests/settings.test.js +++ b/tests/settings.test.js @@ -1,99 +1,151 @@ /** - * P2-12: node:test unit coverage for src/host/settings.js. + * The price store: user rows, both spellings, and the ladder behind it. + * + * Two contracts are worth pinning here because the store used to have a second + * personality (exact-key lookup when nothing was injected) and both personalities + * answered differently for the same model. There is one now. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; + import { DEFAULT_CURRENCY, - DEFAULT_PRICES, + configPrices, createPriceStore, - mergePrices, + normalizePrices, } from '../src/host/settings.js'; +import { ratesOf, resolvePrice } from '../src/host/pricing.js'; + +/** Beijing wall-clock -> epoch ms. */ +function bj(date, time) { + const [y, m, d] = date.split('-').map(Number); + const [hh, mm] = time.split(':').map(Number); + return Date.UTC(y, m - 1, d, hh, mm) - 8 * 3_600_000; +} -const base = () => createPriceStore(DEFAULT_PRICES, DEFAULT_CURRENCY); -const FULL_ZERO = { inputPerM: 0, outputPerM: 0, cacheReadPerM: 0, cacheWritePerM: 0 }; +const oneMillion = { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }; -test('priceOf falls back to the bare model price', () => { - const store = base(); - assert.deepEqual(store.priceOf({ model: 'deepseek-chat' }), DEFAULT_PRICES['deepseek-chat']); - assert.deepEqual(store.priceOf({ provider: '', model: 'deepseek-chat' }), DEFAULT_PRICES['deepseek-chat']); - assert.equal(store.priceOf({ model: 'nope' }), undefined); +test('normalizePrices accepts both spellings and coerces every field', () => { + const rows = normalizePrices({ + 'relay/a': { inputPerM: '2.5', outputPerM: '3', cacheReadPerM: 'x', cacheWritePerM: null }, + 'relay/b': { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 }, + broken: null, + 'also-broken': 'nope', + }); + assert.deepEqual(rows['relay/a'], { input: 2.5, output: 3, cacheRead: 0, cacheWrite: 0 }); + assert.deepEqual(rows['relay/b'], { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 }); + assert.equal(Object.prototype.hasOwnProperty.call(rows, 'broken'), false); + assert.equal(Object.prototype.hasOwnProperty.call(rows, 'also-broken'), false); + assert.deepEqual(normalizePrices(undefined), {}); + assert.deepEqual(normalizePrices([1, 2]), {}); }); -test('priceOf prefers the provider/model composite over the bare model', () => { - const store = base(); - const composite = { inputPerM: 9, outputPerM: 8, cacheReadPerM: 7, cacheWritePerM: 6 }; - store.setPrices({ 'acme/deepseek-chat': composite }); - assert.deepEqual(store.priceOf({ provider: 'acme', model: 'deepseek-chat' }), composite); - assert.deepEqual(store.priceOf({ provider: 'other', model: 'deepseek-chat' }), DEFAULT_PRICES['deepseek-chat']); - assert.deepEqual(store.priceOf({ model: 'deepseek-chat' }), DEFAULT_PRICES['deepseek-chat']); +test('configPrices round-trips normalizePrices', () => { + const canonical = { 'relay/a': { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 } }; + assert.deepEqual(normalizePrices(configPrices(canonical)), canonical); }); -test('hasPrice reports explicit entries, composite first, zero prices included', () => { - const store = base(); - store.setPrices({ 'acme/only-composite': FULL_ZERO }); - assert.ok(store.hasPrice({ provider: 'acme', model: 'deepseek-chat' })); // composite wins - assert.ok(store.hasPrice({ provider: 'other', model: 'deepseek-chat' })); // bare fallback - assert.ok(store.hasPrice({ provider: 'acme', model: 'only-composite' })); // composite-only entry - assert.ok(!store.hasPrice({ model: 'only-composite' })); // no bare entry exists - assert.ok(!store.hasPrice({ provider: 'acme', model: 'missing' })); - assert.ok(!store.hasPrice({ model: 'unknown' })); - // A zero-value price entry still counts as priced. - store.setPrices({ 'free-model': { inputPerM: 0, outputPerM: 0 } }); +test('the store resolves a table id through the ladder, not by exact key', () => { + const store = createPriceStore({}, DEFAULT_CURRENCY); + // A session id and a table id spell the same model differently. + for (const id of ['deepseek-flash', 'deepseek-v4.1-flash', 'group/auto-deepseek-v4-1-flash']) { + const hit = store.lookup({ model: id }); + assert.equal(hit.state, 'exact', `${id} resolves`); + assert.ok(hit.price !== null); + } + assert.equal(store.lookup({ model: 'no-such-model-anywhere' }).state, 'none'); +}); + +test('a user row still wins over the table, composite before bare id', () => { + const store = createPriceStore({ + 'acme/deepseek-flash': { inputPerM: 9, outputPerM: 8, cacheReadPerM: 7, cacheWritePerM: 6 }, + }, DEFAULT_CURRENCY); + const own = store.lookup({ provider: 'acme', model: 'deepseek-flash' }); + assert.equal(own.how, 'custom'); + assert.equal(ratesOf(own.price, 'peak').input, 9); + // Another provider gets the table's price, not acme's. + const table = store.lookup({ provider: 'other', model: 'deepseek-flash' }); + assert.equal(table.how, 'normalized'); + assert.equal(ratesOf(table.price, 'peak').input, 0.3); +}); + +test('hasPrice counts zero rows and rejects unknown ids', () => { + const store = createPriceStore({ 'free-model': { inputPerM: 0, outputPerM: 0 } }, DEFAULT_CURRENCY); assert.ok(store.hasPrice({ model: 'free-model' })); + assert.ok(store.hasPrice({ model: 'deepseek-flash' })); + assert.ok(!store.hasPrice({ model: 'unknown' })); + assert.ok(!store.hasPrice(null)); }); -test('costOf computes per-million token cost across all four fields', () => { - const store = base(); - store.setPrices({ 'acme/m': { inputPerM: 1, outputPerM: 2, cacheReadPerM: 3, cacheWritePerM: 4 } }); - store.setPrices({ 'free-zero': { inputPerM: 0, outputPerM: 0 } }); - const r = { - provider: 'acme', model: 'm', - inputTokens: 1000000, outputTokens: 500000, - cacheReadTokens: 200000, cacheWriteTokens: 100000, +test('costOf bills each token bucket at its own tier', () => { + const store = createPriceStore({}, DEFAULT_CURRENCY); + const record = { + provider: 'deepseek-account', model: 'deepseek-flash', + tokens: { peak: { ...oneMillion }, off: { ...oneMillion } }, }; - assert.equal(store.costOf(r), 3); - assert.equal(store.costOf({ ...r, model: 'nope' }), 0); // missing price -> 0 - assert.equal( - store.costOf({ provider: 'acme', model: 'm', inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }), - 0, - ); - assert.equal(store.costOf({ model: 'free-zero', inputTokens: 100, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }), 0); + // 0.30 peak + 0.15 off-peak per 1M input tokens, from the shipped table. + assert.ok(Math.abs(store.costOf(record) - 0.45) < 1e-12); }); -test('setPrices coerces number fields and ignores non-objects', () => { - const store = base(); - store.setPrices({ m1: { inputPerM: '2.5', outputPerM: '3', cacheReadPerM: 'x', cacheWritePerM: null } }); - assert.deepEqual(store.priceOf({ model: 'm1' }), { inputPerM: 2.5, outputPerM: 3, cacheReadPerM: 0, cacheWritePerM: 0 }); +test('costOf prices a record without buckets at its own instant', () => { + const store = createPriceStore({}, DEFAULT_CURRENCY); + const base = { provider: 'deepseek-account', model: 'deepseek-flash', ...oneMillion }; + assert.equal(store.costOf({ ...base, time: bj('2026-10-19', '10:00') }), 0.3); + assert.equal(store.costOf({ ...base, time: bj('2026-10-19', '20:00') }), 0.15); +}); + +test('setPrices merges, coerces and ignores non-objects', () => { + const store = createPriceStore({}, DEFAULT_CURRENCY); + store.setPrices({ m1: { inputPerM: '2.5', outputPerM: '3' } }); + assert.deepEqual(store.snapshot().prices, { m1: { input: 2.5, output: 3, cacheRead: 0, cacheWrite: 0 } }); const before = store.snapshot(); - for (const bad of [null, undefined, 'nope', 42, [1, 2], {}]) { - store.setPrices(bad); - } + for (const bad of [null, undefined, 'nope', 42, [1, 2], {}]) store.setPrices(bad); assert.deepEqual(store.snapshot(), before); }); test('setCurrency accepts non-empty strings and ignores everything else', () => { - const store = createPriceStore(DEFAULT_PRICES); // default currency + const store = createPriceStore({}); assert.equal(store.currency, DEFAULT_CURRENCY); store.setCurrency('$'); assert.equal(store.currency, '$'); - store.setCurrency(''); - assert.equal(store.currency, '$'); - store.setCurrency(123); + for (const bad of ['', 123, null]) store.setCurrency(bad); assert.equal(store.currency, '$'); - store.setCurrency(null); - assert.equal(store.currency, '$'); - assert.equal(createPriceStore(DEFAULT_PRICES, '€').currency, '€'); }); -test('mergePrices layers configured prices over the defaults', () => { - assert.deepEqual(mergePrices(undefined), DEFAULT_PRICES); - assert.deepEqual(mergePrices({}), DEFAULT_PRICES); - const merged = mergePrices({ - 'deepseek-v4.1-flash-1017': { inputPerM: 0, outputPerM: 1, cacheReadPerM: 0, cacheWritePerM: 0 }, +test('a confirmed match outranks the ladder, and only a live target counts', () => { + const store = createPriceStore({}, DEFAULT_CURRENCY, { + matches: { 'acme/odd-id': 'deepseek-v4-pro' }, }); - assert.deepEqual(merged['deepseek-v4.1-flash-1017'], { inputPerM: 0, outputPerM: 1, cacheReadPerM: 0, cacheWritePerM: 0 }); - assert.deepEqual(merged['deepseek-chat'], DEFAULT_PRICES['deepseek-chat']); // untouched default survives - // The input table is never mutated. - assert.deepEqual(DEFAULT_PRICES['deepseek-chat'], { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 }); + const hit = store.lookup({ provider: 'acme', model: 'odd-id' }); + assert.equal(hit.how, 'confirmed'); + assert.equal(hit.state, 'custom'); + assert.equal(hit.target, 'deepseek-v4-pro'); + + // The store reads the getter on every lookup, so a commit is visible at once. + let live = {}; + const mutable = createPriceStore({}, DEFAULT_CURRENCY, { matches: () => live }); + assert.equal(mutable.lookup({ model: 'odd-id' }).state, 'none'); + live = { 'odd-id': 'deepseek-v4-pro' }; + assert.equal(mutable.lookup({ model: 'odd-id' }).state, 'custom'); +}); + +test('a confirmed target that left the table is reported, not repriced', () => { + const store = createPriceStore({}, DEFAULT_CURRENCY, { matches: { 'odd-id': 'retired-model' } }); + const hit = store.lookup({ model: 'odd-id' }); + assert.equal(hit.price, null); + assert.equal(hit.state, 'none'); + assert.equal(hit.target, 'retired-model'); +}); + +test('the store and the ladder answer the same question identically', () => { + // The regression this guards: propose used to re-derive its own verdict from a + // field the ladder never set, so the dialog said 未匹配 on billed models. + const store = createPriceStore({}, DEFAULT_CURRENCY); + for (const id of ['deepseek-flash', 'deepseek-v4.1-flash', 'deepseek-chat', 'no-such-model']) { + const fromStore = store.lookup({ model: id }); + const fromLadder = resolvePrice({ model: id }); + assert.equal(fromStore.how, fromLadder.how, id); + assert.equal(fromStore.state, fromLadder.state, id); + assert.equal(fromStore.target, fromLadder.target, id); + } }); diff --git a/tests/state.test.js b/tests/state.test.js index 8a22915..a50b49a 100644 --- a/tests/state.test.js +++ b/tests/state.test.js @@ -10,7 +10,7 @@ test('createState exposes the full runtime capsule shape', () => { const state = createState(prices); assert.deepEqual( Object.keys(state).sort(), - ['backfill', 'index', 'lastSample', 'prices', 'records', 'revisions', 'route', 'watermarks'], + ['backfill', 'index', 'lastPass', 'lastSample', 'matchMap', 'peakOf', 'prices', 'records', 'revisions', 'route', 'watermarks'], ); assert.deepEqual(state.records, []); assert.ok(state.watermarks instanceof Map); @@ -18,8 +18,15 @@ test('createState exposes the full runtime capsule shape', () => { assert.ok(state.lastSample instanceof Map); assert.ok(state.route instanceof Map); assert.equal(state.index, null); + // Injected by apply(): the price-tier test and the user's confirmed matches. + // Both default to "not wired", which is the behaviour the fold tests rely on. + assert.equal(state.peakOf, null); + assert.deepEqual(state.matchMap, {}); assert.equal(state.prices, prices); - assert.deepEqual(state.backfill, { status: 'idle', total: 0, done: 0 }); + // `started` is the lazy-start latch (see startBackfillOnDemand): the pass runs + // on the first query, not at activation, so the cache-loading window is the + // one the user can actually see. + assert.deepEqual(state.backfill, { status: 'idle', total: 0, done: 0, started: false }); }); test('createState instances are isolated from each other', () => { @@ -36,5 +43,5 @@ test('createState instances are isolated from each other', () => { assert.equal(b.revisions.size, 0); assert.equal(b.lastSample.size, 0); assert.equal(b.route.size, 0); - assert.deepEqual(b.backfill, { status: 'idle', total: 0, done: 0 }); + assert.deepEqual(b.backfill, { status: 'idle', total: 0, done: 0, started: false }); }); diff --git a/tests/tier-billing.test.js b/tests/tier-billing.test.js new file mode 100644 index 0000000..b1aa60e --- /dev/null +++ b/tests/tier-billing.test.js @@ -0,0 +1,162 @@ +/** + * Peak / off-peak billing, end to end: the fold splits a settlement by the tier + * its own timestamp fell into, and the store prices each bucket with the rate of + * that tier. + * + * Why this file exists: the harness reports one aggregate per model, and DeepSeek + * bills by the instant of each call. Measured on this machine, one model mixed + * ~42% peak and ~58% off-peak inside a single month, so any single-rate estimate + * is off by up to 2x — in a direction nobody can predict from the aggregate. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createState } from '../src/host/state.js'; +import { foldEvent } from '../src/host/fold.js'; +import { createPriceStore } from '../src/host/settings.js'; +import { costOf, resolvePrice } from '../src/host/pricing.js'; +import { isPeak } from '../src/host/peak.js'; +import { peakAt } from '../src/host/index.js'; + +/** Beijing wall-clock -> epoch ms. */ +function bj(date, time) { + const [y, m, d] = date.split('-').map(Number); + const [hh, mm] = time.split(':').map(Number); + return Date.UTC(y, m - 1, d, hh, mm) - 8 * 3_600_000; +} + +/** A settlement carrying `usage`, attributed to `provider/model`. */ +function message(seq, atMs, usage, model = 'deepseek-flash', provider = 'deepseek-account') { + return { + seq, + time: atMs, + type: 'assistant/message', + data: { turn: 1, step: seq, usage, message: { source: { provider, model } } }, + }; +} + +const usage = (inputTokens) => ({ inputTokens, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }); + +test('the live tier rule is the holiday-aware one, not the bare window test', () => { + // The fold asks the state capsule for a predicate. That predicate must know + // the calendar: it once called isPeak() without one, so every public holiday + // inside a window was billed at the peak rate while the price display (which + // did use the calendar) showed it as off-peak. + assert.equal(peakAt(bj('2026-10-29', '10:00')), true, 'an ordinary Thursday window is peak'); + assert.equal(peakAt(bj('2026-10-01', '10:00')), false, '2026-10-01 is National Day'); + assert.equal(peakAt(bj('2026-10-01', '10:00')), isPeak(bj('2026-10-01', '10:00')), + 'the wired predicate and the rule must agree'); + assert.equal(peakAt(bj('2026-10-31', '10:00')), false, 'Saturday stays off-peak'); +}); + +/** An official two-tier price, shaped like a reference-table DeepSeek row. */ +const TWO_TIER = { + peak: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, + offPeak: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, +}; + +test('a settlement inside a peak window lands in the peak bucket', () => { + const state = createState(null); + state.peakOf = (atMs) => isPeak(atMs, () => false); + const rec = foldEvent(state, 's1', message(1, bj('2026-10-19', '10:00'), usage(1_000_000))); + assert.ok(rec.tokens.peak, 'the peak bucket must exist'); + assert.equal(rec.tokens.off, undefined, 'nothing should be counted as off-peak'); + assert.equal(rec.tokens.peak.inputTokens, 1_000_000); + // The flat fields stay: summaries and the token table never change shape. + assert.equal(rec.inputTokens, 1_000_000); +}); + +test('a settlement outside the window lands in the off-peak bucket', () => { + const state = createState(null); + state.peakOf = (atMs) => isPeak(atMs, () => false); + const rec = foldEvent(state, 's1', message(1, bj('2026-10-19', '20:00'), usage(1_000_000))); + assert.ok(rec.tokens.off); + assert.equal(rec.tokens.peak, undefined); +}); + +test('the tier follows the event, not the query time', () => { + const state = createState(null); + state.peakOf = (atMs) => isPeak(atMs, () => false); + const peakRec = foldEvent(state, 's1', message(1, bj('2026-10-19', '09:30'), usage(1_000_000))); + const offRec = foldEvent(state, 's2', message(2, bj('2026-10-19', '21:30'), usage(1_000_000))); + assert.deepEqual(Object.keys(peakRec.tokens), ['peak']); + assert.deepEqual(Object.keys(offRec.tokens), ['off']); +}); + +test('a re-reported settlement replaces its bucket instead of doubling it', () => { + const state = createState(null); + state.peakOf = (atMs) => isPeak(atMs, () => false); + const at = bj('2026-10-19', '10:00'); + foldEvent(state, 's1', message(1, at, usage(1_000_000))); + const second = message(2, at, usage(500_000)); + second.data.turn = 1; + second.data.step = 1; // same (turn, step) as the first settlement's own step + foldEvent(state, 's1', second); + assert.equal(state.records.length, 1, 'the replacement slot keeps one record'); + assert.equal(state.records[0].tokens.peak.inputTokens, 500_000, 'the later sample wins'); +}); + +test('without a peakOf the record keeps no buckets (the old shape)', () => { + const state = createState(null); + const rec = foldEvent(state, 's1', message(1, bj('2026-10-19', '10:00'), usage(10))); + assert.equal(rec.tokens, undefined); +}); + +test('a mixed day is billed as the sum of its two tiers', () => { + const state = createState(null); + state.peakOf = (atMs) => isPeak(atMs, () => false); + foldEvent(state, 's1', message(1, bj('2026-10-19', '10:00'), usage(1_000_000))); + foldEvent(state, 's1', message(2, bj('2026-10-19', '20:00'), usage(1_000_000))); + const billed = state.records.map((r) => costOf(r, TWO_TIER)); + assert.equal(billed[0], 0.3, '1M input at the peak rate'); + assert.equal(billed[1], 0.15, '1M input at the off-peak rate'); + // A float sum of two exact halves is 0.44999999999999996; the point of the + // assertion is that neither bucket is priced twice or dropped. + assert.ok(Math.abs(billed[0] + billed[1] - 0.45) < 1e-12, String(billed[0] + billed[1])); +}); + +test('the store has exactly one policy: injecting another changes nothing', () => { + // The store used to accept `resolve` / `costFor` hooks, which is how a second + // pricing policy survived for a release (exact-key lookup when nothing was + // injected). They are gone; passing them must not resurrect a second answer. + const flat = { model: 'deepseek-flash', inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }; + const plain = createPriceStore({}, '¥'); + const injected = createPriceStore({}, '¥', { + resolve: () => ({ price: { inputPerM: 99, outputPerM: 0, cacheReadPerM: 0, cacheWritePerM: 0 }, how: 'custom', target: null }), + costFor: () => 42, + }); + assert.equal(plain.costOf(flat), injected.costOf(flat)); + assert.equal(plain.lookup(flat).how, injected.lookup(flat).how); +}); + +test('an unpriced record still costs nothing and is reported as unmatched', () => { + const state = createState(null); + state.peakOf = () => true; + const store = createPriceStore({}, '¥'); + const rec = foldEvent(state, 's1', message(1, bj('2026-10-19', '10:00'), usage(100), 'no-such-model')); + assert.equal(store.costOf(rec), 0); + assert.equal(store.hasPrice(rec), false); + assert.equal(store.lookup(rec).how, 'unmatched'); +}); + +test('the shipped table bills both live Flash ids at two tiers through the store', () => { + // Regression gate. `deepseek-flash` used to alias onto Zen's single-price row, + // and that number is DeepSeek's PEAK rate, so every off-peak call was billed at + // double while the dialog reported a confident 按名称归一 hit. + const store = createPriceStore({}, '¥'); + const oneM = (at, model) => ({ + time: at, + provider: 'deepseek-account', + model, + inputTokens: 1_000_000, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }); + for (const model of ['deepseek-flash', 'deepseek-v4.1-flash']) { + assert.equal(store.costOf(oneM(bj('2026-10-19', '10:00'), model)), 0.3, `${model} at peak`); + assert.equal(store.costOf(oneM(bj('2026-10-19', '20:00'), model)), 0.15, `${model} at off-peak`); + } + // A retired name rides the alias table onto the same two-tier row. + assert.equal(store.costOf(oneM(bj('2026-10-19', '20:00'), 'deepseek-chat')), 0.15); +}); diff --git a/tests/typert.test.js b/tests/typert.test.js new file mode 100644 index 0000000..26c00b0 --- /dev/null +++ b/tests/typert.test.js @@ -0,0 +1,154 @@ +/** + * The native Remote contract, checked structurally. + * + * The authoritative gate is `npm run verify:typert`, which runs the OFFICIAL + * `validateTypertManifest` from the installed harness. It needs the archive, so + * it cannot run in CI; the rules below are the same requirements plus the ones + * the loader does NOT check but the gateway does (namespace = package name, the + * receiver binding) — each cites where it comes from. + */ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + USAGE_SERVICE, + buildInvocation, + createReceiver, + registerTypert, + typertContribution, +} from '../src/host/typert.js' + +const ENDPOINTS = ['query', 'prices', 'pricing', 'contract'] +const HANDLERS = new Map([ + ['query', (args) => ({ kind: 'query', args })], + ['prices', () => ({ currency: 'CNY' })], + ['pricing', (args) => ({ kind: 'pricing', args })], + ['contract', () => ({ package: USAGE_SERVICE, names: [] })], +]) + +test('the namespace is the package name, because the client key is remote.', () => { + // dsh-api-gateway client `remoteServiceKey(namespace) = 'remote.' + namespace`. + // Writing an endpoint name here mounts a service nobody can reach. + const contribution = typertContribution(ENDPOINTS) + assert.equal(USAGE_SERVICE, 'dsh-plugin-usage') + assert.equal(contribution.package, USAGE_SERVICE) + for (const invocation of contribution.invocations) { + assert.equal(invocation.namespace, USAGE_SERVICE) + assert.equal(invocation.service, USAGE_SERVICE) + } +}) + +test('the contribution has the shape the loader validates', () => { + const contribution = typertContribution(ENDPOINTS) + assert.equal(contribution.face, 'host') + assert.ok(Array.isArray(contribution.schemas)) + assert.deepEqual(Object.keys(contribution.model).sort(), ['events', 'objects', 'services']) + for (const key of Object.keys(contribution.model)) { + assert.ok(Array.isArray(contribution.model[key]), key + ' must be an array') + } + assert.equal(contribution.invocations.length, ENDPOINTS.length) + // Wire names: the registry accepts only [A-Za-z0-9_$. -] for namespace/method. + for (const invocation of contribution.invocations) { + assert.match(invocation.method, /^[A-Za-z0-9_$.-]+$/) + assert.match(invocation.id, /^[A-Za-z0-9_$./#-]+$/) + } + // Ids are the uniqueness key of a face. + const ids = contribution.invocations.map((i) => i.id) + assert.equal(new Set(ids).size, ids.length) +}) + +test('every parameter and every result uses a strict codec', () => { + // requireStrictCodec: mode === 'strict', non-empty typeSymbol, create(). + for (const invocation of typertContribution(ENDPOINTS).invocations) { + assert.equal(invocation.parameters.length, 1, 'the gateway calls methods positionally: one JSON arg') + const parameter = invocation.parameters[0] + assert.equal(parameter.wire, 'args') + // `source` is only ever 'json' or 'lookup'. + assert.equal(parameter.source, 'json') + assert.equal(parameter.codec.mode, 'strict') + assert.ok(parameter.codec.typeSymbol.length > 0) + assert.equal(typeof parameter.codec.create, 'function') + assert.equal(typeof parameter.codec.create().parse, 'function') + + assert.equal(invocation.result.mode, 'strict') + assert.ok(invocation.result.typeSymbol.length > 0) + assert.equal(typeof invocation.result.create, 'function') + // The gateway skips its own result handling without an encode(). + assert.equal(typeof invocation.result.encode, 'function') + } +}) + +test('sourceLocation names a real file and positive coordinates', () => { + const invocation = buildInvocation('query') + assert.equal(invocation.sourceLocation.file, 'src/host/typert.js') + assert.ok(Number.isInteger(invocation.sourceLocation.line) && invocation.sourceLocation.line >= 1) + assert.ok(Number.isInteger(invocation.sourceLocation.column) && invocation.sourceLocation.column >= 1) +}) + +test('a non-wire-safe endpoint name is refused before it reaches the registry', () => { + assert.throws(() => typertContribution(['ok-name', 'not ok']), /wire-safe/) + assert.throws(() => typertContribution([]), /at least one endpoint/) +}) + +test('the receiver answers every endpoint and carries the binding the gateway checks', async () => { + const receiver = createReceiver(HANDLERS) + assert.equal(receiver.typertRemote.service, receiver) + assert.equal(receiver.typertRemote.serviceKey, USAGE_SERVICE) + assert.equal(receiver.typertRemote.namespace, USAGE_SERVICE) + assert.ok(Object.isFrozen(receiver.typertRemote)) + // Methods are keyed verbatim, so a hyphenated endpoint is reachable. + assert.deepEqual(await receiver.query({ a: 1 }), { kind: 'query', args: { a: 1 } }) + // A missing argument must not become `undefined` inside a handler. + assert.deepEqual(await receiver.pricing(undefined), { kind: 'pricing', args: {} }) +}) + +test('registration reports the channel it chose, and never throws', () => { + const registered = [] + const makeCtx = (services) => ({ + get: (name) => services[name], + ...(services.provide === undefined ? {} : { provide: services.provide }), + }) + + // No typert registry at all: the Fetch routes are the channel, and the reason + // says so (it travels to the dashboard as `channel`). + const noRegistry = registerTypert(makeCtx({}), HANDLERS, () => {}) + assert.equal(noRegistry.ok, false) + assert.equal(noRegistry.transport, 'fetch') + assert.match(noRegistry.reason, /no typert registry/) + + // A registry but no way to provide the receiver service: registering anyway + // would point the client at a channel that cannot answer. + const noProvide = registerTypert(makeCtx({ typert: { register: (c) => registered.push(c) } }), HANDLERS, () => {}) + assert.equal(noProvide.ok, false) + assert.match(noProvide.reason, /cannot provide/) + assert.deepEqual(registered, []) + + // ctx.provide() — the real entry point — is enough on its own. + const provided = [] + const ok = registerTypert(makeCtx({ + typert: { register: (c) => { registered.push(c); return () => {} } }, + provide: (name, value) => { provided.push({ name, value }); return () => {} }, + }), HANDLERS, () => {}) + assert.equal(ok.ok, true) + assert.equal(ok.transport, 'remote') + assert.equal(provided.length, 1) + assert.equal(provided[0].name, 'dsh-plugin-usage') + + // provide() throws: stay on the Fetch path, say why. + const throwing = registerTypert(makeCtx({ + typert: { register: () => {} }, + provide: () => { throw new Error('nope') }, + }), HANDLERS, () => {}) + assert.equal(throwing.ok, false) + assert.match(throwing.reason, /providing the receiver failed: nope/) + + // register() throws: the receiver must be taken back out again. + let disposed = 0 + const rejecting = registerTypert(makeCtx({ + typert: { register: () => { throw new Error('face rejected') } }, + provide: () => () => { disposed += 1 }, + }), HANDLERS, () => {}) + assert.equal(rejecting.ok, false) + assert.equal(disposed, 1, 'a rejected face must not leave the receiver behind') + assert.match(rejecting.reason, /typert rejected the contribution: face rejected/) +})