Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

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
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

本文件记录本项目所有重要变更。格式遵循 [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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.5
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
Expand Down
4 changes: 2 additions & 2 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ dsh web --profile web # 启动 DSH Web(已在运行

```sh
# 方式①:显式指定目标版本
dsh plugin --profile web add dsh-usage-chart@1.1.5
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
Expand All @@ -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.5`
> `npx --yes @deepseek-ai/dsh plugin --profile web add dsh-usage-chart@1.1.6`
> (原因与解法见 [FAQ](#常见问题faq) 第一条)。

### 方式二:从 GitHub 安装(源码构建)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dsh-usage-chart",
"version": "1.1.5",
"version": "1.1.6",
"description": "DeepSeek 用量 / 成本 / 余额仪表盘 for DSH Web:输入框下方实时指标指示器 + 零依赖 SVG 用量可视化图表(含每轮成本视角、耗时叠加、异常标记、成本徽章、上下文压力条),token 数据来自官方 adapter 上报,成本按官方刊例价 + 用户覆盖 pricing.json 解析,余额来自官方 /user/balance 接口。",
"type": "module",
"sideEffects": false,
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 7 additions & 3 deletions tests/core.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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: {},
Expand All @@ -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 () => {} },
Expand Down Expand Up @@ -126,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 } },
Expand Down
46 changes: 44 additions & 2 deletions tests/pricing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,6 +19,10 @@ import {
tierAt,
} from '../lib/index.js'

/** 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, cacheHitInput: 0.02, output: 4 },
Expand Down Expand Up @@ -264,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 } },
Expand All @@ -286,3 +290,41 @@ test('/pricing route exposes builtin + fallback + models snapshot', async () =>
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 })
}
})
13 changes: 9 additions & 4 deletions tests/rounds.test.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
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'

/** 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: 2, cacheHitInput: 0.04, output: 8 },
Expand Down Expand Up @@ -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 } },
Expand Down Expand Up @@ -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 属性。
Expand All @@ -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 } },
Expand Down
Loading