@lancecheney/dsh-deepseek-balance 0.1.0

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/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # dsh-deepseek-balance
2
+
3
+ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) client plugin that renders a compact billing badge to the **left of the "Session log" button** in the web UI:
4
+
5
+ ```
6
+ Spent ¥0.12 | Balance ¥47.17 | Peak | ¥27.0⁺/M output
7
+ ```
8
+
9
+ ## Features
10
+
11
+ - **Balance** — live DeepSeek API balance from `GET /user/balance`, proxied server-side so your API key never reaches the browser.
12
+ - **Spent** — estimated cost of the *current conversation window*, computed from the session's `tokenUsage` projection × the current-period price. Each conversation window shows its own spend.
13
+ - **Peak / Off-peak / Flat** — Beijing-time pricing period (peak: 09:00–12:00 and 14:00–18:00; everything else is off-peak). Before the peak/off-peak scheme takes effect, it shows **Flat** with the pre-change price.
14
+ - **Price** — current-period output price (per 1M tokens) for the selected model, with a small superscript `+` (high) / `++` (max) marker when thinking mode adds extra output.
15
+ - **Model-aware** — switches between DeepSeek-V4-Pro and V4-Flash pricing to match the current conversation's selected model.
16
+ - **Currency-aware** — reads the account currency from the balance (`CNY` / `USD`) and shows `¥` or `$` with the matching price table.
17
+ - **Self-updating** — the host fetches the official pricing docs daily at 01:00 Beijing time (plus once at startup), with a built-in fallback table.
18
+
19
+ ## How pricing is obtained
20
+
21
+ DeepSeek does not expose a pricing API, so the host half scrapes the official docs page — both the Chinese page for CNY and the English page for USD (`https://api-docs.deepseek.com/quick_start/pricing/`), parses the price tables and the effective date, and serves them to the browser at `/api/deepseek-pricing`. Prices are cached in memory and refreshed daily; on any failure the last-known-good built-in table is used.
22
+
23
+ ## Structure
24
+
25
+ - `lib/index.js` — host half. Registers `/api/deepseek-balance` (balance proxy) and `/api/deepseek-pricing` (price table).
26
+ - `lib/client.js` — browser half. Renders the badge into the `conversation.session.header.utilities` slot.
27
+ - `cordis.patch.yml` — bundle patch that inserts the plugin row into the host composition.
28
+ - `package.json` — declares `dsh.bundle` + `dsh.client` (web platform) and the `./client` export.
29
+
30
+ ## Install
31
+
32
+ Via `dsh plugin`:
33
+
34
+ ```sh
35
+ dsh plugin --profile web add @lancecheney/dsh-deepseek-balance
36
+ ```
37
+
38
+ then restart `dsh web` and refresh the page.
39
+
40
+ Requires a `DEEPSEEK_API_KEY` configured through the harness credential store (or the `DEEPSEEK_API_KEY` environment variable).
41
+
42
+ ## Notes
43
+
44
+ - The **Spent** figure is an estimate: all tokens in a session are priced at the current period's rate. Real billing is computed by DeepSeek.
45
+ - Reasoning tokens are billed as output tokens; the `+` / `++` marker signals that high/max effort increases output volume, not the unit price.
46
+
47
+ ## License
48
+
49
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,49 @@
1
+ # dsh-deepseek-balance
2
+
3
+ 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 客户端插件,在网页界面 **「Session log」按钮左侧** 渲染一个紧凑的计费徽章:
4
+
5
+ ```
6
+ 消耗 ¥0.12 | 余额 ¥47.17 | 高峰 | ¥27.0⁺/M 输出
7
+ ```
8
+
9
+ ## 功能
10
+
11
+ - **余额** — 实时 DeepSeek API 余额(`GET /user/balance`),由服务端代理请求,浏览器不会接触你的 API Key。
12
+ - **消耗** — 当前对话窗口的估算花费,由会话的 `tokenUsage` 投影 × 当前时段价格计算。每个对话窗口显示各自的消耗。
13
+ - **高峰 / 空闲 / 平价** — 按北京时间判断计费时段(高峰:09:00–12:00、14:00–18:00,其余空闲)。在峰谷定价生效前显示 **平价**(调价前价格)。
14
+ - **价格** — 当前时段的输出单价(每百万 tokens),思考强度为 high/max 时价格数字后带小上标 `+` / `++`。
15
+ - **按模型切换** — 自动在 DeepSeek-V4-Pro 与 V4-Flash 价格间切换,跟随当前会话选中的模型。
16
+ - **按货币切换** — 从余额接口读取账号货币(`CNY` / `USD`),自动显示 `¥` 或 `$` 及对应价格表。
17
+ - **自动更新** — 服务端每天北京时间 01:00(外加启动时一次)抓取官方定价页,失败时回退到内置价格表。
18
+
19
+ ## 价格如何获取
20
+
21
+ DeepSeek 没有公开的定价 API,因此 host 半区抓取官方文档页(中文页取人民币、英文页取美元,`https://api-docs.deepseek.com/quick_start/pricing/`),解析价格表和生效日期,并通过 `/api/deepseek-pricing` 下发给浏览器。价格缓存在内存中、每天刷新一次;任何失败都会回退到最后一次成功/内置的价格表。
22
+
23
+ ## 结构
24
+
25
+ - `lib/index.js` — host 半区。注册 `/api/deepseek-balance`(余额代理)与 `/api/deepseek-pricing`(价格表)。
26
+ - `lib/client.js` — 浏览器半区。把徽章渲染进 `conversation.session.header.utilities` 槽位。
27
+ - `cordis.patch.yml` — bundle 补丁,把插件行插入 host 组合。
28
+ - `package.json` — 声明 `dsh.bundle` + `dsh.client`(web 平台)以及 `./client` 导出。
29
+
30
+ ## 安装
31
+
32
+ 通过 `dsh plugin`:
33
+
34
+ ```sh
35
+ dsh plugin --profile web add @lancecheney/dsh-deepseek-balance
36
+ ```
37
+
38
+ 然后重启 `dsh web` 并刷新页面。
39
+
40
+ 需要已在 harness 凭据存储中配置 `DEEPSEEK_API_KEY`(或设置 `DEEPSEEK_API_KEY` 环境变量)。
41
+
42
+ ## 说明
43
+
44
+ - **消耗** 是估算值:会话内所有 token 都按当前时段价格计算,实际扣费以 DeepSeek 服务端为准。
45
+ - 思考链按输出 token 计费;`+` / `++` 只是标记 high/max 思考会增加输出量,并不改变单价。
46
+
47
+ ## License
48
+
49
+ MIT
@@ -0,0 +1,4 @@
1
+ # dsh-deepseek-balance bundle patch: inserts this plugin row into the host composition.
2
+ - insert:
3
+ - id: deepseek-balance
4
+ name: '@lancecheney/dsh-deepseek-balance'
package/lib/client.js ADDED
@@ -0,0 +1,338 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@lancecheney/dsh-deepseek-balance",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+
9
+ const css = ".Dbg1_root{display:inline-flex;align-items:center;gap:6px;height:32px;border:1px solid var(--dsw-alias-border-l2);border-radius:18px;padding:0 12px;font-family:var(--dsw-font-family);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);background:0 0;white-space:nowrap;text-decoration:none;cursor:pointer}.Dbg1_root:hover{background:var(--dsw-alias-interactive-bg-hover)}.Dbg1_num{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;font-weight:500}.Dbg1_sep{color:var(--dsw-alias-label-dimmed)}.Dbg1_chip{border-radius:9px;padding:0 8px;font-size:11px;line-height:18px;font-weight:600}.Dbg1_peak{color:var(--dsw-alias-state-warn-primary)}.Dbg1_offPeak{color:var(--dsw-alias-state-success-primary)}.Dbg1_legacy{color:var(--dsw-alias-label-tertiary)}.Dbg1_dimmed{color:var(--dsw-alias-label-dimmed)}.Dbg1_plus{font-size:9px;line-height:1;vertical-align:super;color:var(--dsw-alias-state-warn-primary);font-weight:700}.Dbg1_unit{font-size:10px}";
10
+ const tagId = "@lancecheney/dsh-deepseek-balance/BillingBadge.css";
11
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
12
+ const tag = document.createElement("style");
13
+ tag.dataset.plugin = "@lancecheney/dsh-deepseek-balance";
14
+ tag.dataset.pluginCss = tagId;
15
+ tag.textContent = css;
16
+ document.head.appendChild(tag);
17
+ }
18
+ const cssModule = { root: "Dbg1_root", num: "Dbg1_num", sep: "Dbg1_sep", chip: "Dbg1_chip", peak: "Dbg1_peak", offPeak: "Dbg1_offPeak", legacy: "Dbg1_legacy", dimmed: "Dbg1_dimmed", plus: "Dbg1_plus", unit: "Dbg1_unit" };
19
+
20
+ const MODEL_META = {
21
+ "deepseek-v4-pro": { label: "DeepSeek-V4-Pro" },
22
+ "deepseek-v4-flash": { label: "DeepSeek-V4-Flash" }
23
+ };
24
+
25
+ const FALLBACK_PRICING = {
26
+ effectiveFrom: "2026-08-17T00:00:00+08:00",
27
+ currencies: {
28
+ CNY: {
29
+ symbol: "¥",
30
+ models: {
31
+ "deepseek-v4-pro": {
32
+ legacy: { hit: 0.025, miss: 3.0, output: 6.0 },
33
+ peak: { hit: 0.30, miss: 9.0, output: 27.0 },
34
+ offPeak: { hit: 0.15, miss: 4.5, output: 13.5 }
35
+ },
36
+ "deepseek-v4-flash": {
37
+ legacy: { hit: 0.02, miss: 1.0, output: 2.0 },
38
+ peak: { hit: 0.10, miss: 3.0, output: 9.0 },
39
+ offPeak: { hit: 0.05, miss: 1.5, output: 4.5 }
40
+ }
41
+ }
42
+ },
43
+ USD: {
44
+ symbol: "$",
45
+ models: {
46
+ "deepseek-v4-pro": {
47
+ legacy: { hit: 0.003625, miss: 0.435, output: 0.87 },
48
+ peak: { hit: 0.044, miss: 1.32, output: 3.96 },
49
+ offPeak: { hit: 0.022, miss: 0.66, output: 1.98 }
50
+ },
51
+ "deepseek-v4-flash": {
52
+ legacy: { hit: 0.0028, miss: 0.14, output: 0.28 },
53
+ peak: { hit: 0.014, miss: 0.44, output: 1.32 },
54
+ offPeak: { hit: 0.007, miss: 0.22, output: 0.66 }
55
+ }
56
+ }
57
+ }
58
+ }
59
+ };
60
+ const DEFAULT_MODEL = "deepseek-v4-pro";
61
+
62
+ function beijingDecimalHour(now) {
63
+ let hour = 0;
64
+ let minute = 0;
65
+ try {
66
+ const parts = new Intl.DateTimeFormat("en-GB", {
67
+ timeZone: "Asia/Shanghai",
68
+ hour12: false,
69
+ hour: "2-digit",
70
+ minute: "2-digit"
71
+ }).formatToParts(now);
72
+ for (const part of parts) {
73
+ if (part.type === "hour") hour = Number(part.value);
74
+ if (part.type === "minute") minute = Number(part.value);
75
+ }
76
+ } catch {
77
+ hour = now.getHours();
78
+ minute = now.getMinutes();
79
+ }
80
+ return hour + minute / 60;
81
+ }
82
+
83
+ function periodFor(now, effectiveFrom) {
84
+ const eff = Date.parse(effectiveFrom);
85
+ if (Number.isFinite(eff) && now.getTime() < eff) return "legacy";
86
+ const h = beijingDecimalHour(now);
87
+ return (h >= 9 && h < 12) || (h >= 14 && h < 18) ? "peak" : "offPeak";
88
+ }
89
+
90
+ function firstBalance(data) {
91
+ const infos = data && data.balance_infos;
92
+ if (!Array.isArray(infos) || infos.length === 0) return null;
93
+ return infos.find((row) => row.currency === "CNY") || infos[0];
94
+ }
95
+
96
+ function fmtPrice(value) {
97
+ const n = Number(value);
98
+ return Number.isFinite(n) ? String(n) : String(value);
99
+ }
100
+
101
+ function fmtMoney(value) {
102
+ const n = Number(value);
103
+ if (!Number.isFinite(n)) return "—";
104
+ if (n <= 0) return "0.00";
105
+ if (n < 0.01) return n.toFixed(4);
106
+ if (n < 1) return n.toFixed(3);
107
+ return n.toFixed(2);
108
+ }
109
+
110
+ function costOf(usage, p) {
111
+ const miss = (usage.uncachedInputTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
112
+ const hit = usage.cacheReadTokens ?? 0;
113
+ const output = usage.outputTokens ?? 0;
114
+ return (miss * p.miss + hit * p.hit + output * p.output) / 1e6;
115
+ }
116
+
117
+ function sumBuckets(a, b) {
118
+ return {
119
+ uncachedInputTokens: (a.uncachedInputTokens ?? 0) + (b.uncachedInputTokens ?? 0),
120
+ outputTokens: (a.outputTokens ?? 0) + (b.outputTokens ?? 0),
121
+ cacheReadTokens: (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0),
122
+ cacheWriteTokens: (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0)
123
+ };
124
+ }
125
+
126
+ function translate(dict, key, params) {
127
+ let s = dict[key];
128
+ if (s === void 0) return key;
129
+ if (params) {
130
+ for (const [k, v] of Object.entries(params)) s = s.split(`{${k}}`).join(String(v));
131
+ }
132
+ return s;
133
+ }
134
+
135
+ const noopSubscribe = () => () => {};
136
+
137
+ function BillingBadge(props) {
138
+ const useProjection = props.useProjection;
139
+ const directory = props.directory;
140
+ const usage = useProjection("tokenUsage");
141
+ const byPeriod = useProjection("tokenUsageByPeriod");
142
+ const [state, setState] = react.useState({ phase: "loading" });
143
+
144
+ react.useEffect(() => {
145
+ let alive = true;
146
+ let timer;
147
+ const load = async () => {
148
+ try {
149
+ const [balanceRes, pricingRes] = await Promise.all([
150
+ fetch("/api/deepseek-balance", { headers: { accept: "application/json" }, cache: "no-store" }),
151
+ fetch("/api/deepseek-pricing", { headers: { accept: "application/json" }, cache: "no-store" })
152
+ ]);
153
+ const balanceData = await balanceRes.json().catch(() => ({}));
154
+ const pricingData = await pricingRes.json().catch(() => null);
155
+ if (!alive) return;
156
+ setState({
157
+ phase: "ok",
158
+ balance: balanceRes.ok ? balanceData : null,
159
+ balanceError: balanceRes.ok ? null : (balanceData.error || `HTTP ${balanceRes.status}`),
160
+ pricing: pricingData && pricingRes.ok && pricingData.currencies ? pricingData : FALLBACK_PRICING
161
+ });
162
+ } catch (error) {
163
+ if (alive) setState({ phase: "error", message: error instanceof Error ? error.message : String(error) });
164
+ }
165
+ };
166
+ load();
167
+ timer = setInterval(load, 60000);
168
+ return () => {
169
+ alive = false;
170
+ clearInterval(timer);
171
+ };
172
+ }, []);
173
+
174
+ const pricing = state.pricing || FALLBACK_PRICING;
175
+ const effectiveFrom = pricing.effectiveFrom || FALLBACK_PRICING.effectiveFrom;
176
+ const balanceRow = state.phase === "ok" && state.balance ? firstBalance(state.balance) : null;
177
+ const currency = balanceRow && balanceRow.currency ? balanceRow.currency : "CNY";
178
+ const dict = currency === "USD" ? en : zh;
179
+ const tr = (key, params) => translate(dict, key, params);
180
+ const pricingSet = (pricing.currencies && pricing.currencies[currency]) || pricing.currencies.CNY || FALLBACK_PRICING.currencies.CNY;
181
+ const symbol = pricingSet.symbol || "¥";
182
+ const models = pricingSet.models || FALLBACK_PRICING.currencies.CNY.models;
183
+
184
+ const modelState = react.useSyncExternalStore(
185
+ directory ? (fn) => directory.subscribe(fn) : noopSubscribe,
186
+ () => (directory ? directory.getSnapshot() : null)
187
+ );
188
+ const currentSelection = modelState && modelState.current ? modelState.current : null;
189
+ const modelId = currentSelection && currentSelection.model ? currentSelection.model : DEFAULT_MODEL;
190
+ const reasoningEffort = currentSelection ? currentSelection.reasoningEffort : void 0;
191
+
192
+ const period = periodFor(new Date(), effectiveFrom);
193
+ const isPeak = period === "peak";
194
+ const isLegacy = period === "legacy";
195
+ const model = models[modelId] || models[DEFAULT_MODEL] || FALLBACK_PRICING.currencies.CNY.models[DEFAULT_MODEL];
196
+ const p = (period === "peak" ? model.peak : period === "offPeak" ? model.offPeak : model.legacy) || model.peak;
197
+ const outputPrice = fmtPrice(p.output);
198
+ const cost = (() => {
199
+ if (byPeriod && byPeriod.peak && byPeriod.offPeak) {
200
+ if (isLegacy) {
201
+ return model.legacy ? costOf(sumBuckets(byPeriod.peak, byPeriod.offPeak), model.legacy) : null;
202
+ }
203
+ return costOf(byPeriod.peak, model.peak) + costOf(byPeriod.offPeak, model.offPeak);
204
+ }
205
+ return usage !== undefined && usage !== null ? costOf(usage, p) : null;
206
+ })();
207
+
208
+ // off -> base price; high (default) -> one +; max -> two +.
209
+ const plus = reasoningEffort === "max" ? "++" : reasoningEffort === "off" ? "" : "+";
210
+
211
+ const periodClass = isLegacy ? cssModule.legacy : isPeak ? cssModule.peak : cssModule.offPeak;
212
+ const periodLabel = isLegacy ? tr("period.legacy") : isPeak ? tr("period.peak") : tr("period.offPeak");
213
+
214
+ const tooltipParts = [
215
+ isLegacy ? tr("period.legacyNote") : tr("period.peakHours"),
216
+ isLegacy ? "" : tr("period.offPeakNote"),
217
+ "",
218
+ `${tr("label.model")}: ${(MODEL_META[modelId] || {}).label || modelId}${reasoningEffort ? ` · ${reasoningEffort}` : ""}`,
219
+ tr("reasoning.note")
220
+ ].filter((line) => line !== "");
221
+ if (cost !== null && usage) {
222
+ tooltipParts.push(
223
+ "",
224
+ `${tr("label.spent")}: ${symbol}${fmtMoney(cost)} (${tr("cost.estimate")})`,
225
+ tr("cost.breakdown", {
226
+ miss: (usage.uncachedInputTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
227
+ hit: usage.cacheReadTokens ?? 0,
228
+ output: usage.outputTokens ?? 0
229
+ })
230
+ );
231
+ }
232
+ tooltipParts.push("");
233
+ for (const [id, info] of Object.entries(models)) {
234
+ const meta = MODEL_META[id] || {};
235
+ const row = tr("price.row", { sym: symbol, hit: fmtPrice(info.peak.hit), miss: fmtPrice(info.peak.miss), output: fmtPrice(info.peak.output) });
236
+ const off = tr("price.row", { sym: symbol, hit: fmtPrice(info.offPeak.hit), miss: fmtPrice(info.offPeak.miss), output: fmtPrice(info.offPeak.output) });
237
+ const legacy = info.legacy ? tr("price.row", { sym: symbol, hit: fmtPrice(info.legacy.hit), miss: fmtPrice(info.legacy.miss), output: fmtPrice(info.legacy.output) }) : null;
238
+ tooltipParts.push(
239
+ `${meta.label || id} — ${tr("period.peak")}: ${row} / ${tr("period.offPeak")}: ${off}${legacy ? ` / ${tr("period.legacy")}: ${legacy}` : ""}`
240
+ );
241
+ }
242
+ const tooltip = tooltipParts.join("\n");
243
+
244
+ const sep = (key) => react.createElement("span", { key, className: cssModule.sep, "aria-hidden": true }, "|");
245
+
246
+ const segs = [];
247
+ segs.push(react.createElement("span", { key: "spent" },
248
+ tr("label.spent"), " ",
249
+ cost === null
250
+ ? react.createElement("span", { className: cssModule.num }, "—")
251
+ : react.createElement("span", { className: cssModule.num },
252
+ react.createElement("span", { className: cssModule.unit }, symbol),
253
+ fmtMoney(cost))));
254
+ segs.push(sep("sep1"));
255
+ segs.push(react.createElement("span", { key: "balance" },
256
+ tr("label.balance"), " ",
257
+ balanceRow === null
258
+ ? react.createElement("span", { className: cssModule.num }, state.phase === "loading" ? "…" : tr("balance.unavailable"))
259
+ : react.createElement("span", { className: cssModule.num },
260
+ react.createElement("span", { className: cssModule.unit }, symbol),
261
+ fmtMoney(Number(balanceRow.total_balance)))));
262
+ segs.push(sep("sep2"));
263
+ segs.push(react.createElement("span", { key: "period", className: `${cssModule.chip} ${periodClass}` }, periodLabel));
264
+ segs.push(sep("sep3"));
265
+ segs.push(react.createElement("span", { key: "price" },
266
+ react.createElement("span", { className: cssModule.unit }, symbol),
267
+ outputPrice,
268
+ react.createElement("span", { className: cssModule.unit }, tr("price.perM")),
269
+ plus !== "" ? react.createElement("span", { className: cssModule.plus }, plus) : null));
270
+
271
+ return react.createElement("a", { className: cssModule.root, title: tooltip, href: "https://platform.deepseek.com/usage", target: "_blank", rel: "noreferrer noopener" }, segs);
272
+ }
273
+
274
+ const NS = "deepseek-balance";
275
+ const zh = {
276
+ "label.spent": "消耗",
277
+ "label.balance": "余额",
278
+ "label.model": "模型",
279
+ "period.peak": "高峰",
280
+ "period.offPeak": "空闲",
281
+ "period.legacy": "平价",
282
+ "period.legacyNote": "当前为调价前平价(8/17 前)",
283
+ "period.peakHours": "高峰:09:00–12:00、14:00–18:00(北京时间)",
284
+ "period.offPeakNote": "空闲:其余时段",
285
+ "balance.unavailable": "余额不可用",
286
+ "price.perM": "/M",
287
+ "price.unit": "输出",
288
+ "price.row": "命中{sym}{hit}/未命中{sym}{miss}/输出{sym}{output}",
289
+ "cost.estimate": "按当前时段价格估算",
290
+ "cost.breakdown": "输入(未命中) {miss} · 输入(命中) {hit} · 输出 {output} tokens",
291
+ "reasoning.note": "思考链按输出 token 计费;+ / ++ 表示 high / max 思考会额外增加输出量"
292
+ };
293
+ const en = {
294
+ "label.spent": "Spent",
295
+ "label.balance": "Balance",
296
+ "label.model": "Model",
297
+ "period.peak": "Peak",
298
+ "period.offPeak": "Off-peak",
299
+ "period.legacy": "Flat",
300
+ "period.legacyNote": "Pre-change flat pricing (before Aug 17)",
301
+ "period.peakHours": "Peak: 01:00–04:00, 06:00–10:00 UTC",
302
+ "period.offPeakNote": "Off-peak: all other hours",
303
+ "balance.unavailable": "balance unavailable",
304
+ "price.perM": "/M",
305
+ "price.unit": "output",
306
+ "price.row": "hit {sym}{hit}/miss {sym}{miss}/output {sym}{output}",
307
+ "cost.estimate": "estimated at the current period's price",
308
+ "cost.breakdown": "input(miss) {miss} · input(hit) {hit} · output {output} tokens",
309
+ "reasoning.note": "Reasoning tokens are billed as output; + / ++ mark high / max effort which adds extra output"
310
+ };
311
+
312
+ const inject = ["slots", "locale"];
313
+ function apply(ctx) {
314
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "deepseek-balance: dictionaries");
315
+ ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
316
+ name: "conversation.session.header.utilities",
317
+ id: "deepseek-balance",
318
+ order: -100,
319
+ inject: (sessionId) => {
320
+ const models = ctx.get("modelDirectories");
321
+ let directory;
322
+ if (models && typeof models.directoryFor === "function") {
323
+ try {
324
+ directory = models.directoryFor(sessionId);
325
+ } catch {}
326
+ }
327
+ return { directory: directory ? directory.store : void 0 };
328
+ }
329
+ }, BillingBadge));
330
+ }
331
+
332
+ exports.apply = apply;
333
+ exports.inject = inject;
334
+ return module.exports;
335
+ }
336
+ });
337
+
338
+ //# sourceMappingURL=client.js.map
package/lib/index.js ADDED
@@ -0,0 +1,431 @@
1
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
2
+ import { settingsNamespace } from "@deepseek-ai/dsh-settings";
3
+ import { z } from "zod";
4
+
5
+ /**
6
+ * @lancecheney/dsh-deepseek-balance — host half.
7
+ * - `/api/deepseek-balance` proxies DeepSeek `GET /user/balance` (carries the account currency).
8
+ * - `/api/deepseek-pricing` serves CNY + USD price tables, refreshed daily at
9
+ * 01:00 Beijing time from the official docs, with a hardcoded fallback.
10
+ */
11
+
12
+ const name = "deepseek-balance";
13
+ const inject = ["webServer", "credentials", "settings"];
14
+
15
+ const DEFAULT_BASE_URL = "https://api.deepseek.com";
16
+ const DEFAULT_KEY_ENV = "DEEPSEEK_API_KEY";
17
+ const UPSTREAM_TIMEOUT_MS = 8000;
18
+ const PRICING_CN_URL = "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/";
19
+ const PRICING_US_URL = "https://api-docs.deepseek.com/quick_start/pricing/";
20
+ const PRICING_FETCH_TIMEOUT_MS = 15000;
21
+
22
+ /** Last-known-good prices: legacy + the 2026-08-17 peak/off-peak table, per currency. */
23
+ const FALLBACK_PRICING = {
24
+ effectiveFrom: "2026-08-17T00:00:00+08:00",
25
+ currencies: {
26
+ CNY: {
27
+ symbol: "¥",
28
+ models: {
29
+ "deepseek-v4-flash": {
30
+ legacy: { hit: 0.02, miss: 1.0, output: 2.0 },
31
+ peak: { hit: 0.10, miss: 3.0, output: 9.0 },
32
+ offPeak: { hit: 0.05, miss: 1.5, output: 4.5 }
33
+ },
34
+ "deepseek-v4-pro": {
35
+ legacy: { hit: 0.025, miss: 3.0, output: 6.0 },
36
+ peak: { hit: 0.30, miss: 9.0, output: 27.0 },
37
+ offPeak: { hit: 0.15, miss: 4.5, output: 13.5 }
38
+ }
39
+ }
40
+ },
41
+ USD: {
42
+ symbol: "$",
43
+ models: {
44
+ "deepseek-v4-flash": {
45
+ legacy: { hit: 0.0028, miss: 0.14, output: 0.28 },
46
+ peak: { hit: 0.014, miss: 0.44, output: 1.32 },
47
+ offPeak: { hit: 0.007, miss: 0.22, output: 0.66 }
48
+ },
49
+ "deepseek-v4-pro": {
50
+ legacy: { hit: 0.003625, miss: 0.435, output: 0.87 },
51
+ peak: { hit: 0.044, miss: 1.32, output: 3.96 },
52
+ offPeak: { hit: 0.022, miss: 0.66, output: 1.98 }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ };
58
+
59
+ function json(res, status, body) {
60
+ const payload = JSON.stringify(body);
61
+ res.writeHead(status, {
62
+ "content-type": "application/json; charset=utf-8",
63
+ "cache-control": "no-store"
64
+ });
65
+ res.end(payload);
66
+ }
67
+
68
+ function resolveSettingsSection(ctx) {
69
+ try {
70
+ const settings = ctx.get("settings");
71
+ if (settings && typeof settings.get === "function") {
72
+ return settings.get(settingsNamespace("llm-deepseek"));
73
+ }
74
+ } catch {}
75
+ return void 0;
76
+ }
77
+
78
+ async function resolveDeepSeekFacts(ctx) {
79
+ const section = resolveSettingsSection(ctx);
80
+ const apiKeyEnv =
81
+ section && typeof section === "object" && typeof section.apiKeyEnv === "string" && section.apiKeyEnv.length > 0
82
+ ? section.apiKeyEnv
83
+ : DEFAULT_KEY_ENV;
84
+ const baseURL =
85
+ (section && typeof section === "object" && typeof section.baseURL === "string" && section.baseURL.length > 0
86
+ ? section.baseURL
87
+ : process.env.DEEPSEEK_BASE_URL) || DEFAULT_BASE_URL;
88
+
89
+ let apiKey;
90
+ const credentials = ctx.get("credentials");
91
+ if (credentials && typeof credentials.resolve === "function") {
92
+ try {
93
+ const hit = await credentials.resolve(credentialRef(apiKeyEnv));
94
+ if (hit && hit.value) apiKey = hit.value;
95
+ } catch {}
96
+ }
97
+ if (!apiKey) apiKey = process.env[apiKeyEnv];
98
+ if (!apiKey) throw new Error(`no DeepSeek API key (resolve ${apiKeyEnv} through credentials or env)`);
99
+
100
+ return { apiKey, baseURL: String(baseURL).replace(/\/+$/, "") };
101
+ }
102
+
103
+ function isTrustedRead(req) {
104
+ if (req.headers["sec-fetch-site"] === "cross-site") return false;
105
+ const origin = req.headers.origin;
106
+ if (origin === void 0) return true;
107
+ try {
108
+ const originHost = new URL(origin).host;
109
+ const host = req.headers.host ?? "";
110
+ const hostWithoutPort = host.replace(/:\d+$/, "");
111
+ return originHost === hostWithoutPort || originHost.replace(/:\d+$/, "") === hostWithoutPort;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function stripTags(html) {
118
+ return html
119
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
120
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
121
+ .replace(/<[^>]+>/g, " ")
122
+ .replace(/&nbsp;/g, " ")
123
+ .replace(/&amp;/g, "&")
124
+ .replace(/\s+/g, " ");
125
+ }
126
+
127
+ const number = (match, index) => {
128
+ const value = Number(match[index]);
129
+ return Number.isFinite(value) ? value : void 0;
130
+ };
131
+
132
+ /** Parse the zh-CN docs page: CNY prices + the effective date. */
133
+ function parseCnPricing(html) {
134
+ const text = stripTags(html);
135
+ const parseModel = (id) => {
136
+ const re = new RegExp(id + "\\s+空闲时段\\s+([\\d.]+)元\\s+([\\d.]+)元\\s+([\\d.]+)元\\s+高峰时段\\s+([\\d.]+)元\\s+([\\d.]+)元\\s+([\\d.]+)元");
137
+ const m = text.match(re);
138
+ if (!m) return void 0;
139
+ return {
140
+ offPeak: { hit: number(m, 1), miss: number(m, 2), output: number(m, 3) },
141
+ peak: { hit: number(m, 4), miss: number(m, 5), output: number(m, 6) }
142
+ };
143
+ };
144
+ const flash = parseModel("deepseek-v4-flash");
145
+ const pro = parseModel("deepseek-v4-pro");
146
+ if (!flash || !pro) throw new Error("CNY peak/off-peak table not found");
147
+
148
+ let effectiveFrom = FALLBACK_PRICING.effectiveFrom;
149
+ const dm = text.match(/北京时间\s*(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日\s*(\d{1,2}):(\d{2})/);
150
+ if (dm) {
151
+ const [y, mo, d, h, mi] = [1, 2, 3, 4, 5].map((i) => Number(dm[i]));
152
+ effectiveFrom = `${y}-${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")}T${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}:00+08:00`;
153
+ }
154
+
155
+ let legacy;
156
+ const lm = text.match(/百万tokens输入(缓存命中)\s*([\d.]+)元\s*([\d.]+)元\s*百万tokens输入(缓存未命中)\s*([\d.]+)元\s*([\d.]+)元\s*百万tokens输出\s*([\d.]+)元\s*([\d.]+)元/);
157
+ if (lm) {
158
+ legacy = {
159
+ flash: { hit: number(lm, 1), miss: number(lm, 3), output: number(lm, 5) },
160
+ pro: { hit: number(lm, 2), miss: number(lm, 4), output: number(lm, 6) }
161
+ };
162
+ }
163
+
164
+ return {
165
+ effectiveFrom,
166
+ models: {
167
+ "deepseek-v4-flash": { ...(legacy?.flash ? { legacy: legacy.flash } : {}), peak: flash.peak, offPeak: flash.offPeak },
168
+ "deepseek-v4-pro": { ...(legacy?.pro ? { legacy: legacy.pro } : {}), peak: pro.peak, offPeak: pro.offPeak }
169
+ }
170
+ };
171
+ }
172
+
173
+ /** Parse the English docs page: USD prices. */
174
+ function parseUsModels(html) {
175
+ const text = stripTags(html);
176
+ const parseModel = (id) => {
177
+ const re = new RegExp(id + "\\s+OFF-PEAK\\s+\\$([\\d.]+)\\s+\\$([\\d.]+)\\s+\\$([\\d.]+)\\s+PEAK\\s+\\$([\\d.]+)\\s+\\$([\\d.]+)\\s+\\$([\\d.]+)", "i");
178
+ const m = text.match(re);
179
+ if (!m) return void 0;
180
+ return {
181
+ offPeak: { hit: number(m, 1), miss: number(m, 2), output: number(m, 3) },
182
+ peak: { hit: number(m, 4), miss: number(m, 5), output: number(m, 6) }
183
+ };
184
+ };
185
+ const flash = parseModel("deepseek-v4-flash");
186
+ const pro = parseModel("deepseek-v4-pro");
187
+ if (!flash || !pro) throw new Error("USD peak/off-peak table not found");
188
+
189
+ let legacy;
190
+ const lm = text.match(/1M INPUT TOKENS \(CACHE HIT\)\s+\$([\d.]+)\s+\$([\d.]+)\s+1M INPUT TOKENS \(CACHE MISS\)\s+\$([\d.]+)\s+\$([\d.]+)\s+1M OUTPUT TOKENS\s+\$([\d.]+)\s+\$([\d.]+)/i);
191
+ if (lm) {
192
+ legacy = {
193
+ flash: { hit: number(lm, 1), miss: number(lm, 3), output: number(lm, 5) },
194
+ pro: { hit: number(lm, 2), miss: number(lm, 4), output: number(lm, 6) }
195
+ };
196
+ }
197
+
198
+ return {
199
+ "deepseek-v4-flash": { ...(legacy?.flash ? { legacy: legacy.flash } : {}), peak: flash.peak, offPeak: flash.offPeak },
200
+ "deepseek-v4-pro": { ...(legacy?.pro ? { legacy: legacy.pro } : {}), peak: pro.peak, offPeak: pro.offPeak }
201
+ };
202
+ }
203
+
204
+ const pricing = { data: FALLBACK_PRICING, fetchedAt: 0, source: "fallback" };
205
+ let pricingRefresh = null;
206
+
207
+ async function refreshPricing(ctx) {
208
+ if (pricingRefresh) return pricingRefresh;
209
+ pricingRefresh = (async () => {
210
+ const warn = (message) => {
211
+ if (ctx?.logger?.warn) ctx.logger.warn(`deepseek-balance: ${message}`);
212
+ };
213
+ let effectiveFrom = FALLBACK_PRICING.effectiveFrom;
214
+ let cnModels = FALLBACK_PRICING.currencies.CNY.models;
215
+ let usModels = FALLBACK_PRICING.currencies.USD.models;
216
+ try {
217
+ const [cnRes, usRes] = await Promise.all([
218
+ fetch(PRICING_CN_URL, { signal: AbortSignal.timeout(PRICING_FETCH_TIMEOUT_MS), headers: { accept: "text/html" } }),
219
+ fetch(PRICING_US_URL, { signal: AbortSignal.timeout(PRICING_FETCH_TIMEOUT_MS), headers: { accept: "text/html" } })
220
+ ]);
221
+ if (cnRes.ok) {
222
+ try {
223
+ const parsed = parseCnPricing(await cnRes.text());
224
+ effectiveFrom = parsed.effectiveFrom;
225
+ cnModels = parsed.models;
226
+ } catch (error) {
227
+ warn(`CNY pricing parse failed: ${error instanceof Error ? error.message : String(error)}`);
228
+ }
229
+ } else {
230
+ warn(`CNY pricing docs HTTP ${cnRes.status}`);
231
+ }
232
+ if (usRes.ok) {
233
+ try {
234
+ usModels = parseUsModels(await usRes.text());
235
+ } catch (error) {
236
+ warn(`USD pricing parse failed: ${error instanceof Error ? error.message : String(error)}`);
237
+ }
238
+ } else {
239
+ warn(`USD pricing docs HTTP ${usRes.status}`);
240
+ }
241
+ pricing.data = {
242
+ effectiveFrom,
243
+ currencies: {
244
+ CNY: { symbol: "¥", models: cnModels },
245
+ USD: { symbol: "$", models: usModels }
246
+ }
247
+ };
248
+ pricing.fetchedAt = Date.now();
249
+ pricing.source = "docs";
250
+ } catch (error) {
251
+ warn(`pricing refresh failed (keeping fallback): ${error instanceof Error ? error.message : String(error)}`);
252
+ } finally {
253
+ pricingRefresh = null;
254
+ }
255
+ })();
256
+ return pricingRefresh;
257
+ }
258
+
259
+ function msUntilNextHourBeijing(hour) {
260
+ const now = new Date();
261
+ const beijing = new Date(now.getTime() + 8 * 3600 * 1000);
262
+ const bHour = beijing.getUTCHours();
263
+ const bMinute = beijing.getUTCMinutes();
264
+ const bSecond = beijing.getUTCSeconds();
265
+ const bMs = beijing.getUTCMilliseconds();
266
+ let deltaHours = hour - bHour;
267
+ if (deltaHours < 0 || (deltaHours === 0 && (bMinute > 0 || bSecond > 0 || bMs > 0))) deltaHours += 24;
268
+ return deltaHours * 3600 * 1000 - bMinute * 60 * 1000 - bSecond * 1000 - bMs;
269
+ }
270
+
271
+ /** Beijing hour of a timestamp (UTC+8, no DST). */
272
+ function beijingHourOf(time) {
273
+ return new Date(time + 8 * 3600 * 1000).getUTCHours();
274
+ }
275
+
276
+ function isPeakHour(hour) {
277
+ return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18);
278
+ }
279
+
280
+ const bucketSchema = z.object({
281
+ uncachedInputTokens: z.number().int().nonnegative(),
282
+ outputTokens: z.number().int().nonnegative(),
283
+ cacheReadTokens: z.number().int().nonnegative(),
284
+ cacheWriteTokens: z.number().int().nonnegative()
285
+ });
286
+
287
+ const tokenUsageByPeriodSchema = z.object({
288
+ peak: bucketSchema,
289
+ offPeak: bucketSchema
290
+ });
291
+
292
+ const zeroBucket = () => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
293
+
294
+ const bucketsFrom = (usage) => ({
295
+ uncachedInputTokens: usage.inputTokens,
296
+ outputTokens: usage.outputTokens,
297
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
298
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0
299
+ });
300
+
301
+ const bucketsEqual = (a, b) =>
302
+ a.uncachedInputTokens === b.uncachedInputTokens &&
303
+ a.outputTokens === b.outputTokens &&
304
+ a.cacheReadTokens === b.cacheReadTokens &&
305
+ a.cacheWriteTokens === b.cacheWriteTokens;
306
+
307
+ const addBucket = (total, delta) => ({
308
+ uncachedInputTokens: total.uncachedInputTokens + delta.uncachedInputTokens,
309
+ outputTokens: total.outputTokens + delta.outputTokens,
310
+ cacheReadTokens: total.cacheReadTokens + delta.cacheReadTokens,
311
+ cacheWriteTokens: total.cacheWriteTokens + delta.cacheWriteTokens
312
+ });
313
+
314
+ const subtractBucket = (total, delta) => ({
315
+ uncachedInputTokens: total.uncachedInputTokens - delta.uncachedInputTokens,
316
+ outputTokens: total.outputTokens - delta.outputTokens,
317
+ cacheReadTokens: total.cacheReadTokens - delta.cacheReadTokens,
318
+ cacheWriteTokens: total.cacheWriteTokens - delta.cacheWriteTokens
319
+ });
320
+
321
+ /**
322
+ * Whole-log token usage bucketed by Beijing-time peak/off-peak hour, so the
323
+ * client can price each hour at its own rate. Replacement semantics mirror the
324
+ * token-meter `tokenUsage` projection: an assistant/message finalizes the usage
325
+ * for its turn/step, replacing an earlier usage chunk instead of double counting.
326
+ */
327
+ const tokenUsageByPeriodProjection = {
328
+ key: "tokenUsageByPeriod",
329
+ schema: tokenUsageByPeriodSchema,
330
+ init: () => ({ totals: { peak: zeroBucket(), offPeak: zeroBucket() }, last: null }),
331
+ apply: (state, event) => {
332
+ let turn;
333
+ let step;
334
+ let usage;
335
+ if (event.type === "assistant/chunk" && event.data.chunk.type === "usage") {
336
+ ({ turn, step } = event.data);
337
+ usage = event.data.chunk.usage;
338
+ } else if (event.type === "assistant/message" && event.data.usage !== void 0) {
339
+ ({ turn, step, usage } = event.data);
340
+ } else {
341
+ return state;
342
+ }
343
+ const period = isPeakHour(beijingHourOf(event.time)) ? "peak" : "offPeak";
344
+ const buckets = bucketsFrom(usage);
345
+ const previous = state.last !== null && state.last.turn === turn && state.last.step === step ? state.last : void 0;
346
+ if (previous !== void 0 && previous.period === period && bucketsEqual(previous.buckets, buckets)) return state;
347
+ const totals = { peak: state.totals.peak, offPeak: state.totals.offPeak };
348
+ if (previous !== void 0) totals[previous.period] = subtractBucket(totals[previous.period], previous.buckets);
349
+ totals[period] = addBucket(totals[period], buckets);
350
+ return { totals, last: { turn, step, period, buckets } };
351
+ },
352
+ view: (state) => state.totals,
353
+ stateVersion: 1
354
+ };
355
+
356
+ function apply(ctx) {
357
+ ctx.inject(["sessionProjections"], (projectionCtx) => {
358
+ projectionCtx.sessionProjections.register(tokenUsageByPeriodProjection);
359
+ });
360
+
361
+ const balanceHandler = async (req, res) => {
362
+ if (req.method !== "GET" && req.method !== "HEAD") {
363
+ json(res, 405, { error: "method not allowed" });
364
+ return;
365
+ }
366
+ if (!isTrustedRead(req)) {
367
+ json(res, 403, { error: "forbidden" });
368
+ return;
369
+ }
370
+ try {
371
+ const { apiKey, baseURL } = await resolveDeepSeekFacts(ctx);
372
+ const upstream = await fetch(`${baseURL}/user/balance`, {
373
+ method: "GET",
374
+ headers: {
375
+ authorization: `Bearer ${apiKey}`,
376
+ accept: "application/json"
377
+ },
378
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
379
+ });
380
+ const body = await upstream.json().catch(() => ({}));
381
+ if (!upstream.ok) {
382
+ const message = body?.error?.message || `DeepSeek balance API HTTP ${upstream.status}`;
383
+ json(res, upstream.status === 401 || upstream.status === 403 ? 401 : 502, { error: message });
384
+ return;
385
+ }
386
+ json(res, 200, body);
387
+ } catch (error) {
388
+ json(res, 502, { error: error instanceof Error ? error.message : String(error) });
389
+ }
390
+ };
391
+
392
+ const pricingHandler = async (req, res) => {
393
+ if (req.method !== "GET" && req.method !== "HEAD") {
394
+ json(res, 405, { error: "method not allowed" });
395
+ return;
396
+ }
397
+ if (!isTrustedRead(req)) {
398
+ json(res, 403, { error: "forbidden" });
399
+ return;
400
+ }
401
+ if (pricing.fetchedAt === 0) {
402
+ try {
403
+ await refreshPricing(ctx);
404
+ } catch {}
405
+ }
406
+ json(res, 200, {
407
+ ...pricing.data,
408
+ fetchedAt: pricing.fetchedAt,
409
+ source: pricing.source
410
+ });
411
+ };
412
+
413
+ ctx.effect(() => ctx.webServer.register({ kind: "exact", path: "/api/deepseek-balance", handler: balanceHandler }), "deepseek-balance: /api/deepseek-balance route");
414
+ ctx.effect(() => ctx.webServer.register({ kind: "exact", path: "/api/deepseek-pricing", handler: pricingHandler }), "deepseek-balance: /api/deepseek-pricing route");
415
+
416
+ ctx.effect(() => {
417
+ refreshPricing(ctx).catch(() => {});
418
+ let timer;
419
+ const schedule = () => {
420
+ const delay = msUntilNextHourBeijing(1);
421
+ timer = setTimeout(async () => {
422
+ await refreshPricing(ctx).catch(() => {});
423
+ schedule();
424
+ }, delay);
425
+ };
426
+ schedule();
427
+ return () => clearTimeout(timer);
428
+ }, "deepseek-balance: daily 01:00 Beijing pricing refresh");
429
+ }
430
+
431
+ export { apply, inject, name };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@lancecheney/dsh-deepseek-balance",
3
+ "description": "Session-header DeepSeek API balance, per-conversation spend, and peak/off-peak pricing indicator",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": ["lib", "cordis.patch.yml", "README.md", "README.zh.md"],
14
+ "dsh": {
15
+ "bundle": {
16
+ "patch": "./cordis.patch.yml"
17
+ },
18
+ "client": {
19
+ "inject": [
20
+ "@deepseek-ai/dsh-client-locale",
21
+ "@deepseek-ai/dsh-client-runtime",
22
+ "@deepseek-ai/dsh-client-ui-conversation"
23
+ ],
24
+ "platform": "web"
25
+ }
26
+ },
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "zod": "^4.4.3"
30
+ },
31
+ "peerDependencies": {
32
+ "@deepseek-ai/cordis": "^4.0.1",
33
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
34
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
35
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
36
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
37
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
38
+ "react": "^18.2.0"
39
+ }
40
+ }