diff --git a/CHANGELOG.md b/CHANGELOG.md index e2fba94..c4628bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/). 中文版见 [CHANGELOG_ZH.md](./CHANGELOG_ZH.md). +## [1.1.6] - 2026-09-19 + +### Fixed + +- **Host pricing file source lifecycle (`pricing.json` override & change watcher)** — [PR #10](https://github.com/Max-Samson/dsh-usage-chart/pull/10): + - Fixed premature disposal of `filePricingSource` during plugin registration: `ctx.effect(() => fileSource.dispose(), ...)` called `dispose()` synchronously while building the fiber and registered `undefined` as the cleanup handler, aborting initial asynchronous file loading and disabling the file change watcher. Resolved by returning a disposer function: `ctx.effect(() => () => fileSource.dispose(), ...)`. + - Updated test mocks in `tests/core.test.mjs`, `tests/pricing.test.mjs`, and `tests/rounds.test.mjs` to properly track and execute Cordis effect disposers in `after()`. + - Added regression test covering the `/pricing` route with user `pricing.json` overrides enabled at startup. + +## [1.1.5] - 2026-09-12 + +### Changed + +- **DeepSeek official pricing update (2026-09-10) & `deepseek-flash` support** — [PR #8](https://github.com/Max-Samson/dsh-usage-chart/pull/8): + - **New `deepseek-flash` model pricing**: Updated builtin pricing according to the official DeepSeek pricing table (verified 2026-09-10). The new `deepseek-flash` rates are: + - **Off-peak** (Beijing Mon–Fri non-peak & weekends): CNY 1.0 / 0.02 / 4.0 per 1M tokens (cache-miss input / cache-hit input / output); USD 0.15 / 0.003 / 0.6 per 1M tokens. + - **Peak** (Beijing Mon–Fri 09:00–12:00 & 14:00–18:00): CNY 2.0 / 0.04 / 8.0 per 1M tokens; USD 0.30 / 0.006 / 1.2 per 1M tokens. + - **Backward compatibility for legacy model names**: `deepseek-v4-flash` and `deepseek-v4-flash-vision-exp` are mapped to `deepseek-flash` in `BUILTIN_PRICING` so historical session logs continue resolving without falling back to unknown-model estimates. + - **Fallback pricing**: Default unlisted model fallback (`FALLBACK_PRICING`) now points to the new `deepseek-flash` pricing. + - **Docs & test synchronization**: Updated custom `pricing.json` example in `README.md` and `README_ZH.md` (`verifiedAt: 1788998400000`), and aligned test cases across `tests/core.test.mjs`, `tests/pricing.test.mjs`, and `tests/rounds.test.mjs` (`BUILTIN_VERIFIED_AT` set to `2026-09-10T00:00:00Z`). + +## [1.1.4] - 2026-09-08 + +### Fixed + +- **`dsh-session >= 0.1.2-rc.1` compatibility (snapshotEvents())** — [PR #7](https://github.com/Max-Samson/dsh-usage-chart/pull/7): `dsh-session` removed its public `events` property in `0.1.2-rc.1` (replaced by the `snapshotEvents()` method); the `/usage` route then threw `TypeError: events is not iterable`, which `dsh-host-webserver` wrapped as a bare `400` empty response, and the per-round chart disappeared (the client fell back to the empty observed-rounds path). Reproduced live on `dsh-session 0.1.2-rc.1`: `/usage?session=` returned an empty `400` while `/usage?session=` returned the plugin `404` JSON and folding the same log offline succeeded — confirming the crash was only the new runtime API shape. The host now reads events via capability detection — prefers `session.snapshotEvents()` when the function is present and falls back to the legacy `session.events ?? []` (so a missing API can never crash the route again). The vendored `SessionEventLike` type adds the `time` field (Unix epoch ms) and a `SessionHandle` shape (optional `events` + optional `snapshotEvents`) so `SessionStoreService.get()/list()` stay valid across both API generations. Verified against the upstream `deepseek-harness` source: `Session.events` was removed from `0.1.2-alpha.4` onward (official architecture note `2026-08-21-session-log-read-intent`); the installed `0.1.0-rc.6` still exposes `events`, so the fallback path remains covered. +- **Test coverage for the new `snapshotEvents()` access path**: added `tests/rounds.test.mjs` → `/usage route prefers snapshotEvents() when the new API is present`, mocking a session handle that exposes only `snapshotEvents()` (its `get events()` throws, proving the route never reads the removed property), asserts `time` is consumed (duration/peak billing) and that `snapshotEvents()` wins when both APIs are present. + ## [1.1.2] - 2026-08-26 ### Improved & Fixed diff --git a/CHANGELOG_ZH.md b/CHANGELOG_ZH.md index b4f5c49..60d43b4 100644 --- a/CHANGELOG_ZH.md +++ b/CHANGELOG_ZH.md @@ -2,6 +2,34 @@ 本文件记录本项目所有重要变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。英文版见 [CHANGELOG.md](./CHANGELOG.md)。 +## [1.1.6] - 2026-09-19 + +### 修复 + +- **宿主自定义价格文件源生命周期早释问题(`pricing.json` 覆盖与变更监听)**——[PR #10](https://github.com/Max-Samson/dsh-usage-chart/pull/10): + - **修复 `filePricingSource` 启动即被销毁的 Bug**:此前 `ctx.effect(() => fileSource.dispose(), ...)` 在挂载期立即执行了清理调用,导致文件数据源在首次异步读取完成前被截断关闭,致使用户配置的 `pricing.json` 覆盖及变更监听均无法生效。现已修正为返回高阶清理函数 `ctx.effect(() => () => fileSource.dispose(), ...)`,使文件源在 fiber 卸载时才被释放。 + - **纠正单测 Cordis effect Mock 语义**:在 `tests/core.test.mjs`、`tests/pricing.test.mjs` 与 `tests/rounds.test.mjs` 中对齐 Cordis 真实生命周期契约,在 `after()` 中统一执行清理函数。 + - **补充端到端回归用例**:新增 `/pricing route serves the user pricing.json override (file source survives startup)`,验证插件挂载自定义 `pricing.json` 时价格覆盖正常生效且来源正确标记为 `file`。 + + +## [1.1.5] - 2026-09-12 + +### 变更与优化 + +- **同步 DeepSeek 官方最新调价(2026-09-10)与 `deepseek-flash` 适配**——[PR #8](https://github.com/Max-Samson/dsh-usage-chart/pull/8): + - **新增 `deepseek-flash` 官方刊例价**:按 DeepSeek 官方 2026-09-10 最新调价更新内置定价表,`deepseek-flash` 刊例价全面下调: + - **空闲时段**(北京时间工作日非高峰与周末全天):人民币 CNY 1.0 / 0.02 / 4.0 每 1M tokens(未命中输入 / 命中输入 / 输出);美元 USD 0.15 / 0.003 / 0.6 每 1M tokens。 + - **高峰时段**(北京时间工作日 09:00–12:00 与 14:00–18:00):人民币 CNY 2.0 / 0.04 / 8.0 每 1M tokens;美元 USD 0.30 / 0.006 / 1.2 每 1M tokens。 + - **历史模型标识平滑兼容**:将 `deepseek-v4-flash` 与 `deepseek-v4-flash-vision-exp` 指向 `deepseek-flash` 价格表,确保历史会话在重新加载与折叠时平滑解析,不被判定为未定价模型。 + - **未收录模型回退**:未收录模型的默认保守估算回退(`FALLBACK_PRICING`)同步切换至新版 `deepseek-flash` 刊例价。 + - **文档与测试同步**:更新 README 中的 `pricing.json` 自定义配置示例与核验时间戳(`verifiedAt: 1788998400000` 即 2026-09-10);全量同步 core、pricing、rounds 单测断言与 `BUILTIN_VERIFIED_AT`。 + +## [1.1.4] - 2026-09-08 + +### 修复 + +- **适配 `dsh-session >= 0.1.2-rc.1`(改用 `snapshotEvents()`)**——[PR #7](https://github.com/Max-Samson/dsh-usage-chart/pull/7):`dsh-session` 在 `0.1.2-rc.1` 移除了公开的 `events` 属性(改为 `snapshotEvents()` 方法);`/usage` 路由随之抛 `TypeError: events is not iterable`,被 `dsh-host-webserver` 包装成裸 `400` 空响应,每轮图表消失(客户端回退到空的 observed-rounds 路径)。已在 `dsh-session 0.1.2-rc.1` 上实测复现:`/usage?session=<已加载会话>` 返回空 `400`、`/usage?session=<未知>` 返回插件 `404` JSON、离线折叠同一日志却成功——证明崩溃仅源于新版运行时 API 形状。宿主现在通过能力检测读取事件——`snapshotEvents` 为函数时优先调用它,否则回退到旧版 `session.events ?? []`(即便 API 缺失也绝不再导致路由崩溃)。同时为 vendored 的 `SessionEventLike` 类型补齐 `time` 字段(Unix epoch 毫秒),并新增 `SessionHandle` 形状(可选 `events` + 可选 `snapshotEvents`),使 `SessionStoreService.get()/list()` 在新旧两代 API 下均成立。已对照上游 `deepseek-harness` 源码核实:`Session.events` 自 `0.1.2-alpha.4` 起移除(官方架构笔记 `2026-08-21-session-log-read-intent`);本机安装的 `0.1.0-rc.6` 仍暴露 `events`,因此回退分支持续有覆盖。 +- **为新的 `snapshotEvents()` 读取路径补充测试**:新增 `tests/rounds.test.mjs` → `/usage route prefers snapshotEvents() when the new API is present`,mock 一个只暴露 `snapshotEvents()` 的会话句柄(其 `get events()` 会抛错,证明路由绝不读取被移除的属性),断言 `time` 被正确消费(耗时/峰值计费),并验证同时暴露两者时优先使用 `snapshotEvents()`。 ## [1.1.2] - 2026-08-26 diff --git a/README.md b/README.md index d224a2c..bb45e8c 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ To update (upgrade to a new version): pnpm may print `Already up to date` when a ```sh # Option ①: pin the target version explicitly -dsh plugin --profile web add dsh-usage-chart@1.1.2 +dsh plugin --profile web add dsh-usage-chart@1.1.6 # Option ②: remove, then re-add (back to latest) dsh plugin --profile web remove dsh-usage-chart dsh plugin --profile web add dsh-usage-chart @@ -148,16 +148,16 @@ Costs are resolved with priority **user override file > builtin list price > fal ```json { - "deepseek-v4-flash": { + "deepseek-flash": { "offPeak": { - "cny": { "cacheMissInput": 1.5, "cacheHitInput": 0.05, "output": 4.5 }, - "usd": { "cacheMissInput": 0.22, "cacheHitInput": 0.007, "output": 0.66 } + "cny": { "cacheMissInput": 1, "cacheHitInput": 0.02, "output": 4 }, + "usd": { "cacheMissInput": 0.15, "cacheHitInput": 0.003, "output": 0.6 } }, "peak": { - "cny": { "cacheMissInput": 3.0, "cacheHitInput": 0.10, "output": 9.0 }, - "usd": { "cacheMissInput": 0.44, "cacheHitInput": 0.014, "output": 1.32 } + "cny": { "cacheMissInput": 2, "cacheHitInput": 0.04, "output": 8 }, + "usd": { "cacheMissInput": 0.3, "cacheHitInput": 0.006, "output": 1.2 } }, - "verifiedAt": 1755100800000 + "verifiedAt": 1788998400000 } } ``` @@ -278,7 +278,7 @@ dsh-usage-chart/ | Component | Supported | |---|---| -| DSH | ≥ 0.1.0-rc.6, built against the 0.1.x API | +| DSH | ≥ 0.1.0-rc.6, built against the 0.1.x API; adapts to the `dsh-session` `snapshotEvents()` API — `Session.events` was removed in `0.1.2-rc.1`, so the `/usage` route reads events via `snapshotEvents()` with a legacy `events` fallback | | Node.js | ≥ 20 | | Web UI | React 18 / `conversation.composer.dock` + `conversation.chat.assistant-actions` | | OS | macOS, Linux, Windows (pure JavaScript, no native dependencies) | diff --git a/README_ZH.md b/README_ZH.md index 1688e25..19aa7d6 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -78,7 +78,7 @@ dsh web --profile web # 启动 DSH Web(已在运行 ```sh # 方式①:显式指定目标版本 -dsh plugin --profile web add dsh-usage-chart@1.1.2 +dsh plugin --profile web add dsh-usage-chart@1.1.6 # 方式②:先移除再重装(回到最新版) dsh plugin --profile web remove dsh-usage-chart dsh plugin --profile web add dsh-usage-chart @@ -91,7 +91,7 @@ dsh plugin --profile web add dsh-usage-chart > ⚠️ **未全局安装 dsh(报 `dsh: command not found` / PowerShell `无法将“dsh”项识别为…`)? > 把上面每条 `dsh` 都写成 `npx --yes @deepseek-ai/dsh`**,例如 -> `npx --yes @deepseek-ai/dsh plugin --profile web add dsh-usage-chart@1.1.2` +> `npx --yes @deepseek-ai/dsh plugin --profile web add dsh-usage-chart@1.1.6` > (原因与解法见 [FAQ](#常见问题faq) 第一条)。 ### 方式二:从 GitHub 安装(源码构建) @@ -168,16 +168,16 @@ dsh web --profile web ```json { - "deepseek-v4-flash": { + "deepseek-flash": { "offPeak": { - "cny": { "cacheMissInput": 1.5, "cacheHitInput": 0.05, "output": 4.5 }, - "usd": { "cacheMissInput": 0.22, "cacheHitInput": 0.007, "output": 0.66 } + "cny": { "cacheMissInput": 1, "cacheHitInput": 0.02, "output": 4 }, + "usd": { "cacheMissInput": 0.15, "cacheHitInput": 0.003, "output": 0.6 } }, "peak": { - "cny": { "cacheMissInput": 3.0, "cacheHitInput": 0.10, "output": 9.0 }, - "usd": { "cacheMissInput": 0.44, "cacheHitInput": 0.014, "output": 1.32 } + "cny": { "cacheMissInput": 2, "cacheHitInput": 0.04, "output": 8 }, + "usd": { "cacheMissInput": 0.3, "cacheHitInput": 0.006, "output": 1.2 } }, - "verifiedAt": 1755100800000 + "verifiedAt": 1788998400000 } } ``` @@ -331,7 +331,7 @@ dsh-usage-chart/ | 组件 | 支持范围 | |---|---| -| DSH | ≥ 0.1.0-rc.6,当前按 0.1.x API 构建 | +| DSH | ≥ 0.1.0-rc.6,当前按 0.1.x API 构建;已适配 `dsh-session` 的 `snapshotEvents()` API——`Session.events` 自 `0.1.2-rc.1` 起移除,`/usage` 路由通过 `snapshotEvents()` 读取事件,并保留旧版 `events` 属性回退 | | Node.js | ≥ 20 | | Web UI | React 18 / `conversation.composer.dock` + `conversation.chat.assistant-actions` | | 系统 | macOS、Linux、Windows(纯 JavaScript,无原生依赖) | diff --git a/package-lock.json b/package-lock.json index 5f7da2e..33d0f6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-usage-chart", - "version": "1.1.2", + "version": "1.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-usage-chart", - "version": "1.1.2", + "version": "1.1.6", "license": "MIT", "devDependencies": { "@deepseek-ai/cordis": "^4.0.1", diff --git a/package.json b/package.json index ff30dfa..d3b90d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-usage-chart", - "version": "1.1.2", + "version": "1.1.6", "description": "DeepSeek 用量 / 成本 / 余额仪表盘 for DSH Web:输入框下方实时指标指示器 + 零依赖 SVG 用量可视化图表(含每轮成本视角、耗时叠加、异常标记、成本徽章、上下文压力条),token 数据来自官方 adapter 上报,成本按官方刊例价 + 用户覆盖 pricing.json 解析,余额来自官方 /user/balance 接口。", "type": "module", "sideEffects": false, diff --git a/src/client/UsageIndicator.tsx b/src/client/UsageIndicator.tsx index 30c9877..56b6aed 100644 --- a/src/client/UsageIndicator.tsx +++ b/src/client/UsageIndicator.tsx @@ -50,6 +50,23 @@ function ChartIcon(): JSX.Element { ) } +/** + * fixed 定位的包含块:任一 transform/filter/contain 祖先都会把定位基准从视口改成它自己 + * (皮肤给 dock 子元素加了 backdrop-filter,面板随之整体偏移)。 + */ +function containingBlock(start: HTMLElement): Element | null { + // 属性可能不存在(旧浏览器):undefined 一律按「不创建包含块」处理。 + const creates = (value: string | undefined): boolean => value !== undefined && value !== 'none' && value !== 'normal' + for (let node: Element | null = start; node !== null && node !== document.documentElement; node = node.parentElement) { + const style = getComputedStyle(node) + if (creates(style.transform) || creates(style.translate) || creates(style.rotate) || creates(style.scale) + || creates(style.perspective) || creates(style.filter) || creates(style.backdropFilter) + || creates(style.contain) || creates(style.containerType) + || /transform|perspective|filter/.test(style.willChange)) return node + } + return null +} + export function UsageIndicator(props: DockUsageProps): JSX.Element | null { const { useSession, useProjection, sessionId } = props const locale = useUiLocale() @@ -89,8 +106,14 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { if (el === null) return const r = el.getBoundingClientRect() const width = Math.min(Math.max(r.width, 320), 520, window.innerWidth - 16) - const left = Math.min(Math.max(8, r.left), window.innerWidth - width - 8) - setAnchor({ left, width, bottom: window.innerHeight - r.top + 8 }) + const left = Math.min(Math.max(8, r.left), Math.max(8, window.innerWidth - width - 8)) + // 视口坐标 → 包含块坐标:面板是 fixed,祖先有 transform/filter/contain 时 left/bottom 以它为准。 + const base = containingBlock(el)?.getBoundingClientRect() + setAnchor({ + left: left - (base?.left ?? 0), + width, + bottom: (base?.bottom ?? window.innerHeight) - r.top + 8, + }) }, []) useLayoutEffect(() => { diff --git a/src/index.ts b/src/index.ts index 5ef5eb6..eee6186 100644 --- a/src/index.ts +++ b/src/index.ts @@ -364,7 +364,8 @@ export function apply(ctx: Context, config: Config = {}): void { const pricingFile = config.pricingFile?.trim() || defaultPricingFile() const fileSource = filePricingSource(pricingFile) const resolver = createPricingResolver(fileSource) - ctx.effect(() => fileSource.dispose(), 'dsh-usage-chart: pricing file watcher') + // 注意返回的是「返回 disposer 的函数」:写成 () => fileSource.dispose() 会在注册时立刻销毁文件源。 + ctx.effect(() => () => fileSource.dispose(), 'dsh-usage-chart: pricing file watcher') ctx.effect(() => ctx.webServer.register({ kind: 'exact', diff --git a/src/pricing/source.ts b/src/pricing/source.ts index 8794d9b..70af990 100644 --- a/src/pricing/source.ts +++ b/src/pricing/source.ts @@ -31,23 +31,25 @@ export interface PricingSource { /** * 当前官方在售模型定价表(双币种 / 1M tokens,区分高峰/空闲时段)。 - * 来源:官方定价页(2026-08-26 核验) + * 来源:官方定价页(2026-09-10 核验) * - 中文页 https://api-docs.deepseek.com/zh-cn/quick_start/pricing(CNY 报价) * - 英文页 https://api-docs.deepseek.com/quick_start/pricing(USD 报价) * 高峰时段(北京时间周一至周五 09:00–12:00、14:00–18:00 = UTC 01:00–04:00、06:00–10:00) * 价格为空闲时段的两倍。 */ -export const BUILTIN_PRICING: Record = { - 'deepseek-v4-flash': { - offPeak: { - cny: { cacheMissInput: 1.5, cacheHitInput: 0.05, output: 4.5 }, - usd: { cacheMissInput: 0.22, cacheHitInput: 0.007, output: 0.66 }, - }, - peak: { - cny: { cacheMissInput: 3.0, cacheHitInput: 0.10, output: 9.0 }, - usd: { cacheMissInput: 0.44, cacheHitInput: 0.014, output: 1.32 }, - }, +const FLASH_PRICING: ModelPricing = { + offPeak: { + cny: { cacheMissInput: 1, cacheHitInput: 0.02, output: 4 }, + usd: { cacheMissInput: 0.15, cacheHitInput: 0.003, output: 0.6 }, }, + peak: { + cny: { cacheMissInput: 2, cacheHitInput: 0.04, output: 8 }, + usd: { cacheMissInput: 0.3, cacheHitInput: 0.006, output: 1.2 }, + }, +} + +export const BUILTIN_PRICING: Record = { + 'deepseek-flash': FLASH_PRICING, 'deepseek-v4-pro': { offPeak: { cny: { cacheMissInput: 4.5, cacheHitInput: 0.15, output: 13.5 }, @@ -58,23 +60,21 @@ export const BUILTIN_PRICING: Record = { usd: { cacheMissInput: 1.32, cacheHitInput: 0.044, output: 3.96 }, }, }, - 'deepseek-v4-flash-vision-exp': { - offPeak: { - cny: { cacheMissInput: 1.5, cacheHitInput: 0.05, output: 4.5 }, - usd: { cacheMissInput: 0.22, cacheHitInput: 0.007, output: 0.66 }, - }, - peak: { - cny: { cacheMissInput: 3.0, cacheHitInput: 0.10, output: 9.0 }, - usd: { cacheMissInput: 0.44, cacheHitInput: 0.014, output: 1.32 }, - }, - }, } -/** 内置表的核验日期(来源:官方定价页中/英文版,2026-08-26 抓取核验)。 */ -export const BUILTIN_VERIFIED_AT = Date.parse('2026-08-26T00:00:00Z') +/** + * 旧模型名兼容: + * - `deepseek-v4-flash` / `deepseek-v4-flash-vision-exp` 已并入 `deepseek-flash`, + * 仍按同一价格表解析,避免历史会话突然变成「未定价模型」。 + */ +BUILTIN_PRICING['deepseek-v4-flash'] = FLASH_PRICING +BUILTIN_PRICING['deepseek-v4-flash-vision-exp'] = FLASH_PRICING + +/** 内置表的核验日期(来源:官方定价页中/英文版,2026-09-10 抓取核验)。 */ +export const BUILTIN_VERIFIED_AT = Date.parse('2026-09-10T00:00:00Z') -/** 未收录模型回退:按 deepseek-v4-flash 刊例价估算并标记 ≈。 */ -export const FALLBACK_PRICING: ModelPricing = BUILTIN_PRICING['deepseek-v4-flash'] +/** 未收录模型回退:按 deepseek-flash 刊例价估算并标记 ≈。 */ +export const FALLBACK_PRICING: ModelPricing = FLASH_PRICING /** 前缀匹配(如带日期后缀的模型版本);精确匹配优先。 */ function matchEntry(table: Record, model: string): T | null { diff --git a/tests/core.test.mjs b/tests/core.test.mjs index 2e69469..30d3b1e 100644 --- a/tests/core.test.mjs +++ b/tests/core.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import test from 'node:test' +import test, { after } from 'node:test' import { DEFAULT_CNY_PER_USD, @@ -16,6 +16,10 @@ import { normalizeCurrency, } from '../lib/index.js' +/** cordis 语义:effect(setup) 立即执行 setup,把返回的 disposer 留到 fiber 销毁时再调用。 */ +const disposers = [] +after(() => { for (const dispose of disposers) if (typeof dispose === 'function') dispose() }) + function responseRecorder() { return { headers: {}, @@ -30,7 +34,7 @@ function responseRecorder() { function mountedRoutes(events = [], config = {}) { const routes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get() { return undefined }, webServer: { register(route) { routes.set(route.path, route); return () => {} }, @@ -72,17 +76,18 @@ test('foldTurnUsage ignores malformed or negative usage samples', () => { }) test('pricing matches the documented DeepSeek V4 prices (CNY + USD, peak/off-peak)', () => { - assert.deepEqual(PRICING['deepseek-v4-flash'], { + assert.deepEqual(PRICING['deepseek-flash'], { offPeak: { - cny: { cacheMissInput: 1.5, cacheHitInput: 0.05, output: 4.5 }, - usd: { cacheMissInput: 0.22, cacheHitInput: 0.007, output: 0.66 }, + cny: { cacheMissInput: 1, cacheHitInput: 0.02, output: 4 }, + usd: { cacheMissInput: 0.15, cacheHitInput: 0.003, output: 0.6 }, }, peak: { - cny: { cacheMissInput: 3.0, cacheHitInput: 0.10, output: 9.0 }, - usd: { cacheMissInput: 0.44, cacheHitInput: 0.014, output: 1.32 }, + cny: { cacheMissInput: 2, cacheHitInput: 0.04, output: 8 }, + usd: { cacheMissInput: 0.3, cacheHitInput: 0.006, output: 1.2 }, }, }) - assert.deepEqual(PRICING['deepseek-v4-flash-vision-exp'], PRICING['deepseek-v4-flash']) + assert.deepEqual(PRICING['deepseek-v4-flash'], PRICING['deepseek-flash']) + assert.deepEqual(PRICING['deepseek-v4-flash-vision-exp'], PRICING['deepseek-flash']) assert.equal(cacheHitPercent({ uncachedInputTokens: 50, outputTokens: 0, cacheReadTokens: 50, cacheWriteTokens: 0 }), 50) // 时刻未知 → 高峰价保守估算:1M 未命中输入 + 1M 输出(v4-pro 高峰 CNY = 9 + 27) assert.equal(estimateCost({ uncachedInputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 0, cacheWriteTokens: 0 }, 'deepseek-v4-pro').cny, 36) @@ -125,7 +130,7 @@ test('mounted Host routes enforce method and browser-origin guards', async () => async function balanceResponse(extraCtx = {}, config = {}) { const routes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get(name) { return name === 'credentials' ? extraCtx.credentials : undefined }, webServer: { register(route) { routes.set(route.path, route); return () => {} } }, sessions: { get() { return undefined } }, diff --git a/tests/pricing.test.mjs b/tests/pricing.test.mjs index ca02855..e1f9cbf 100644 --- a/tests/pricing.test.mjs +++ b/tests/pricing.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { mkdtemp, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import test from 'node:test' +import test, { after } from 'node:test' import { BUILTIN_VERIFIED_AT, @@ -19,14 +19,18 @@ import { tierAt, } from '../lib/index.js' -/** 2026-08-26 内置刊例价(双币种 /1M,高峰/空闲)。 */ +/** cordis 语义:effect(setup) 立即执行 setup,把返回的 disposer 留到 fiber 销毁时再调用。 */ +const disposers = [] +after(() => { for (const dispose of disposers) if (typeof dispose === 'function') dispose() }) + +/** 2026-09-10 内置刊例价(双币种 /1M,高峰/空闲)。 */ const FLASH_OFF_PEAK = { - cny: { cacheMissInput: 1.5, cacheHitInput: 0.05, output: 4.5 }, - usd: { cacheMissInput: 0.22, cacheHitInput: 0.007, output: 0.66 }, + cny: { cacheMissInput: 1, cacheHitInput: 0.02, output: 4 }, + usd: { cacheMissInput: 0.15, cacheHitInput: 0.003, output: 0.6 }, } const FLASH_PEAK = { - cny: { cacheMissInput: 3.0, cacheHitInput: 0.10, output: 9.0 }, - usd: { cacheMissInput: 0.44, cacheHitInput: 0.014, output: 1.32 }, + cny: { cacheMissInput: 2, cacheHitInput: 0.04, output: 8 }, + usd: { cacheMissInput: 0.3, cacheHitInput: 0.006, output: 1.2 }, } const PRO_OFF_PEAK = { cny: { cacheMissInput: 4.5, cacheHitInput: 0.15, output: 13.5 }, @@ -42,9 +46,10 @@ test('builtin source resolves exact and prefixed models, misses unknown', () => assert.deepEqual(source.resolve('deepseek-v4-pro').pricing.offPeak, PRO_OFF_PEAK) assert.deepEqual(source.resolve('deepseek-v4-pro').pricing.peak, PRO_PEAK) // 前缀匹配(带日期后缀的模型版本) - assert.equal(source.resolve('deepseek-v4-flash-2026-08-01').pricing.offPeak.cny.output, 4.5) - assert.equal(source.resolve('deepseek-v4-flash-2026-08-01').pricing.offPeak.usd.output, 0.66) - assert.equal(source.resolve('deepseek-v4-flash-2026-08-01').pricing.peak.usd.output, 1.32) + assert.equal(source.resolve('deepseek-flash-2026-09-01').pricing.offPeak.cny.output, 4) + assert.equal(source.resolve('deepseek-flash-2026-09-01').pricing.offPeak.usd.output, 0.6) + assert.equal(source.resolve('deepseek-flash-2026-09-01').pricing.peak.usd.output, 1.2) + assert.deepEqual(source.resolve('deepseek-flash').pricing, { offPeak: FLASH_OFF_PEAK, peak: FLASH_PEAK }) assert.deepEqual(source.resolve('deepseek-v4-flash-vision-exp').pricing.offPeak, FLASH_OFF_PEAK) assert.deepEqual(source.resolve('deepseek-v4-flash-vision-exp').pricing.peak, FLASH_PEAK) assert.equal(source.resolve('unknown-model').pricing, null) @@ -106,12 +111,12 @@ test('costSplitAt bills in both official currencies per tier', () => { const usage = { uncachedInputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 0, cacheWriteTokens: 0 } const offPeakAt = Date.UTC(1970, 0, 1, 0, 0, 0) // 北京时间 08:00 const peakAt = Date.UTC(1970, 0, 1, 2, 0, 0) // 北京时间 10:00 - // 空闲:flash 未命中 1.5 + 输出 4.5(CNY);0.22 + 0.66(USD) - assert.deepEqual(costSplitAt(usage, PRICING['deepseek-v4-flash'], offPeakAt, 'cny'), { input: 1.5, cacheRead: 0, output: 4.5, total: 6 }) - assert.deepEqual(costSplitAt(usage, PRICING['deepseek-v4-flash'], offPeakAt, 'usd'), { input: 0.22, cacheRead: 0, output: 0.66, total: 0.88 }) + // 空闲:flash 未命中 1 + 输出 4(CNY);0.15 + 0.6(USD) + assert.deepEqual(costSplitAt(usage, PRICING['deepseek-flash'], offPeakAt, 'cny'), { input: 1, cacheRead: 0, output: 4, total: 5 }) + assert.deepEqual(costSplitAt(usage, PRICING['deepseek-flash'], offPeakAt, 'usd'), { input: 0.15, cacheRead: 0, output: 0.6, total: 0.75 }) // 高峰 = 空闲 × 2 - assert.deepEqual(costSplitAt(usage, PRICING['deepseek-v4-flash'], peakAt, 'cny'), { input: 3, cacheRead: 0, output: 9, total: 12 }) - assert.deepEqual(costSplitAt(usage, PRICING['deepseek-v4-flash'], peakAt, 'usd'), { input: 0.44, cacheRead: 0, output: 1.32, total: 1.76 }) + assert.deepEqual(costSplitAt(usage, PRICING['deepseek-flash'], peakAt, 'cny'), { input: 2, cacheRead: 0, output: 8, total: 10 }) + assert.deepEqual(costSplitAt(usage, PRICING['deepseek-flash'], peakAt, 'usd'), { input: 0.3, cacheRead: 0, output: 1.2, total: 1.5 }) }) test('parsePricingFile validates entries and accepts dual-currency + legacy shapes', () => { @@ -239,12 +244,14 @@ test('file source serves prefix matches and refresh after rewrite', async () => }) test('estimateCost / pricingFor keep v0.1 compat semantics', () => { - assert.deepEqual(PRICING['deepseek-v4-flash'], { peak: FLASH_PEAK, offPeak: FLASH_OFF_PEAK }) + assert.deepEqual(PRICING['deepseek-flash'], { peak: FLASH_PEAK, offPeak: FLASH_OFF_PEAK }) + assert.deepEqual(PRICING['deepseek-v4-flash'], PRICING['deepseek-flash']) // 时刻未知 → 按高峰价保守估算:1M 未命中输入 + 1M 输出(v4-pro 高峰 CNY = 9 + 27) assert.equal(estimateCost({ uncachedInputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 0, cacheWriteTokens: 0 }, 'deepseek-v4-pro').cny, 36) + assert.equal(pricingFor('deepseek-flash').estimated, false) assert.equal(pricingFor('deepseek-v4-flash').estimated, false) assert.equal(pricingFor('unknown-model').estimated, true) - assert.equal(BUILTIN_VERIFIED_AT, Date.parse('2026-08-26T00:00:00Z')) + assert.equal(BUILTIN_VERIFIED_AT, Date.parse('2026-09-10T00:00:00Z')) }) function responseRecorder() { @@ -261,7 +268,7 @@ function responseRecorder() { test('/pricing route exposes builtin + fallback + models snapshot', async () => { const routes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get() { return undefined }, webServer: { register(route) { routes.set(route.path, route); return () => {} } }, sessions: { get() { return undefined } }, @@ -274,11 +281,50 @@ test('/pricing route exposes builtin + fallback + models snapshot', async () => const body = JSON.parse(recorder.body) assert.equal(body.ok, true) assert.equal(body.builtinVerifiedAt, BUILTIN_VERIFIED_AT) - assert.equal(body.fallback.pricing.offPeak.cny.output, 4.5) - assert.equal(body.fallback.pricing.offPeak.usd.output, 0.66) - assert.equal(body.fallback.pricing.peak.usd.output, 1.32) + assert.equal(body.fallback.pricing.offPeak.cny.output, 4) + assert.equal(body.fallback.pricing.offPeak.usd.output, 0.6) + assert.equal(body.fallback.pricing.peak.usd.output, 1.2) const models = body.models.map((m) => m.model) + assert.ok(models.includes('deepseek-flash')) assert.ok(models.includes('deepseek-v4-flash')) assert.ok(models.includes('deepseek-v4-pro')) assert.ok(models.includes('deepseek-v4-flash-vision-exp')) }) + +test('/pricing route serves the user pricing.json override (file source survives startup)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'duc-pricing-')) + const pricingFile = join(dir, 'pricing.json') + await writeFile(pricingFile, JSON.stringify({ + models: { + 'deepseek-flash': { + peak: { cny: { cacheMissInput: 19.99, cacheHitInput: 0.04, output: 8 }, usd: { cacheMissInput: 2.99, cacheHitInput: 0.006, output: 1.2 } }, + offPeak: { cny: { cacheMissInput: 9.99, cacheHitInput: 0.02, output: 4 }, usd: { cacheMissInput: 1.49, cacheHitInput: 0.003, output: 0.6 } }, + }, + }, + })) + const routes = new Map() + const disposers = [] + try { + apply({ + // cordis 语义:effect(setup) 立即执行 setup,把返回的 disposer 留到 fiber 销毁时才调用。 + // 写成 () => source.dispose() 会在这里当场销毁文件源 → 覆盖文件永不生效(本测试即为此回归)。 + effect(setup) { disposers.push(setup()) }, + get() { return undefined }, + webServer: { register(route) { routes.set(route.path, route); return () => {} } }, + sessions: { get() { return undefined } }, + }, { pricingFile }) + await new Promise((resolve) => setTimeout(resolve, 50)) + + const recorder = responseRecorder() + await routes.get('/dsh-usage-chart/pricing').handler( + { method: 'GET', url: '/dsh-usage-chart/pricing', headers: { host: 'localhost:3000' } }, + recorder, + ) + const flash = JSON.parse(recorder.body).models.find((m) => m.model === 'deepseek-flash') + assert.equal(flash.source, 'file') + assert.equal(flash.pricing.offPeak.cny.cacheMissInput, 9.99) + } finally { + for (const dispose of disposers) if (typeof dispose === 'function') dispose() + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/tests/rounds.test.mjs b/tests/rounds.test.mjs index 1aa05a9..3308751 100644 --- a/tests/rounds.test.mjs +++ b/tests/rounds.test.mjs @@ -1,16 +1,21 @@ import assert from 'node:assert/strict' -import test from 'node:test' +import test, { after } from 'node:test' import { apply, foldRounds, foldTurnUsage, tierAt } from '../lib/index.js' -/** 2026-08-17 内置刊例价(双币种 /1M):flash 高峰/空闲。 */ +/** cordis 语义:effect(setup) 立即执行 setup,把返回的 disposer 留到 fiber 销毁时再调用。 */ +const disposers = [] +after(() => { for (const dispose of disposers) if (typeof dispose === 'function') dispose() }) + + +/** 2026-09-10 内置刊例价(双币种 /1M):flash 高峰/空闲。 */ const FLASH_PEAK = { - cny: { cacheMissInput: 3.0, cacheHitInput: 0.10, output: 9.0 }, - usd: { cacheMissInput: 0.44, cacheHitInput: 0.014, output: 1.32 }, + cny: { cacheMissInput: 2, cacheHitInput: 0.04, output: 8 }, + usd: { cacheMissInput: 0.3, cacheHitInput: 0.006, output: 1.2 }, } const FLASH_OFF_PEAK = { - cny: { cacheMissInput: 1.5, cacheHitInput: 0.05, output: 4.5 }, - usd: { cacheMissInput: 0.22, cacheHitInput: 0.007, output: 0.66 }, + cny: { cacheMissInput: 1, cacheHitInput: 0.02, output: 4 }, + usd: { cacheMissInput: 0.15, cacheHitInput: 0.003, output: 0.6 }, } /** 合成事件流工具:seq 自动递增,time 由调用方给定。 */ @@ -68,12 +73,12 @@ test('foldRounds computes per-round timing, TTFT, TPS, model attribution and cos assert.equal(r1.cost.estimated, false) // 轮 1 开始于 1970-01-01T00:00:01Z = 北京时间 08:00(空闲时段)→ 空闲价 assert.equal(tierAt(r1.startedAt), 'offPeak') - assert.equal(r1.cost.cny.input, (100 / 1_000_000) * 1.5) - assert.equal(r1.cost.cny.cacheRead, (30 / 1_000_000) * 0.05) - assert.equal(r1.cost.cny.output, (20 / 1_000_000) * 4.5) - assert.equal(r1.cost.usd.input, (100 / 1_000_000) * 0.22) - assert.equal(r1.cost.usd.cacheRead, (30 / 1_000_000) * 0.007) - assert.equal(r1.cost.usd.output, (20 / 1_000_000) * 0.66) + assert.equal(r1.cost.cny.input, (100 / 1_000_000) * 1) + assert.equal(r1.cost.cny.cacheRead, (30 / 1_000_000) * 0.02) + assert.equal(r1.cost.cny.output, (20 / 1_000_000) * 4) + assert.equal(r1.cost.usd.input, (100 / 1_000_000) * 0.15) + assert.equal(r1.cost.usd.cacheRead, (30 / 1_000_000) * 0.003) + assert.equal(r1.cost.usd.output, (20 / 1_000_000) * 0.6) const r2 = rounds.find((r) => r.turn === 2) assert.ok(r2, 'round 2 present') @@ -101,11 +106,11 @@ test('foldRounds bills peak vs off-peak rounds by their start time', () => { const offPeak = rounds.find((r) => r.turn === 2) assert.equal(tierAt(peak.startedAt), 'peak') assert.equal(tierAt(offPeak.startedAt), 'offPeak') - // 1M 未命中输入:高峰 = 空闲 × 2(CNY 3.0/1.5;USD 0.44/0.22) - assert.equal(peak.cost.cny.total, 3.0) - assert.equal(peak.cost.usd.total, 0.44) - assert.equal(offPeak.cost.cny.total, 1.5) - assert.equal(offPeak.cost.usd.total, 0.22) + // 1M 未命中输入:高峰 = 空闲 × 2(CNY 2/1;USD 0.3/0.15) + assert.equal(peak.cost.cny.total, 2) + assert.equal(peak.cost.usd.total, 0.3) + assert.equal(offPeak.cost.cny.total, 1) + assert.equal(offPeak.cost.usd.total, 0.15) }) test('foldRounds tolerates missing turn/end (open round) and missing model', () => { @@ -150,9 +155,9 @@ test('foldRounds marks unknown models and uses fallback pricing', () => { assert.equal(rounds[0].cost.unknownModel, true) assert.equal(rounds[0].cost.estimated, true) assert.equal(rounds[0].cost.source, 'fallback') - // 轮开始于北京时间 08:00(空闲)→ 1M token × flash 空闲未命中价(CNY 1.5 / USD 0.22) - assert.equal(rounds[0].cost.cny.total, 1.5) - assert.equal(rounds[0].cost.usd.total, 0.22) + // 轮开始于北京时间 08:00(空闲)→ 1M token × flash 空闲未命中价(CNY 1 / USD 0.15) + assert.equal(rounds[0].cost.cny.total, 1) + assert.equal(rounds[0].cost.usd.total, 0.15) }) test('foldTurnUsage stays compatible with the v0.1 semantics', () => { @@ -206,7 +211,7 @@ test('/usage route returns rounds with cost and timing', async () => { ] const routes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get() { return undefined }, webServer: { register(route) { routes.set(route.path, route); return () => {} } }, sessions: { get(id) { return id === 'session-test' ? { id, events } : undefined } }, @@ -247,7 +252,7 @@ test('/usage route prefers snapshotEvents() when the new API is present', async ] const routes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get() { return undefined }, webServer: { register(route) { routes.set(route.path, route); return () => {} } }, // 有 snapshotEvents、无 events —— 必须走 snapshotEvents() 分支,且绝不触碰 events 属性。 @@ -273,7 +278,7 @@ test('/usage route prefers snapshotEvents() when the new API is present', async const both = { id: 'session-test', events: [], snapshotEvents: () => events } const bothRoutes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get() { return undefined }, webServer: { register(route) { bothRoutes.set(route.path, route); return () => {} } }, sessions: { get(id) { return id === 'session-test' ? both : undefined } },