dsh-plugin-cost-meter 0.0.0-stage → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/README.zh.md +159 -0
- package/catalog/prices.json +73 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +148 -0
- package/lib/index.js +574 -0
- package/package.json +69 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
## 0.5.1
|
|
6
|
+
|
|
7
|
+
- Manual `config.models` entries now outrank every automatic source even at
|
|
8
|
+
equal specificity (previously a same-route catalog entry could win). Added a
|
|
9
|
+
regression test for the tie-break.
|
|
10
|
+
|
|
11
|
+
## 0.5.0
|
|
12
|
+
|
|
13
|
+
- **Automatic silent price updates**: bundled `catalog/prices.json`, an
|
|
14
|
+
out-of-band remote catalog (`catalogUrl`, default derived from the package
|
|
15
|
+
repository), and a periodic refresh (`refreshMinutes`, default 360) that
|
|
16
|
+
swaps the live table in place. A failed refresh keeps the last good rates.
|
|
17
|
+
- **Weekday-aware peak hours**: peak now applies only on the configured
|
|
18
|
+
weekdays (`peak.days`, default Monday–Friday), so weekends bill off-peak.
|
|
19
|
+
This fixes weekend over-billing.
|
|
20
|
+
- **Explicit peak/off-peak rates**: catalog entries may carry
|
|
21
|
+
`cacheMissOffPeak` / `cacheHitOffPeak` / `outputOffPeak`; the multiplier is
|
|
22
|
+
only a fallback.
|
|
23
|
+
- **Route-aware pricing**: usage is bucketed per `{ provider, model }`; lookups
|
|
24
|
+
support `provider/*`, model wildcards, and case/space/punctuation-insensitive
|
|
25
|
+
model ids. Manual config keys may be bare (`deepseek-flash`) or routed.
|
|
26
|
+
- **Currency conversion**: a USD catalog can be displayed in CNY via
|
|
27
|
+
`config.fx.cnyPerUsd` (default 7.1).
|
|
28
|
+
- **No fabricated prices**: a route with no matching rate reports
|
|
29
|
+
`priced: false` and the badge shows "未配置" instead of a fallback amount.
|
|
30
|
+
- Projection state bumped to `stateVersion 3`; tests cover the new behaviour.
|
|
31
|
+
|
|
32
|
+
## 0.4.0
|
|
33
|
+
|
|
34
|
+
- Package as a `dsh.bundle` profile layer: `dsh plugin add dsh-plugin-cost-meter`
|
|
35
|
+
now activates the plugin automatically (no manual patch row required).
|
|
36
|
+
- Split documentation into an English `README.md` and a Chinese `README.zh.md`.
|
|
37
|
+
- Add contract tests (`npm test`), `LICENSE`, `.gitignore`, and `CHANGELOG.md`.
|
|
38
|
+
- Add npm/GitHub package metadata (`repository`, `homepage`, `bugs`, `keywords`).
|
|
39
|
+
|
|
40
|
+
## 0.3.2
|
|
41
|
+
|
|
42
|
+
- Fix the DSH 0.2.1 compatibility gate: declare `engines.dsh` and widen the
|
|
43
|
+
`@deepseek-ai/dsh-session-projection` peer range to `>=0.1.5-rc.1 <1.0.0`.
|
|
44
|
+
|
|
45
|
+
## 0.3.1
|
|
46
|
+
|
|
47
|
+
- Price the new `deepseek-flash` model id (DSH 0.1.7+ adapter catalog); keep
|
|
48
|
+
`deepseek-v4-flash` for replayed older sessions.
|
|
49
|
+
|
|
50
|
+
## 0.3.0
|
|
51
|
+
|
|
52
|
+
- Migrate to the DSH >= 0.1.5 session-projection contract:
|
|
53
|
+
`stateSchema` + `wire: { viewSchema, view }`.
|
|
54
|
+
- Replace the removed `@deepseek-ai/dsh-client-runtime` client dependency with
|
|
55
|
+
`dsh-client-ui-renderer` and `dsh-client-ui-session`.
|
|
56
|
+
|
|
57
|
+
## 0.2.0
|
|
58
|
+
|
|
59
|
+
- Peak/off-peak (time-of-day) pricing: each request is bucketed by its own
|
|
60
|
+
`event.time`, with a configurable multiplier, time zone, and peak windows.
|
|
61
|
+
|
|
62
|
+
## 0.1.0
|
|
63
|
+
|
|
64
|
+
- Initial release: per-session cost badge in the conversation header, with a
|
|
65
|
+
configurable price table and an optional JSON price source.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 doubsumi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,158 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-plugin-cost-meter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-plugin-cost-meter)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://github.com/topics/dsh-plugin)
|
|
6
|
+
|
|
7
|
+
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin that puts a **live cost badge in the conversation header (top-right)** and prices every request with **peak / off-peak (time-of-day) tiers**.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
11
|
+
│ Session title [¥0.0042] │ ← badge here
|
|
12
|
+
├──────────────────────────────────────────────────────────────┤
|
|
13
|
+
│ … conversation … │
|
|
14
|
+
└──────────────────────────────────────────────────────────────┘
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Live** — the badge updates as the host reports provider usage; no polling, no timers.
|
|
20
|
+
- **Historically correct** — each request is priced by its **own wall-clock timestamp**, so a session that spans peak and off-peak stays accurate even if the page sits open overnight.
|
|
21
|
+
- **Weekday-aware peak tiers** — DeepSeek bills peak only **Monday–Friday**; weekends are off-peak all day. The schedule is configurable (windows, weekdays, multiplier, time zone).
|
|
22
|
+
- **Silent price sync** — a bundled price catalog plus an out-of-band remote catalog refreshed on an interval. Push a new `catalog/prices.json` and every installed copy picks it up **without a plugin release**; a failed refresh keeps the last good rates.
|
|
23
|
+
- **Explicit peak/off-peak rates** — models may carry an explicit discounted pair (`cacheMiss` + `cacheMissOffPeak`), not just a multiplier.
|
|
24
|
+
- **Route-aware matching** — `provider/model`, `provider/*`, `*`-model wildcards, and case/space/punctuation-insensitive model ids.
|
|
25
|
+
- **Never fakes a number** — an unpriced route renders `未配置` (`unpriced`) instead of a made-up fallback rate.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
### From npm (recommended)
|
|
30
|
+
|
|
31
|
+
```cmd
|
|
32
|
+
dsh plugin --profile web add dsh-plugin-cost-meter
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The package declares `dsh.bundle`, so `dsh plugin add` activates it as a profile layer automatically — no manual `cordis.patch.yml` row is required.
|
|
36
|
+
|
|
37
|
+
### From a local checkout
|
|
38
|
+
|
|
39
|
+
```cmd
|
|
40
|
+
dsh plugin --profile web add "link:E:/path/to/dsh-plugin-cost-meter"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`link:` keeps the checkout live, so edits apply after a `dsh web` restart without reinstalling.
|
|
44
|
+
|
|
45
|
+
Restart the server afterwards:
|
|
46
|
+
|
|
47
|
+
```cmd
|
|
48
|
+
dsh web
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Configure
|
|
52
|
+
|
|
53
|
+
The bundled `cordis.patch.yml` inserts the plugin row with defaults. Override it by adding a `cost-meter` row with the same `id` in your profile's `cordis.patch.yml` (`~/.dsh/profiles/web/cordis.patch.yml`):
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
- insert:
|
|
57
|
+
- id: cost-meter
|
|
58
|
+
name: dsh-plugin-cost-meter
|
|
59
|
+
config:
|
|
60
|
+
currency: CNY # display currency; empty = catalog currency
|
|
61
|
+
fx:
|
|
62
|
+
cnyPerUsd: 7.1 # used when the catalog is USD and you display CNY
|
|
63
|
+
catalogUrl: "" # "" = this package's own catalog (derived from repository)
|
|
64
|
+
refreshMinutes: 360 # silent refresh interval; 0 disables it
|
|
65
|
+
pricingTimeoutMs: 4000
|
|
66
|
+
pricingUrl: "" # optional legacy { models, default } document
|
|
67
|
+
models: # manual overrides (highest priority, display currency)
|
|
68
|
+
deepseek-official/deepseek-flash:
|
|
69
|
+
inputPerM: 1.06 # off-peak base
|
|
70
|
+
cacheReadPerM: 0.021
|
|
71
|
+
cacheWritePerM: 1.06
|
|
72
|
+
outputPerM: 4.26
|
|
73
|
+
default: # optional fallback; omit to report unknown routes as unpriced
|
|
74
|
+
inputPerM: 1
|
|
75
|
+
outputPerM: 4
|
|
76
|
+
peak:
|
|
77
|
+
enabled: true
|
|
78
|
+
multiplier: 2 # used only when a rate has no explicit off-peak value
|
|
79
|
+
timezoneOffsetMinutes: 480 # UTC+8
|
|
80
|
+
days: [1, 2, 3, 4, 5] # ISO weekdays; weekends are off-peak
|
|
81
|
+
windows: [[9, 12], [14, 18]]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Two rate styles are accepted everywhere:
|
|
85
|
+
|
|
86
|
+
- **Catalog style** (peak base + optional off-peak): `cacheMiss`, `cacheHit`, `cacheWrite`, `output`, and `cacheMissOffPeak`, `cacheHitOffPeak`, `cacheWriteOffPeak`, `outputOffPeak`.
|
|
87
|
+
- **Legacy style** (off-peak base): `inputPerM`, `cacheReadPerM`, `cacheWritePerM`, `outputPerM` (with optional `*Peak` fields).
|
|
88
|
+
|
|
89
|
+
## Automatic price updates
|
|
90
|
+
|
|
91
|
+
Prices resolve in this order (highest wins):
|
|
92
|
+
|
|
93
|
+
1. `config.models` / `config.default`
|
|
94
|
+
2. `config.pricingUrl` (legacy document)
|
|
95
|
+
3. the **remote catalog** at `config.catalogUrl` — default: this package's own `catalog/prices.json` on GitHub, derived from `package.json.repository`
|
|
96
|
+
4. the **bundled** `catalog/prices.json` (offline fallback)
|
|
97
|
+
|
|
98
|
+
A timer re-reads the remote catalog and `pricingUrl` every `refreshMinutes` (default 6 hours) and swaps the live table in place:
|
|
99
|
+
|
|
100
|
+
- a failed refresh **keeps the last successful data**;
|
|
101
|
+
- a source that never loaded does not clear anything;
|
|
102
|
+
- a price change applies to the whole session on the **next event**, and immediately on a page reload.
|
|
103
|
+
|
|
104
|
+
This makes the catalog the single place to maintain rates: **edit and push `catalog/prices.json`, and installed copies update themselves** — no new plugin release. Forks get their own update channel automatically because the default URL is derived from `package.json.repository`.
|
|
105
|
+
|
|
106
|
+
To point at a private mirror or a local file, set `catalogUrl` (`https://…`, a path, or `file:///…`). Set it to `""` to disable remote syncing.
|
|
107
|
+
|
|
108
|
+
## Peak / off-peak
|
|
109
|
+
|
|
110
|
+
- Tiering uses the **request's own `event.time`**, never "now".
|
|
111
|
+
- Peak applies only on the configured weekdays (`days`, default Monday–Friday); weekends are off-peak all day.
|
|
112
|
+
- The schedule comes from the catalog's `peakSchedule` (time zone, days, windows, off-peak ratio) and can be overridden field by field in `config.peak`.
|
|
113
|
+
- `peak.enabled: false` bills one flat rate; `peak.timezoneOffsetMinutes` shifts the wall clock for the window check.
|
|
114
|
+
|
|
115
|
+
The hover tooltip shows the current tier, the multiplier, the route, and the token breakdown.
|
|
116
|
+
|
|
117
|
+
## Pricing model
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
cost = Σ_routes [ cost_off(route) + cost_peak(route) ]
|
|
121
|
+
|
|
122
|
+
cost_tier(route) = (uncachedInput × miss
|
|
123
|
+
+ cacheRead × hit
|
|
124
|
+
+ cacheWrite × write
|
|
125
|
+
+ output × out) / 1_000_000
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Totals are accumulated per `{ provider, model }` route and per tier, so switching models mid-session keeps each route on its own price. A repeated usage sample for the same `(turn, step)` replaces the earlier one instead of double-counting.
|
|
129
|
+
|
|
130
|
+
## Compatibility
|
|
131
|
+
|
|
132
|
+
| Surface | DSH ≤ 0.1.0-rc.6 | DSH ≥ 0.1.5 |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| Projection contract | `schema` + `view` | `stateSchema` + `wire: { viewSchema, view }` |
|
|
135
|
+
| Client deps | `@deepseek-ai/dsh-client-runtime` | `@deepseek-ai/dsh-client-ui-renderer`, `@deepseek-ai/dsh-client-ui-session` |
|
|
136
|
+
| Compatibility gate | — | `engines.dsh` / `peerDependencies` must satisfy the running version |
|
|
137
|
+
|
|
138
|
+
The badge uses the additive `conversation.session.header.utilities` slot; `…header.corner` is a `single` slot already occupied by a shipped plugin.
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```cmd
|
|
143
|
+
npm install
|
|
144
|
+
npm test
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`test/contract.test.mjs` covers the projection contract, weekday peak, catalog parsing, explicit dual rates, currency conversion, normalization/wildcards, unpriced routes, and manual overrides. It runs offline (`catalogUrl: ""`).
|
|
148
|
+
|
|
149
|
+
## Notes
|
|
150
|
+
|
|
151
|
+
- Costs are **estimates**: they depend on the provider-reported `usage` and on the price table.
|
|
152
|
+
- The badge is **per session**; it does not aggregate sub-sessions or the whole account.
|
|
153
|
+
- The bundled catalog is a dated snapshot — verify it against the [official pricing page](https://api-docs.deepseek.com/quick_start/pricing) and update `catalog/prices.json` when rates change.
|
|
154
|
+
- The plugin is a Web surface plugin; compositions without `sessionProjections` (e.g. headless) skip registration.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
[MIT](LICENSE)
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# dsh-plugin-cost-meter
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-plugin-cost-meter)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
[English](README.md) | 中文
|
|
7
|
+
|
|
8
|
+
一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:在**会话头部右上角**实时显示当前对话的**消耗金额**,按**峰谷分时**计价,并**自动静默更新价格**。
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
12
|
+
│ 会话标题 [¥0.0042] │ ← 徽章位置
|
|
13
|
+
├──────────────────────────────────────────────────────────────┤
|
|
14
|
+
│ … 对话内容 … │
|
|
15
|
+
└──────────────────────────────────────────────────────────────┘
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 特性
|
|
19
|
+
|
|
20
|
+
- **实时**:宿主上报用量后徽章即更新,不轮询、不定时器。
|
|
21
|
+
- **历史准确**:每个请求按**它自己的发生时间**判峰谷,会话横跨峰谷、页面开一夜也不漂。
|
|
22
|
+
- **周内高峰**:DeepSeek 高峰只在**周一至周五**,周末全天低谷;窗口、星期、倍率、时区全部可配。
|
|
23
|
+
- **静默同步价格**:内置价格目录 + 远程目录定时刷新。**改并推送 `catalog/prices.json` 即可**,已安装的插件自动跟上,无需发新版;刷新失败保留上一次成功的数据。
|
|
24
|
+
- **显式峰谷双价**:支持 `cacheMiss` + `cacheMissOffPeak` 这样的成对费率,而不只是乘一个倍率。
|
|
25
|
+
- **路由感知匹配**:支持 `provider/model`、`provider/*`、模型通配,模型 id 忽略大小写/空格/标点差异。
|
|
26
|
+
- **绝不编造数字**:未配置价格的模型显示**"未配置"**,而不是用一个兜底价假装准确。
|
|
27
|
+
|
|
28
|
+
## 安装
|
|
29
|
+
|
|
30
|
+
### 从 npm 安装(推荐)
|
|
31
|
+
|
|
32
|
+
```cmd
|
|
33
|
+
dsh plugin --profile web add dsh-plugin-cost-meter
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
插件声明了 `dsh.bundle`,`dsh plugin add` 会自动把它注册为 profile 层,**无需手写** `cordis.patch.yml`。
|
|
37
|
+
|
|
38
|
+
### 本地源码安装
|
|
39
|
+
|
|
40
|
+
```cmd
|
|
41
|
+
dsh plugin --profile web add "link:E:/path/to/dsh-plugin-cost-meter"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`link:` 方式下改源码后重启 `dsh web` 即可生效。
|
|
45
|
+
|
|
46
|
+
随后重启服务:
|
|
47
|
+
|
|
48
|
+
```cmd
|
|
49
|
+
dsh web
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 配置
|
|
53
|
+
|
|
54
|
+
插件自带的 `cordis.patch.yml` 只插入默认行;在**你自己的** `cordis.patch.yml`(`~/.dsh/profiles/web/cordis.patch.yml`)里用相同 `id` 覆盖:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
- insert:
|
|
58
|
+
- id: cost-meter
|
|
59
|
+
name: dsh-plugin-cost-meter
|
|
60
|
+
config:
|
|
61
|
+
currency: CNY # 显示币种;留空 = 跟随目录币种
|
|
62
|
+
fx:
|
|
63
|
+
cnyPerUsd: 7.1 # 目录是美元、显示人民币时使用
|
|
64
|
+
catalogUrl: "" # "" = 用本包自带目录(按 repository 推导远程地址)
|
|
65
|
+
refreshMinutes: 360 # 静默刷新间隔;0 表示关闭
|
|
66
|
+
pricingTimeoutMs: 4000
|
|
67
|
+
pricingUrl: "" # 可选的旧版 { models, default } 文档
|
|
68
|
+
models: # 手动覆盖(优先级最高,显示币种)
|
|
69
|
+
deepseek-official/deepseek-flash:
|
|
70
|
+
inputPerM: 1.06 # 空闲价基准
|
|
71
|
+
cacheReadPerM: 0.021
|
|
72
|
+
cacheWritePerM: 1.06
|
|
73
|
+
outputPerM: 4.26
|
|
74
|
+
default: # 可选兜底;不写则未命中路由显示"未配置"
|
|
75
|
+
inputPerM: 1
|
|
76
|
+
outputPerM: 4
|
|
77
|
+
peak:
|
|
78
|
+
enabled: true
|
|
79
|
+
multiplier: 2 # 仅在费率没有显式空闲价时使用
|
|
80
|
+
timezoneOffsetMinutes: 480 # UTC+8
|
|
81
|
+
days: [1, 2, 3, 4, 5] # ISO 星期;周末为空闲
|
|
82
|
+
windows: [[9, 12], [14, 18]]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
费率支持两种写法:
|
|
86
|
+
|
|
87
|
+
- **目录风格**(高峰基准 + 可选空闲价):`cacheMiss`、`cacheHit`、`cacheWrite`、`output`,以及 `cacheMissOffPeak`、`cacheHitOffPeak`、`cacheWriteOffPeak`、`outputOffPeak`。
|
|
88
|
+
- **旧版风格**(空闲基准):`inputPerM`、`cacheReadPerM`、`cacheWritePerM`、`outputPerM`(可加 `*Peak`)。
|
|
89
|
+
|
|
90
|
+
## 自动更新价格
|
|
91
|
+
|
|
92
|
+
价格按优先级合并(高者胜):
|
|
93
|
+
|
|
94
|
+
1. `config.models` / `config.default`
|
|
95
|
+
2. `config.pricingUrl`(旧版文档)
|
|
96
|
+
3. **远程目录** `config.catalogUrl` —— 默认由 `package.json.repository` 推导为本包在 GitHub 上的 `catalog/prices.json`
|
|
97
|
+
4. 包内自带的 `catalog/prices.json`(离线兜底)
|
|
98
|
+
|
|
99
|
+
定时器每 `refreshMinutes`(默认 6 小时)重读远程目录与 `pricingUrl`,并**就地替换**生效价格表:
|
|
100
|
+
|
|
101
|
+
- 刷新失败**保留上一次成功的数据**;
|
|
102
|
+
- 从未成功过的源不会清空任何已有数据;
|
|
103
|
+
- 价格变更会在**下一次会话事件**时对整个会话重新计价;刷新页面则立即生效。
|
|
104
|
+
|
|
105
|
+
因此价格只需维护**一处**:**改并推送 `catalog/prices.json`,已安装的插件自动更新**,不用发版。默认地址由 `package.json.repository` 推导,fork 出来的仓库自动拥有自己的更新通道。
|
|
106
|
+
|
|
107
|
+
需要私有镜像或本地文件时,把 `catalogUrl` 指向 `https://…`、本地路径或 `file:///…`;设为 `""` 可关闭远程同步。
|
|
108
|
+
|
|
109
|
+
## 峰谷分时
|
|
110
|
+
|
|
111
|
+
- 时段判定用**请求自身的 `event.time`**,不是"现在"。
|
|
112
|
+
- 高峰只在配置的星期生效(`days`,默认周一至周五),周末全天空闲。
|
|
113
|
+
- 时段来自目录里的 `peakSchedule`(时区、星期、窗口、低谷比例),并可用 `config.peak` 逐字段覆盖。
|
|
114
|
+
- `peak.enabled: false` 退化为固定费率;`peak.timezoneOffsetMinutes` 决定窗口按哪个墙钟判断。
|
|
115
|
+
|
|
116
|
+
悬停徽章可看到当前时段、倍率、模型路由与 token 明细。
|
|
117
|
+
|
|
118
|
+
## 计价模型
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
cost = Σ_routes [ cost_off(route) + cost_peak(route) ]
|
|
122
|
+
|
|
123
|
+
cost_tier(route) = (未命中输入 × miss
|
|
124
|
+
+ 缓存命中 × hit
|
|
125
|
+
+ 缓存写入 × write
|
|
126
|
+
+ 输出 × out) / 1_000_000
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
用量按 `{ provider, model }` 路由 × 时段分别累计,所以中途换模型也各按各的价;同一 `(turn, step)` 重复上报会替换而非重复累计。
|
|
130
|
+
|
|
131
|
+
## 兼容性
|
|
132
|
+
|
|
133
|
+
| 接口 | DSH ≤ 0.1.0-rc.6 | DSH ≥ 0.1.5 |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| 投影契约 | `schema` + `view` | `stateSchema` + `wire: { viewSchema, view }` |
|
|
136
|
+
| 客户端依赖 | `@deepseek-ai/dsh-client-runtime` | `@deepseek-ai/dsh-client-ui-renderer`、`@deepseek-ai/dsh-client-ui-session` |
|
|
137
|
+
| 兼容性闸门 | — | `engines.dsh` / `peerDependencies` 必须满足运行时版本 |
|
|
138
|
+
|
|
139
|
+
徽章使用增量式的 `conversation.session.header.utilities` 槽位;`…header.corner` 是 `single` 槽位且已被内置插件占用。
|
|
140
|
+
|
|
141
|
+
## 开发
|
|
142
|
+
|
|
143
|
+
```cmd
|
|
144
|
+
npm install
|
|
145
|
+
npm test
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`test/contract.test.mjs` 覆盖:投影契约、周内高峰、目录解析、显式双价、币种换算、归一化/通配、未配置路由、手动覆盖。测试离线运行(`catalogUrl: ""`)。
|
|
149
|
+
|
|
150
|
+
## 说明
|
|
151
|
+
|
|
152
|
+
- 金额是**估算**:依赖提供方上报的 `usage` 与价格表。
|
|
153
|
+
- 徽章是**单会话**口径,不聚合子会话或整个账号。
|
|
154
|
+
- 内置目录是某个时点的快照,请对照[官方价格页](https://api-docs.deepseek.com/quick_start/pricing)核对,并在费率变化时更新 `catalog/prices.json`。
|
|
155
|
+
- 插件面向 Web 表层;没有 `sessionProjections` 的组合(如 headless)会跳过注册。
|
|
156
|
+
|
|
157
|
+
## 许可证
|
|
158
|
+
|
|
159
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"updatedAt": "2026-10-18",
|
|
4
|
+
"currency": "USD",
|
|
5
|
+
"note": "Out-of-band price catalog for dsh-plugin-cost-meter. Every rate is USD per 1,000,000 tokens. `cacheMiss`/`cacheHit`/`output` are the PEAK (full) rates; the `*OffPeak` fields are the discounted rates. The plugin reads this file from its own package and, when `catalogUrl` is set (the default derives from package.json repository), re-reads the same shape from the network on an interval — pushing a new version of this file updates every installed copy without publishing a plugin release. Rates captured 2026-09 from the vendor-published table; DeepSeek announced Flash price cuts in 2026-09, so verify against https://api-docs.deepseek.com/quick_start/pricing before relying on them.",
|
|
6
|
+
"providers": {
|
|
7
|
+
"deepseek-official": {
|
|
8
|
+
"label": { "zh": "DeepSeek 官方", "en": "DeepSeek Official" },
|
|
9
|
+
"api": "openai-completions",
|
|
10
|
+
"docs": "https://api-docs.deepseek.com/quick_start/pricing",
|
|
11
|
+
"peakSchedule": {
|
|
12
|
+
"timezone": "UTC",
|
|
13
|
+
"days": [1, 2, 3, 4, 5],
|
|
14
|
+
"windows": [
|
|
15
|
+
{ "from": "01:00", "to": "04:00" },
|
|
16
|
+
{ "from": "06:00", "to": "10:00" }
|
|
17
|
+
],
|
|
18
|
+
"offPeakRatio": 0.5,
|
|
19
|
+
"note": "Peak hours are 01:00-04:00 and 06:00-10:00 UTC Monday through Friday (09:00-12:00 and 14:00-18:00 Beijing); every other hour, plus the whole weekend, is off-peak at half price."
|
|
20
|
+
},
|
|
21
|
+
"models": {
|
|
22
|
+
"deepseek-flash": {
|
|
23
|
+
"label": "DeepSeek-V4.1-Flash",
|
|
24
|
+
"cacheHit": 0.006,
|
|
25
|
+
"cacheHitOffPeak": 0.003,
|
|
26
|
+
"cacheMiss": 0.3,
|
|
27
|
+
"cacheMissOffPeak": 0.15,
|
|
28
|
+
"output": 1.2,
|
|
29
|
+
"outputOffPeak": 0.6
|
|
30
|
+
},
|
|
31
|
+
"deepseek-v4-flash": {
|
|
32
|
+
"label": "DeepSeek-V4.1-Flash (legacy alias)",
|
|
33
|
+
"note": "The V4 Flash name is still accepted and served by V4.1 Flash at the Flash price.",
|
|
34
|
+
"cacheHit": 0.006,
|
|
35
|
+
"cacheHitOffPeak": 0.003,
|
|
36
|
+
"cacheMiss": 0.3,
|
|
37
|
+
"cacheMissOffPeak": 0.15,
|
|
38
|
+
"output": 1.2,
|
|
39
|
+
"outputOffPeak": 0.6
|
|
40
|
+
},
|
|
41
|
+
"deepseek-v4-pro": {
|
|
42
|
+
"label": "DeepSeek-V4-Pro-0813",
|
|
43
|
+
"cacheHit": 0.044,
|
|
44
|
+
"cacheHitOffPeak": 0.022,
|
|
45
|
+
"cacheMiss": 1.32,
|
|
46
|
+
"cacheMissOffPeak": 0.66,
|
|
47
|
+
"output": 3.96,
|
|
48
|
+
"outputOffPeak": 1.98
|
|
49
|
+
},
|
|
50
|
+
"deepseek-chat": {
|
|
51
|
+
"label": "DeepSeek Chat (legacy)",
|
|
52
|
+
"note": "Legacy flat-price model; the off-peak fields repeat the peak rate so it is never discounted.",
|
|
53
|
+
"cacheHit": 0.07,
|
|
54
|
+
"cacheHitOffPeak": 0.07,
|
|
55
|
+
"cacheMiss": 0.27,
|
|
56
|
+
"cacheMissOffPeak": 0.27,
|
|
57
|
+
"output": 1.1,
|
|
58
|
+
"outputOffPeak": 1.1
|
|
59
|
+
},
|
|
60
|
+
"deepseek-reasoner": {
|
|
61
|
+
"label": "DeepSeek Reasoner (legacy)",
|
|
62
|
+
"note": "Legacy flat-price model; the off-peak fields repeat the peak rate so it is never discounted.",
|
|
63
|
+
"cacheHit": 0.14,
|
|
64
|
+
"cacheHitOffPeak": 0.14,
|
|
65
|
+
"cacheMiss": 0.55,
|
|
66
|
+
"cacheMissOffPeak": 0.55,
|
|
67
|
+
"output": 2.19,
|
|
68
|
+
"outputOffPeak": 2.19
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# dsh-cost-meter — profile-layer patch.
|
|
2
|
+
#
|
|
3
|
+
# Inserted when this package is installed as a bundle (`dsh plugin add`).
|
|
4
|
+
# The row is intentionally config-free: set `config.currency` / `config.models`
|
|
5
|
+
# / `config.peak` in your profile's own `cordis.patch.yml` (same id
|
|
6
|
+
# `cost-meter`), which overrides this row by id.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: cost-meter
|
|
9
|
+
name: dsh-plugin-cost-meter
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-cost-meter — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Renders a small, passive cost pill into the conversation header's
|
|
5
|
+
* right-aligned utilities slot (`conversation.session.header.utilities` — the
|
|
6
|
+
* top-right corner of the session header). The value is the `costUsage`
|
|
7
|
+
* session projection registered by the host half, read through the framework
|
|
8
|
+
* `useProjection` hook; it updates live as the host reports provider usage.
|
|
9
|
+
*
|
|
10
|
+
* An unpriced route renders "未配置" instead of a fabricated amount, matching
|
|
11
|
+
* the host's `priced: false` contract.
|
|
12
|
+
*
|
|
13
|
+
* Loaded by the client module loader (`window.__ModuleLoader__`).
|
|
14
|
+
*/
|
|
15
|
+
const TAG_ID = "dsh-plugin-cost-meter/client.css";
|
|
16
|
+
|
|
17
|
+
const css = [
|
|
18
|
+
".dsh-cost-meter-badge{",
|
|
19
|
+
"border:1px solid var(--dsw-alias-border-l2);",
|
|
20
|
+
"color:var(--dsw-alias-label-primary);",
|
|
21
|
+
"font-family:var(--dsw-font-family);",
|
|
22
|
+
"background:transparent;",
|
|
23
|
+
"border-radius:18px;",
|
|
24
|
+
"height:32px;",
|
|
25
|
+
"padding:6px 12px;",
|
|
26
|
+
"font-size:13px;",
|
|
27
|
+
"font-weight:400;",
|
|
28
|
+
"line-height:20px;",
|
|
29
|
+
"display:inline-flex;",
|
|
30
|
+
"align-items:center;",
|
|
31
|
+
"white-space:nowrap;",
|
|
32
|
+
"font-variant-numeric:tabular-nums;",
|
|
33
|
+
"cursor:default;",
|
|
34
|
+
"}",
|
|
35
|
+
".dsh-cost-meter-badge[data-priced='0']{",
|
|
36
|
+
"color:var(--dsw-alias-label-dimmed);",
|
|
37
|
+
"border-style:dashed;",
|
|
38
|
+
"}"
|
|
39
|
+
].join("");
|
|
40
|
+
|
|
41
|
+
window.__ModuleLoader__.load({
|
|
42
|
+
id: "dsh-plugin-cost-meter",
|
|
43
|
+
factory: (require) => {
|
|
44
|
+
var module = { exports: {} };
|
|
45
|
+
var exports = module.exports;
|
|
46
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
47
|
+
var react_jsx_runtime = require("react/jsx-runtime");
|
|
48
|
+
|
|
49
|
+
// Inject the badge stylesheet once.
|
|
50
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(TAG_ID) + "]") === null) {
|
|
51
|
+
var tag = document.createElement("style");
|
|
52
|
+
tag.dataset.plugin = "dsh-plugin-cost-meter";
|
|
53
|
+
tag.dataset.pluginCss = TAG_ID;
|
|
54
|
+
tag.textContent = css;
|
|
55
|
+
document.head.appendChild(tag);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
var cssClass = { badge: "dsh-cost-meter-badge" };
|
|
59
|
+
|
|
60
|
+
/** Format a cost with adaptive precision for tiny per-conversation sums. */
|
|
61
|
+
function formatCost(cost, currency) {
|
|
62
|
+
var abs = Math.abs(cost);
|
|
63
|
+
var fractionDigits;
|
|
64
|
+
if (abs === 0) fractionDigits = 2;
|
|
65
|
+
else if (abs >= 1) fractionDigits = 2;
|
|
66
|
+
else if (abs >= 0.01) fractionDigits = 4;
|
|
67
|
+
else if (abs >= 0.0001) fractionDigits = 6;
|
|
68
|
+
else fractionDigits = 8;
|
|
69
|
+
try {
|
|
70
|
+
return new Intl.NumberFormat(undefined, {
|
|
71
|
+
style: "currency",
|
|
72
|
+
currency: currency,
|
|
73
|
+
currencyDisplay: "narrowSymbol",
|
|
74
|
+
minimumFractionDigits: fractionDigits,
|
|
75
|
+
maximumFractionDigits: fractionDigits
|
|
76
|
+
}).format(cost);
|
|
77
|
+
} catch (_err) {
|
|
78
|
+
return cost.toFixed(fractionDigits) + " " + currency;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Hover breakdown for the badge. */
|
|
83
|
+
function tooltip(value) {
|
|
84
|
+
var tier = value.currentTier === "peak" ? "高峰时段 (x" + value.peakMultiplier + ")" : "空闲时段";
|
|
85
|
+
var route = (value.provider != null ? value.provider + "/" : "") + (value.model != null ? value.model : "未知");
|
|
86
|
+
var lines = [
|
|
87
|
+
"当前对话估算费用",
|
|
88
|
+
"当前时段: " + tier,
|
|
89
|
+
"模型: " + route,
|
|
90
|
+
"输入: " + value.uncachedInputTokens.toLocaleString() + " tokens",
|
|
91
|
+
"输出: " + value.outputTokens.toLocaleString() + " tokens",
|
|
92
|
+
"缓存命中读取: " + value.cacheReadTokens.toLocaleString() + " tokens",
|
|
93
|
+
"缓存写入: " + value.cacheWriteTokens.toLocaleString() + " tokens"
|
|
94
|
+
];
|
|
95
|
+
if (!value.priced) {
|
|
96
|
+
lines.push("");
|
|
97
|
+
lines.push("未配置价格: " + (value.unpricedRoutes || []).join(", "));
|
|
98
|
+
lines.push("请在插件 config.models 或价格目录中补充该模型");
|
|
99
|
+
} else {
|
|
100
|
+
lines.push("");
|
|
101
|
+
lines.push("价格随目录自动同步; 可在插件 config 中覆盖");
|
|
102
|
+
}
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The badge. Session-scope standard kit supplies `useProjection`; the
|
|
108
|
+
* value reads `undefined` until the first projection baseline lands.
|
|
109
|
+
*/
|
|
110
|
+
function CostBadge(props) {
|
|
111
|
+
var useProjection = props.useProjection;
|
|
112
|
+
var value = useProjection("costUsage");
|
|
113
|
+
if (!value) return null;
|
|
114
|
+
var priced = value.priced !== false;
|
|
115
|
+
var label = priced ? formatCost(value.cost, value.currency) : "未配置";
|
|
116
|
+
var description = tooltip(value);
|
|
117
|
+
return react_jsx_runtime.jsx("span", {
|
|
118
|
+
className: cssClass.badge,
|
|
119
|
+
title: description,
|
|
120
|
+
role: "status",
|
|
121
|
+
"aria-label": description,
|
|
122
|
+
"data-priced": priced ? "1" : "0",
|
|
123
|
+
children: label
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
var inject = ["slots"];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Mount the badge into the header utilities slot once ui-conversation
|
|
131
|
+
* declares it.
|
|
132
|
+
* @param ctx - browser root context carrying the slots service.
|
|
133
|
+
*/
|
|
134
|
+
function apply(ctx) {
|
|
135
|
+
ctx.slots.inject("conversation.session.header.utilities", function () {
|
|
136
|
+
return ctx.slots.register({
|
|
137
|
+
name: "conversation.session.header.utilities",
|
|
138
|
+
id: "cost-meter",
|
|
139
|
+
order: 0
|
|
140
|
+
}, CostBadge);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
exports.apply = apply;
|
|
145
|
+
exports.inject = inject;
|
|
146
|
+
return module.exports;
|
|
147
|
+
}
|
|
148
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-cost-meter — host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers a `costUsage` session-projection unit that folds durable provider
|
|
5
|
+
* token usage (the same `assistant/chunk` / `assistant/message` usage samples
|
|
6
|
+
* the built-in token meter consumes) and attributes each step's usage to the
|
|
7
|
+
* `{ provider, model }` route named by the latest `request/header`.
|
|
8
|
+
*
|
|
9
|
+
* Price sources, highest priority first:
|
|
10
|
+
* 1. manual `config.models` / `config.default` (display currency)
|
|
11
|
+
* 2. legacy `config.pricingUrl` JSON document (`{ models, default }`)
|
|
12
|
+
* 3. remote catalog `config.catalogUrl` (default: this package's
|
|
13
|
+
* own `catalog/prices.json` on GitHub, derived from `package.json`)
|
|
14
|
+
* 4. bundled `catalog/prices.json` (offline fallback)
|
|
15
|
+
*
|
|
16
|
+
* Silent updates: a timer re-reads the remote catalog / pricingUrl on an
|
|
17
|
+
* interval (`config.refreshMinutes`, default 360) and swaps the live table.
|
|
18
|
+
* A failed refresh keeps the last successful data; a failed source never
|
|
19
|
+
* clears data it did not create. The badge reflects a price change on the
|
|
20
|
+
* next session event (the whole session is re-priced) or a page reload.
|
|
21
|
+
*
|
|
22
|
+
* Peak / off-peak
|
|
23
|
+
* ---------------
|
|
24
|
+
* Each request is tiered by its own `event.time` (never by "now"), and the
|
|
25
|
+
* schedule supports a weekday filter — DeepSeek bills peak only Monday
|
|
26
|
+
* through Friday, so weekends are off-peak all day. Rates may be given as an
|
|
27
|
+
* explicit peak/off-peak pair (`cacheMiss` + `cacheMissOffPeak`) or as a
|
|
28
|
+
* single off-peak base scaled by `peak.multiplier`.
|
|
29
|
+
*
|
|
30
|
+
* Unknown routes are reported as `priced: false` instead of being billed at a
|
|
31
|
+
* made-up fallback rate; the badge shows "unpriced" for them.
|
|
32
|
+
*
|
|
33
|
+
* @module dsh-plugin-cost-meter
|
|
34
|
+
*/
|
|
35
|
+
import { readFileSync } from "node:fs";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { z as zod } from "zod";
|
|
38
|
+
|
|
39
|
+
/** Cordis plugin name (diagnostics). */
|
|
40
|
+
const name = "cost-meter";
|
|
41
|
+
/** No required services: `sessionProjections` is an optional capability. */
|
|
42
|
+
const inject = [];
|
|
43
|
+
|
|
44
|
+
/** Built-in schedule: DeepSeek Beijing peak, weekdays only, half price off peak. */
|
|
45
|
+
const PEAK_DEFAULTS = {
|
|
46
|
+
enabled: true,
|
|
47
|
+
multiplier: 2,
|
|
48
|
+
ratio: 0.5,
|
|
49
|
+
timezoneOffsetMinutes: 480,
|
|
50
|
+
days: [1, 2, 3, 4, 5],
|
|
51
|
+
windows: [[9, 12], [14, 18]]
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** How often a silent refresh runs when `refreshMinutes` is not configured. */
|
|
55
|
+
const DEFAULT_REFRESH_MINUTES = 360;
|
|
56
|
+
/** Default CNY/USD rate for converting a USD catalog into a CNY display. */
|
|
57
|
+
const DEFAULT_CNY_PER_USD = 7.1;
|
|
58
|
+
|
|
59
|
+
const ZERO_BUCKETS = () => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
|
|
60
|
+
|
|
61
|
+
function readJson(url) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(readFileSync(url, "utf8"));
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** This package's manifest, used to derive the default catalog URL. */
|
|
70
|
+
const PACKAGE = readJson(new URL("../package.json", import.meta.url)) ?? {};
|
|
71
|
+
/** The bundled offline catalog. */
|
|
72
|
+
const BUNDLED_CATALOG = readJson(new URL("../catalog/prices.json", import.meta.url)) ?? { schema: 1, providers: {} };
|
|
73
|
+
|
|
74
|
+
/** Finite, non-negative number or undefined. */
|
|
75
|
+
function num(value) {
|
|
76
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Matching key: case/space/punctuation-insensitive, bracket annotations dropped. */
|
|
80
|
+
function norm(value) {
|
|
81
|
+
return String(value ?? "")
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/[((\[][^)\])]*[)\])]/g, "")
|
|
84
|
+
.replace(/[\s\-_.]+/g, "");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Split a `provider/model` key; a bare key matches any provider. */
|
|
88
|
+
function splitKey(key) {
|
|
89
|
+
const index = key.indexOf("/");
|
|
90
|
+
return index < 0 ? { provider: "*", model: key } : { provider: key.slice(0, index), model: key.slice(index + 1) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse one rate entry. Catalog style (`cacheMiss`/`cacheHit`/`output`, peak
|
|
95
|
+
* base with optional `*OffPeak`) and legacy style (`*PerM`, off-peak base)
|
|
96
|
+
* are both accepted.
|
|
97
|
+
* @returns `{ peak, off }` per-1M buckets, or undefined when incomplete.
|
|
98
|
+
*/
|
|
99
|
+
function parseRates(entry, { ratio, multiplier }) {
|
|
100
|
+
if (entry == null || typeof entry !== "object") return undefined;
|
|
101
|
+
const catalogStyle = entry.cacheMiss !== undefined || entry.cacheHit !== undefined || entry.output !== undefined;
|
|
102
|
+
if (catalogStyle) {
|
|
103
|
+
const miss = num(entry.cacheMiss);
|
|
104
|
+
const out = num(entry.output);
|
|
105
|
+
if (miss === undefined || out === undefined) return undefined;
|
|
106
|
+
const hit = num(entry.cacheHit) ?? 0;
|
|
107
|
+
const write = num(entry.cacheWrite) ?? miss;
|
|
108
|
+
return {
|
|
109
|
+
peak: { miss, hit, write, out },
|
|
110
|
+
off: {
|
|
111
|
+
miss: num(entry.cacheMissOffPeak) ?? miss * ratio,
|
|
112
|
+
hit: num(entry.cacheHitOffPeak) ?? hit * ratio,
|
|
113
|
+
write: num(entry.cacheWriteOffPeak) ?? write * ratio,
|
|
114
|
+
out: num(entry.outputOffPeak) ?? out * ratio
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const miss = num(entry.inputPerM);
|
|
119
|
+
const out = num(entry.outputPerM);
|
|
120
|
+
if (miss === undefined || out === undefined) return undefined;
|
|
121
|
+
const hit = num(entry.cacheReadPerM) ?? 0;
|
|
122
|
+
const write = num(entry.cacheWritePerM) ?? miss;
|
|
123
|
+
return {
|
|
124
|
+
off: { miss, hit, write, out },
|
|
125
|
+
peak: {
|
|
126
|
+
miss: num(entry.inputPerMPeak) ?? miss * multiplier,
|
|
127
|
+
hit: num(entry.cacheReadPerMPeak) ?? hit * multiplier,
|
|
128
|
+
write: num(entry.cacheWritePerMPeak) ?? write * multiplier,
|
|
129
|
+
out: num(entry.outputPerMPeak) ?? out * multiplier
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `"01:00"` / `1` → hours past midnight (fractional). */
|
|
135
|
+
function parseClock(value) {
|
|
136
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
137
|
+
const match = /^(\d{1,2})(?::(\d{1,2}))?$/.exec(String(value ?? "").trim());
|
|
138
|
+
if (match === null) return undefined;
|
|
139
|
+
return Number(match[1]) + Number(match[2] ?? 0) / 60;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Accept `[[9,12]]`, `[{from:"09:00",to:"12:00"}]`, or `[{fromHour,toHour}]`. */
|
|
143
|
+
function parseWindows(list) {
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
146
|
+
if (Array.isArray(item) && item.length === 2) {
|
|
147
|
+
const from = Number(item[0]);
|
|
148
|
+
const to = Number(item[1]);
|
|
149
|
+
if (Number.isFinite(from) && Number.isFinite(to)) out.push([from, to]);
|
|
150
|
+
} else if (item !== null && typeof item === "object") {
|
|
151
|
+
const from = parseClock(item.from ?? item.fromHour);
|
|
152
|
+
const to = parseClock(item.to ?? item.toHour);
|
|
153
|
+
if (from !== undefined && to !== undefined) out.push([from, to]);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Minutes east of UTC for a numeric offset, `UTC+8`, or a well-known zone name. */
|
|
160
|
+
function parseOffset(value) {
|
|
161
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
162
|
+
const text = String(value ?? "").trim();
|
|
163
|
+
const utc = /^UTC([+-])(\d{1,2})(?::(\d{2}))?$/i.exec(text);
|
|
164
|
+
if (utc !== null) return (utc[1] === "-" ? -1 : 1) * (Number(utc[2]) * 60 + Number(utc[3] ?? 0));
|
|
165
|
+
if (/^UTC$/i.test(text)) return 0;
|
|
166
|
+
const named = { "Asia/Shanghai": 480, "Asia/Chongqing": 480, "Asia/Hong_Kong": 480, "Asia/Taipei": 480, "Asia/Tokyo": 540 };
|
|
167
|
+
return named[text];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolve the effective peak schedule from the catalog's `peakSchedule` and
|
|
172
|
+
* the plugin config (config wins field by field).
|
|
173
|
+
*/
|
|
174
|
+
function resolveSchedule(configPeak, catalogSchedule) {
|
|
175
|
+
const schedule = { ...PEAK_DEFAULTS, days: [...PEAK_DEFAULTS.days], windows: PEAK_DEFAULTS.windows.map((w) => [...w]) };
|
|
176
|
+
if (catalogSchedule !== null && typeof catalogSchedule === "object") {
|
|
177
|
+
const offset = parseOffset(catalogSchedule.timezone);
|
|
178
|
+
if (offset !== undefined) schedule.timezoneOffsetMinutes = offset;
|
|
179
|
+
if (Array.isArray(catalogSchedule.days)) {
|
|
180
|
+
const days = catalogSchedule.days.filter((d) => Number.isInteger(d) && d >= 0 && d <= 6);
|
|
181
|
+
if (days.length > 0) schedule.days = days;
|
|
182
|
+
}
|
|
183
|
+
const windows = parseWindows(catalogSchedule.windows);
|
|
184
|
+
if (windows.length > 0) schedule.windows = windows;
|
|
185
|
+
const ratio = num(catalogSchedule.offPeakRatio);
|
|
186
|
+
if (ratio !== undefined && ratio > 0 && ratio <= 1) schedule.ratio = ratio;
|
|
187
|
+
}
|
|
188
|
+
const source = configPeak ?? {};
|
|
189
|
+
if (typeof source.enabled === "boolean") schedule.enabled = source.enabled;
|
|
190
|
+
if (num(source.multiplier) !== undefined && source.multiplier > 0) schedule.multiplier = source.multiplier;
|
|
191
|
+
if (typeof source.timezoneOffsetMinutes === "number" && Number.isFinite(source.timezoneOffsetMinutes)) schedule.timezoneOffsetMinutes = source.timezoneOffsetMinutes;
|
|
192
|
+
if (Array.isArray(source.days)) {
|
|
193
|
+
const days = source.days.filter((d) => Number.isInteger(d) && d >= 0 && d <= 6);
|
|
194
|
+
if (days.length > 0) schedule.days = days;
|
|
195
|
+
}
|
|
196
|
+
const windows = parseWindows(source.windows);
|
|
197
|
+
if (windows.length > 0) schedule.windows = windows;
|
|
198
|
+
const ratio = num(source.offPeakRatio);
|
|
199
|
+
if (ratio !== undefined && ratio > 0 && ratio <= 1) schedule.ratio = ratio;
|
|
200
|
+
return schedule;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Whether an instant falls inside a peak window on a peak weekday. */
|
|
204
|
+
function isPeakAt(timeMs, schedule) {
|
|
205
|
+
if (!schedule.enabled || schedule.days.length === 0 || schedule.windows.length === 0) return false;
|
|
206
|
+
const shifted = new Date(timeMs + schedule.timezoneOffsetMinutes * 60000);
|
|
207
|
+
if (!schedule.days.includes(shifted.getUTCDay())) return false;
|
|
208
|
+
const minutes = shifted.getUTCHours() * 60 + shifted.getUTCMinutes();
|
|
209
|
+
for (const [from, to] of schedule.windows) {
|
|
210
|
+
if (minutes >= from * 60 && minutes < to * 60) return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Currency factor between a catalog currency and the display currency. */
|
|
216
|
+
function fxFactor(from, to, fx) {
|
|
217
|
+
const source = String(from ?? "").toUpperCase();
|
|
218
|
+
const target = String(to ?? "").toUpperCase();
|
|
219
|
+
if (source === "" || target === "" || source === target) return 1;
|
|
220
|
+
const cnyPerUsd = num(fx?.cnyPerUsd) ?? DEFAULT_CNY_PER_USD;
|
|
221
|
+
if (source === "USD" && target === "CNY") return cnyPerUsd;
|
|
222
|
+
if (source === "CNY" && target === "USD") return 1 / cnyPerUsd;
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function scaleRates(rates, factor) {
|
|
227
|
+
if (factor === 1) return rates;
|
|
228
|
+
const scale = (bucket) => ({ miss: bucket.miss * factor, hit: bucket.hit * factor, write: bucket.write * factor, out: bucket.out * factor });
|
|
229
|
+
return { peak: scale(rates.peak), off: scale(rates.off) };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Flatten one catalog document into priced route entries. */
|
|
233
|
+
function catalogEntries(catalog, { displayCurrency, fx, schedule, source, rank }) {
|
|
234
|
+
const entries = [];
|
|
235
|
+
const currency = catalog?.currency ?? "USD";
|
|
236
|
+
const factor = fxFactor(currency, displayCurrency, fx);
|
|
237
|
+
for (const [provider, declaration] of Object.entries(catalog?.providers ?? {})) {
|
|
238
|
+
for (const [model, entry] of Object.entries(declaration?.models ?? {})) {
|
|
239
|
+
const rates = parseRates(entry, { ratio: schedule.ratio, multiplier: schedule.multiplier });
|
|
240
|
+
if (rates === undefined) continue;
|
|
241
|
+
entries.push({ provider, model, rates: scaleRates(rates, factor), source, rank });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return entries;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Build the live price table: bundled catalog, remote catalog, legacy pricing
|
|
249
|
+
* documents, then manual config (highest priority).
|
|
250
|
+
*/
|
|
251
|
+
function buildPriceTable({ bundled, remote, legacyDocs = [], config, schedule }) {
|
|
252
|
+
const catalogCurrency = remote?.currency ?? bundled?.currency ?? "USD";
|
|
253
|
+
const displayCurrency = (typeof config?.currency === "string" && config.currency !== "" && config.currency)
|
|
254
|
+
|| catalogCurrency;
|
|
255
|
+
const entries = [];
|
|
256
|
+
entries.push(...catalogEntries(bundled, { displayCurrency, fx: config?.fx, schedule, source: "bundled", rank: 1 }));
|
|
257
|
+
if (remote !== undefined) entries.push(...catalogEntries(remote, { displayCurrency, fx: config?.fx, schedule, source: "remote", rank: 2 }));
|
|
258
|
+
for (const doc of legacyDocs) {
|
|
259
|
+
const factor = fxFactor(doc?.currency, displayCurrency, config?.fx);
|
|
260
|
+
for (const [key, entry] of Object.entries(doc?.models ?? {})) {
|
|
261
|
+
const rates = parseRates(entry, { ratio: schedule.ratio, multiplier: schedule.multiplier });
|
|
262
|
+
if (rates === undefined) continue;
|
|
263
|
+
const { provider, model } = splitKey(key);
|
|
264
|
+
entries.push({ provider, model, rates: scaleRates(rates, factor), source: "pricingUrl", rank: 3 });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const [key, entry] of Object.entries(config?.models ?? {})) {
|
|
268
|
+
const rates = parseRates(entry, { ratio: schedule.ratio, multiplier: schedule.multiplier });
|
|
269
|
+
if (rates === undefined) continue;
|
|
270
|
+
const { provider, model } = splitKey(key);
|
|
271
|
+
entries.push({ provider, model, rates, source: "config", rank: 4 });
|
|
272
|
+
}
|
|
273
|
+
const fallback = parseRates(config?.default, { ratio: schedule.ratio, multiplier: schedule.multiplier })
|
|
274
|
+
?? parseRates(legacyDocs.at(-1)?.default, { ratio: schedule.ratio, multiplier: schedule.multiplier });
|
|
275
|
+
return { currency: displayCurrency, entries, default: fallback };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Resolve a route's priced entry. Manual config outranks every automatic
|
|
280
|
+
* source; within equal rank, a more specific route beats a wildcard, and a
|
|
281
|
+
* later entry wins an exact tie. A provider wildcard and a model wildcard are
|
|
282
|
+
* both supported, and a bare model key matches every provider.
|
|
283
|
+
* @returns the matched entry, or undefined when the route is unpriced.
|
|
284
|
+
*/
|
|
285
|
+
function lookupPrice(table, provider, model) {
|
|
286
|
+
if (table === undefined) return undefined;
|
|
287
|
+
const wantedProvider = norm(provider);
|
|
288
|
+
const wantedModel = norm(model ?? "");
|
|
289
|
+
let best;
|
|
290
|
+
for (const entry of table.entries) {
|
|
291
|
+
if (entry.provider !== "*" && norm(entry.provider) !== wantedProvider) continue;
|
|
292
|
+
if (entry.model !== "*" && norm(entry.model) !== wantedModel) continue;
|
|
293
|
+
const score = (entry.provider === "*" ? 0 : 2) + (entry.model === "*" ? 0 : 2);
|
|
294
|
+
const rank = entry.rank ?? 0;
|
|
295
|
+
const stronger = best === undefined
|
|
296
|
+
|| rank > best.rank
|
|
297
|
+
|| (rank === best.rank && score >= best.score);
|
|
298
|
+
if (stronger) best = { entry, score, rank };
|
|
299
|
+
}
|
|
300
|
+
return best?.entry;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Cost of one token bucket under one rate bucket. */
|
|
304
|
+
function costOf(buckets, rate) {
|
|
305
|
+
return (
|
|
306
|
+
buckets.uncachedInputTokens * rate.miss +
|
|
307
|
+
buckets.cacheReadTokens * rate.hit +
|
|
308
|
+
buckets.cacheWriteTokens * rate.write +
|
|
309
|
+
buckets.outputTokens * rate.out
|
|
310
|
+
) / 1_000_000;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Disjoint provider usage buckets (reasoning is already inside output). */
|
|
314
|
+
const bucketsFrom = (usage) => ({
|
|
315
|
+
uncachedInputTokens: usage.inputTokens ?? 0,
|
|
316
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
317
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
318
|
+
cacheWriteTokens: usage.cacheWriteTokens ?? 0
|
|
319
|
+
});
|
|
320
|
+
const bucketsEqual = (a, b) => a.uncachedInputTokens === b.uncachedInputTokens && a.outputTokens === b.outputTokens && a.cacheReadTokens === b.cacheReadTokens && a.cacheWriteTokens === b.cacheWriteTokens;
|
|
321
|
+
const addBuckets = (a, b) => ({
|
|
322
|
+
uncachedInputTokens: (a?.uncachedInputTokens ?? 0) + b.uncachedInputTokens,
|
|
323
|
+
outputTokens: (a?.outputTokens ?? 0) + b.outputTokens,
|
|
324
|
+
cacheReadTokens: (a?.cacheReadTokens ?? 0) + b.cacheReadTokens,
|
|
325
|
+
cacheWriteTokens: (a?.cacheWriteTokens ?? 0) + b.cacheWriteTokens
|
|
326
|
+
});
|
|
327
|
+
const subBuckets = (a, b) => ({
|
|
328
|
+
uncachedInputTokens: (a?.uncachedInputTokens ?? 0) - b.uncachedInputTokens,
|
|
329
|
+
outputTokens: (a?.outputTokens ?? 0) - b.outputTokens,
|
|
330
|
+
cacheReadTokens: (a?.cacheReadTokens ?? 0) - b.cacheReadTokens,
|
|
331
|
+
cacheWriteTokens: (a?.cacheWriteTokens ?? 0) - b.cacheWriteTokens
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
/** Fetch a JSON document from an http(s) URL, a local path, or a file:// URL. */
|
|
335
|
+
async function fetchJson(url, timeoutMs) {
|
|
336
|
+
if (url.startsWith("file://")) return JSON.parse(readFileSync(fileURLToPath(url), "utf8"));
|
|
337
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return JSON.parse(readFileSync(url, "utf8"));
|
|
338
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: "application/json" } });
|
|
339
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
340
|
+
return await response.json();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Default catalog URL derived from the package's repository (fork-friendly). */
|
|
344
|
+
function defaultCatalogUrl() {
|
|
345
|
+
const repository = String(PACKAGE.repository?.url ?? "");
|
|
346
|
+
const match = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?(?:#.*)?$/.exec(repository.trim());
|
|
347
|
+
return match === null ? "" : `https://raw.githubusercontent.com/${match[1]}/${match[2]}/main/catalog/prices.json`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The first provider-declared peak schedule in a catalog document. */
|
|
351
|
+
function firstSchedule(catalog) {
|
|
352
|
+
for (const declaration of Object.values(catalog?.providers ?? {})) {
|
|
353
|
+
if (declaration?.peakSchedule !== undefined) return declaration.peakSchedule;
|
|
354
|
+
}
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Load every configured source. Each source fails independently and keeps the
|
|
360
|
+
* previous successful payload, so a transient network error never drops data.
|
|
361
|
+
* @returns `{ table, schedule, remote, legacy }`.
|
|
362
|
+
*/
|
|
363
|
+
async function loadSources(config, warn, previous) {
|
|
364
|
+
const timeoutMs = num(config.pricingTimeoutMs) ?? 4000;
|
|
365
|
+
let remote = previous?.remote;
|
|
366
|
+
let legacy = previous?.legacy;
|
|
367
|
+
|
|
368
|
+
const catalogUrl = config.catalogUrl === undefined ? defaultCatalogUrl() : String(config.catalogUrl ?? "");
|
|
369
|
+
if (catalogUrl !== "") {
|
|
370
|
+
try {
|
|
371
|
+
remote = await fetchJson(catalogUrl, timeoutMs);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (previous?.remote === undefined) warn(`catalog ${JSON.stringify(catalogUrl)} unavailable: ${error?.message ?? error}; using the bundled catalog`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (typeof config.pricingUrl === "string" && config.pricingUrl !== "") {
|
|
377
|
+
try {
|
|
378
|
+
legacy = await fetchJson(config.pricingUrl, timeoutMs);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (previous?.legacy === undefined) warn(`pricing ${JSON.stringify(config.pricingUrl)} unavailable: ${error?.message ?? error}; using bundled rates`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const schedule = resolveSchedule(config.peak, firstSchedule(BUNDLED_CATALOG) ?? firstSchedule(remote));
|
|
385
|
+
const table = buildPriceTable({ bundled: BUNDLED_CATALOG, remote, legacyDocs: legacy === undefined ? [] : [legacy], config, schedule });
|
|
386
|
+
return { table, schedule, remote, legacy };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Build the `costUsage` projection unit over a live source ref.
|
|
391
|
+
* State is pure token accounting per (route × tier); `wire.view` prices it.
|
|
392
|
+
*/
|
|
393
|
+
function buildProjection(live) {
|
|
394
|
+
const bucketsSchema = zod.object({
|
|
395
|
+
uncachedInputTokens: zod.number().int().nonnegative(),
|
|
396
|
+
outputTokens: zod.number().int().nonnegative(),
|
|
397
|
+
cacheReadTokens: zod.number().int().nonnegative(),
|
|
398
|
+
cacheWriteTokens: zod.number().int().nonnegative()
|
|
399
|
+
}).strict();
|
|
400
|
+
|
|
401
|
+
const routeSchema = zod.object({
|
|
402
|
+
provider: zod.string().nullable(),
|
|
403
|
+
model: zod.string().nullable(),
|
|
404
|
+
off: bucketsSchema,
|
|
405
|
+
peak: bucketsSchema
|
|
406
|
+
}).strict();
|
|
407
|
+
|
|
408
|
+
const stateSchema = zod.object({
|
|
409
|
+
provider: zod.string().nullable(),
|
|
410
|
+
model: zod.string().nullable(),
|
|
411
|
+
perRoute: zod.record(zod.string(), routeSchema),
|
|
412
|
+
last: zod.union([
|
|
413
|
+
zod.null(),
|
|
414
|
+
zod.object({
|
|
415
|
+
turn: zod.number().int(),
|
|
416
|
+
step: zod.number().int(),
|
|
417
|
+
route: zod.string(),
|
|
418
|
+
tier: zod.enum(["off", "peak"]),
|
|
419
|
+
buckets: bucketsSchema
|
|
420
|
+
}).strict()
|
|
421
|
+
])
|
|
422
|
+
}).strict();
|
|
423
|
+
|
|
424
|
+
const viewSchema = zod.object({
|
|
425
|
+
currency: zod.string(),
|
|
426
|
+
cost: zod.number(),
|
|
427
|
+
priced: zod.boolean(),
|
|
428
|
+
provider: zod.string().nullable(),
|
|
429
|
+
model: zod.string().nullable(),
|
|
430
|
+
currentTier: zod.enum(["off", "peak"]),
|
|
431
|
+
peakMultiplier: zod.number(),
|
|
432
|
+
unpricedRoutes: zod.array(zod.string()),
|
|
433
|
+
uncachedInputTokens: zod.number().int().nonnegative(),
|
|
434
|
+
outputTokens: zod.number().int().nonnegative(),
|
|
435
|
+
cacheReadTokens: zod.number().int().nonnegative(),
|
|
436
|
+
cacheWriteTokens: zod.number().int().nonnegative()
|
|
437
|
+
}).strict();
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
key: "costUsage",
|
|
441
|
+
stateSchema,
|
|
442
|
+
init: () => ({ provider: null, model: null, perRoute: {}, last: null }),
|
|
443
|
+
apply(state, event) {
|
|
444
|
+
const liveNow = live.current;
|
|
445
|
+
let provider = state.provider;
|
|
446
|
+
let model = state.model;
|
|
447
|
+
if (event.type === "request/header") {
|
|
448
|
+
const headerConfig = event.data?.header?.config;
|
|
449
|
+
if (typeof headerConfig?.provider === "string" && headerConfig.provider !== "") provider = headerConfig.provider;
|
|
450
|
+
if (typeof headerConfig?.model === "string" && headerConfig.model !== "") model = headerConfig.model;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
let turn;
|
|
454
|
+
let step;
|
|
455
|
+
let usage;
|
|
456
|
+
if (event.type === "assistant/chunk" && event.data?.chunk?.type === "usage") {
|
|
457
|
+
turn = event.data.turn;
|
|
458
|
+
step = event.data.step;
|
|
459
|
+
usage = event.data.chunk.usage;
|
|
460
|
+
} else if (event.type === "assistant/message" && event.data?.usage !== undefined) {
|
|
461
|
+
turn = event.data.turn;
|
|
462
|
+
step = event.data.step;
|
|
463
|
+
usage = event.data.usage;
|
|
464
|
+
} else {
|
|
465
|
+
return provider === state.provider && model === state.model ? state : { ...state, provider, model };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const tier = isPeakAt(event.time, liveNow.schedule) ? "peak" : "off";
|
|
469
|
+
const route = `${provider ?? ""}\u0000${model ?? ""}`;
|
|
470
|
+
const buckets = bucketsFrom(usage);
|
|
471
|
+
|
|
472
|
+
const previous = state.last !== null && state.last.turn === turn && state.last.step === step ? state.last : undefined;
|
|
473
|
+
if (previous !== undefined && previous.route === route && previous.tier === tier && bucketsEqual(previous.buckets, buckets)) {
|
|
474
|
+
return provider === state.provider && model === state.model ? state : { ...state, provider, model };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const perRoute = { ...state.perRoute };
|
|
478
|
+
if (previous !== undefined) {
|
|
479
|
+
const replaced = { ...(perRoute[previous.route] ?? { provider: null, model: null, off: ZERO_BUCKETS(), peak: ZERO_BUCKETS() }) };
|
|
480
|
+
replaced[previous.tier] = subBuckets(replaced[previous.tier], previous.buckets);
|
|
481
|
+
perRoute[previous.route] = replaced;
|
|
482
|
+
}
|
|
483
|
+
const entry = { ...(perRoute[route] ?? { provider, model, off: ZERO_BUCKETS(), peak: ZERO_BUCKETS() }) };
|
|
484
|
+
entry.provider = provider;
|
|
485
|
+
entry.model = model;
|
|
486
|
+
entry[tier] = addBuckets(entry[tier], buckets);
|
|
487
|
+
perRoute[route] = entry;
|
|
488
|
+
|
|
489
|
+
return { provider, model, perRoute, last: { turn, step, route, tier, buckets } };
|
|
490
|
+
},
|
|
491
|
+
wire: {
|
|
492
|
+
viewSchema,
|
|
493
|
+
view(state) {
|
|
494
|
+
const liveNow = live.current;
|
|
495
|
+
const table = liveNow.table;
|
|
496
|
+
const totals = ZERO_BUCKETS();
|
|
497
|
+
const unpriced = [];
|
|
498
|
+
let cost = 0;
|
|
499
|
+
for (const entry of Object.values(state.perRoute)) {
|
|
500
|
+
const matched = lookupPrice(table, entry.provider, entry.model);
|
|
501
|
+
if (matched === undefined) {
|
|
502
|
+
unpriced.push(`${entry.provider ?? "?"}/${entry.model ?? "?"}`);
|
|
503
|
+
} else {
|
|
504
|
+
cost += costOf(entry.off, matched.rates.off) + costOf(entry.peak, matched.rates.peak);
|
|
505
|
+
}
|
|
506
|
+
totals.uncachedInputTokens += entry.off.uncachedInputTokens + entry.peak.uncachedInputTokens;
|
|
507
|
+
totals.outputTokens += entry.off.outputTokens + entry.peak.outputTokens;
|
|
508
|
+
totals.cacheReadTokens += entry.off.cacheReadTokens + entry.peak.cacheReadTokens;
|
|
509
|
+
totals.cacheWriteTokens += entry.off.cacheWriteTokens + entry.peak.cacheWriteTokens;
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
currency: table.currency,
|
|
513
|
+
cost,
|
|
514
|
+
priced: unpriced.length === 0,
|
|
515
|
+
provider: state.provider,
|
|
516
|
+
model: state.model,
|
|
517
|
+
currentTier: isPeakAt(Date.now(), liveNow.schedule) ? "peak" : "off",
|
|
518
|
+
peakMultiplier: liveNow.schedule.multiplier,
|
|
519
|
+
unpricedRoutes: unpriced,
|
|
520
|
+
...totals
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
stateVersion: 3
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function warnWith(ctx, message) {
|
|
529
|
+
try {
|
|
530
|
+
if (ctx?.logger?.warn != null) ctx.logger.warn(`[dsh-plugin-cost-meter] ${message}`);
|
|
531
|
+
else console.warn(`[dsh-plugin-cost-meter] ${message}`);
|
|
532
|
+
} catch {
|
|
533
|
+
console.warn(`[dsh-plugin-cost-meter] ${message}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Register the projection and start the silent refresh loop.
|
|
539
|
+
* @param ctx - host root context.
|
|
540
|
+
* @param config - the raw plugin row config.
|
|
541
|
+
*/
|
|
542
|
+
async function apply(ctx, config) {
|
|
543
|
+
const cfg = config ?? {};
|
|
544
|
+
const live = { current: undefined };
|
|
545
|
+
const warn = (message) => warnWith(ctx, message);
|
|
546
|
+
|
|
547
|
+
live.current = await loadSources(cfg, warn, undefined);
|
|
548
|
+
ctx.inject(["sessionProjections"], (projectionCtx) => {
|
|
549
|
+
projectionCtx.sessionProjections.register(buildProjection(live));
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const minutes = num(cfg.refreshMinutes) ?? DEFAULT_REFRESH_MINUTES;
|
|
553
|
+
if (minutes > 0) {
|
|
554
|
+
let busy = false;
|
|
555
|
+
const timer = setInterval(async () => {
|
|
556
|
+
if (busy) return;
|
|
557
|
+
busy = true;
|
|
558
|
+
try {
|
|
559
|
+
live.current = await loadSources(cfg, warn, live.current);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
warn(`price refresh failed: ${error?.message ?? error}; keeping the previous rates`);
|
|
562
|
+
} finally {
|
|
563
|
+
busy = false;
|
|
564
|
+
}
|
|
565
|
+
}, minutes * 60_000);
|
|
566
|
+
timer.unref?.();
|
|
567
|
+
if (typeof ctx.effect === "function") ctx.effect(() => () => clearInterval(timer), "dsh-plugin-cost-meter: price refresh");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Internal helpers exported for the contract tests. */
|
|
572
|
+
const internals = { buildPriceTable, catalogEntries, isPeakAt, lookupPrice, parseRates, resolveSchedule };
|
|
573
|
+
|
|
574
|
+
export { apply, inject, internals, name };
|
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-cost-meter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Live per-session cost badge in the DeepSeek Harness conversation header (top-right), with peak/off-peak (峰谷分时) pricing.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh-plugin",
|
|
7
|
+
"deepseek-harness",
|
|
8
|
+
"dsh",
|
|
9
|
+
"deepseek",
|
|
10
|
+
"cost",
|
|
11
|
+
"pricing",
|
|
12
|
+
"token",
|
|
13
|
+
"usage",
|
|
14
|
+
"billing"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "doubsumi",
|
|
18
|
+
"homepage": "https://github.com/doubsumi/dsh-plugin-cost-meter#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/doubsumi/dsh-plugin-cost-meter.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/doubsumi/dsh-plugin-cost-meter/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "lib/index.js",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"default": "./lib/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./client": {
|
|
33
|
+
"default": "./lib/client.js"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"lib/index.js",
|
|
39
|
+
"lib/client.js",
|
|
40
|
+
"catalog/prices.json",
|
|
41
|
+
"cordis.patch.yml",
|
|
42
|
+
"README.zh.md",
|
|
43
|
+
"CHANGELOG.md"
|
|
44
|
+
],
|
|
45
|
+
"engines": {
|
|
46
|
+
"dsh": ">=0.1.5-rc.1 <1.0.0"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"zod": "^4.4.3"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
53
|
+
"@deepseek-ai/dsh-session-projection": ">=0.1.5-rc.1 <1.0.0"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"test": "node test/contract.test.mjs"
|
|
57
|
+
},
|
|
58
|
+
"dsh": {
|
|
59
|
+
"bundle": {
|
|
60
|
+
"patch": "cordis.patch.yml"
|
|
61
|
+
},
|
|
62
|
+
"client": {
|
|
63
|
+
"inject": [
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-session",
|
|
66
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
67
|
+
],
|
|
68
|
+
"platform": "web"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|