dsh-balance-widget 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/LICENSE +21 -0
- package/README.en.md +106 -0
- package/README.md +102 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +294 -0
- package/lib/index.js +246 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-balance-widget contributors
|
|
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.en.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# dsh-balance-widget
|
|
2
|
+
|
|
3
|
+
[中文](README.md) | **English**
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img src="https://img.shields.io/github/stars/LL-cmyk-so/dsh-balance-widget?style=flat-square&label=Stars" alt="Stars">
|
|
7
|
+
<img src="https://img.shields.io/github/license/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="License">
|
|
8
|
+
<img src="https://img.shields.io/github/last-commit/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="Last commit">
|
|
9
|
+
<img src="https://img.shields.io/badge/Node%2024-ready-brightgreen?style=flat-square" alt="Node 24">
|
|
10
|
+
<img src="https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square" alt="Zero deps">
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) Web GUI: a 💰 icon in the corner of the conversation session header. Click it to see your DeepSeek account balance and the current session's estimated spend.
|
|
14
|
+
|
|
15
|
+
## How it differs from similar plugins
|
|
16
|
+
|
|
17
|
+
| Aspect | This plugin | Others (dsh-balance / dsh-token-price / ...) |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| **Zero external dependencies** | ✅ Imports no `@deepseek-ai/*` packages, no native modules | ❌ Most depend on dsh SDK packages |
|
|
20
|
+
| **Node 24 ready** | ✅ Works out of the box on any profile layout | ⚠️ Many community plugins still error on Node 24 |
|
|
21
|
+
| **Boot stability** | ✅ Registers routes via official `ctx.webServer`; never conflicts with apiproxy | ⚠️ Some self-host HTTP servers that crash `dsh web` on boot |
|
|
22
|
+
| **On-demand queries** | ✅ Fetches balance only on click; no polling, zero background requests | Some always-on badges refresh on a timer |
|
|
23
|
+
| **Peak/off-peak pricing** | ✅ Built-in official 2026-08-17 rate table, auto-switches by window | Partial support |
|
|
24
|
+
| **Security** | ✅ API key stays in the host process; loopback-only guard | Varies |
|
|
25
|
+
|
|
26
|
+
**In one line**: *The zero-dependency, Node 24-ready balance/cost widget that never breaks `dsh web` boot.*
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Account balance** — On click, the host proxies DeepSeek's official `GET /user/balance` and shows the `¥` balance. The API key is resolved through the host credentials service and never leaves the host process; the browser only talks to same-origin routes.
|
|
31
|
+
- **Session cost (estimate)** — Computed from the session's `tokenUsage` projection × DeepSeek's official peak/off-peak price table. Follows the configured model (default `deepseek-v4-flash`, switchable to `deepseek-v4-pro`) and the Beijing-time peak/off-peak windows automatically.
|
|
32
|
+
- **Token usage** — Also shows the session's input (incl. cache hits) / output tokens.
|
|
33
|
+
- **On-demand refresh** — No polling, no background requests; the balance endpoint is only hit when you click the icon. Costs zero tokens to use.
|
|
34
|
+
|
|
35
|
+
## Why this plugin
|
|
36
|
+
|
|
37
|
+
- **Zero external dependencies** — the host half imports no `@deepseek-ai/*` packages and no native modules, so it loads from any profile layout and works on **Node 24** (many community cordis plugins still lag on Node 24).
|
|
38
|
+
- **Uses official APIs only** — routes are registered through `ctx.webServer` (the same seam `dsh-ssh` uses) with loopback-only guards; no conflicting custom HTTP servers.
|
|
39
|
+
|
|
40
|
+
## Architecture
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
host half (lib/index.js)
|
|
44
|
+
ctx.webServer.register:
|
|
45
|
+
GET /api/dsh-balance/balance → official /user/balance (loopback-only guard)
|
|
46
|
+
GET /api/dsh-balance/cost → reserved route (cost priced client-side)
|
|
47
|
+
|
|
48
|
+
client half (lib/client.js)
|
|
49
|
+
ctx.slots.inject("conversation.session.header.utilities")
|
|
50
|
+
→ 💰 icon (session header, top-right corner)
|
|
51
|
+
→ click fetches same-origin /api/dsh-balance/balance → popover with balance + cost + tokens
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
From npm (once published):
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
dsh plugin --profile web add dsh-balance-widget
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
From GitHub (development):
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
git clone https://github.com/LL-cmyk-so/dsh-balance-widget.git
|
|
66
|
+
cd dsh-balance-widget
|
|
67
|
+
dsh plugin --profile web add "link:$(pwd)"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Then restart `dsh web`.
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
In `~/.dsh/profiles/web/cordis.patch.yml`:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
- id: balance-widget
|
|
78
|
+
name: dsh-balance-widget
|
|
79
|
+
config:
|
|
80
|
+
balanceBaseURL: https://api.deepseek.com # official balance endpoint
|
|
81
|
+
balanceApiKeyEnv: DEEPSEEK_API_KEY # credential ref for the API key
|
|
82
|
+
requestTimeoutMs: 5000 # balance request timeout
|
|
83
|
+
modelId: deepseek-v4-flash # pricing model (or deepseek-v4-pro)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Pricing
|
|
87
|
+
|
|
88
|
+
Built-in DeepSeek official peak/off-peak pricing (CNY per 1M tokens), effective 2026-08-17. Peak windows are Beijing time 09:00–12:00 and 14:00–18:00; prices are double the off-peak rates:
|
|
89
|
+
|
|
90
|
+
| Model | Window | Cache hit (input) | Cache miss (input) | Output |
|
|
91
|
+
| --- | --- | --- | --- | --- |
|
|
92
|
+
| V4-Flash | Off-peak | 0.05 | 1.5 | 4.5 |
|
|
93
|
+
| V4-Flash | Peak | 0.10 | 3.0 | 9.0 |
|
|
94
|
+
| V4-Pro | Off-peak | 0.15 | 4.5 | 13.5 |
|
|
95
|
+
| V4-Pro | Peak | 0.30 | 9.0 | 27.0 |
|
|
96
|
+
|
|
97
|
+
`deepseek-chat` / `deepseek-reasoner` aliases map to Flash / Pro pricing respectively. Costs are **estimates**; actual billing from the provider is authoritative.
|
|
98
|
+
|
|
99
|
+
## Verify
|
|
100
|
+
|
|
101
|
+
- Config tree: `dsh --profile web --dump-config` should show a `balance-widget` entry.
|
|
102
|
+
- Balance route: after restarting dsh web, `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` should return `{ ok, balance_infos, modelId }`.
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# dsh-balance-widget
|
|
2
|
+
|
|
3
|
+
**中文** | [English](README.en.md)
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img src="https://img.shields.io/github/stars/LL-cmyk-so/dsh-balance-widget?style=flat-square&label=Stars" alt="Stars">
|
|
7
|
+
<img src="https://img.shields.io/github/license/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="License">
|
|
8
|
+
<img src="https://img.shields.io/github/last-commit/LL-cmyk-so/dsh-balance-widget?style=flat-square" alt="Last commit">
|
|
9
|
+
<img src="https://img.shields.io/badge/Node%2024-ready-brightgreen?style=flat-square" alt="Node 24">
|
|
10
|
+
<img src="https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square" alt="Zero deps">
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:在会话头部右上角(角落)渲染一个 💰 图标,点击弹出账户余额与本会话的估算成本。
|
|
14
|
+
|
|
15
|
+
## 与同类插件的区别
|
|
16
|
+
|
|
17
|
+
| 特点 | 本插件 | 同类插件(dsh-balance / dsh-token-price 等) |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| **零外部依赖** | ✅ 不 import 任何 `@deepseek-ai/*` 包,无原生模块 | ❌ 多数依赖 dsh SDK 包 |
|
|
20
|
+
| **Node 24 兼容** | ✅ 天然兼容(零依赖设计),任何 profile 布局可加载 | ⚠️ 不少社区插件在 Node 24 下报错 |
|
|
21
|
+
| **启动稳定性** | ✅ 用官方 `ctx.webServer` 注册路由,不与 apiproxy 冲突 | ⚠️ 有的自建 HTTP 服务导致 dsh web 启动崩溃 |
|
|
22
|
+
| **按需查询** | ✅ 点击才查余额,无轮询、零后台请求 | 有的常驻徽章定时刷新 |
|
|
23
|
+
| **峰谷定价** | ✅ 内置官方 2026-08-17 峰谷价表,自动按时段切换 | 部分支持 |
|
|
24
|
+
| **安全性** | ✅ API key 仅在宿主进程,loopback-only 守卫 | 参差不齐 |
|
|
25
|
+
|
|
26
|
+
**一句话**:*零依赖、Node 24 就绪、永不拖垮 dsh web 启动的余额/成本小部件。*
|
|
27
|
+
|
|
28
|
+
## 功能
|
|
29
|
+
|
|
30
|
+
- **账户余额** — 点击图标时经宿主代理查询 DeepSeek 官方 `GET /user/balance`,展示 `¥` 余额;API key 只在宿主进程内读取(凭据服务),浏览器不接触密钥。
|
|
31
|
+
- **本会话成本(估算)** — 由会话的 `tokenUsage` 投影 × DeepSeek 官方峰谷定价表计算,随当前会话模型(默认 `deepseek-v4-flash`,可在配置中改为 `deepseek-v4-pro`)与北京时间高峰/空闲时段自动切换。
|
|
32
|
+
- **Token 用量** — 同时展示本会话输入(含缓存命中)/ 输出 token 数。
|
|
33
|
+
- **按需刷新** — 无轮询、无后台请求;只有点击图标时才发起余额查询,不消耗任何 token。
|
|
34
|
+
|
|
35
|
+
## 架构
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
host 半区 (lib/index.js)
|
|
39
|
+
ctx.webServer.register:
|
|
40
|
+
GET /api/dsh-balance/balance → 官方 /user/balance(loopback-only 守卫)
|
|
41
|
+
GET /api/dsh-balance/cost → 保留路由(成本目前客户端计价)
|
|
42
|
+
依赖:零外部 @deepseek-ai/* import,任何 profile 布局均可解析
|
|
43
|
+
|
|
44
|
+
client 半区 (lib/client.js)
|
|
45
|
+
ctx.slots.inject("conversation.session.header.utilities")
|
|
46
|
+
→ 💰 图标(会话头部右上角)
|
|
47
|
+
→ 点击 fetch 同源 /api/dsh-balance/balance → 弹层展示余额 + 成本 + token
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 安装
|
|
51
|
+
|
|
52
|
+
npm 安装(发布后):
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
dsh plugin --profile web add dsh-balance-widget
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
GitHub 仓库安装(开发调试):
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
git clone https://github.com/LL-cmyk-so/dsh-balance-widget.git
|
|
62
|
+
cd dsh-balance-widget
|
|
63
|
+
dsh plugin --profile web add "link:$(pwd)"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
装完重启 `dsh web` 生效。
|
|
67
|
+
|
|
68
|
+
## 配置
|
|
69
|
+
|
|
70
|
+
在 `~/.dsh/profiles/web/cordis.patch.yml` 中:
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
- id: balance-widget
|
|
74
|
+
name: dsh-balance-widget
|
|
75
|
+
config:
|
|
76
|
+
balanceBaseURL: https://api.deepseek.com # 官方余额接口
|
|
77
|
+
balanceApiKeyEnv: DEEPSEEK_API_KEY # 凭据服务中的密钥 ref
|
|
78
|
+
requestTimeoutMs: 5000 # 余额查询超时
|
|
79
|
+
modelId: deepseek-v4-flash # 成本计价模型(可改 deepseek-v4-pro)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 定价说明
|
|
83
|
+
|
|
84
|
+
内置 DeepSeek 官方 2026-08-17 峰谷定价(元 / 百万 tokens),高峰时段为北京时间 09:00–12:00、14:00–18:00,价格为空闲时段两倍:
|
|
85
|
+
|
|
86
|
+
| 模型 | 时段 | 缓存命中(输入) | 缓存未命中(输入) | 输出 |
|
|
87
|
+
| --- | --- | --- | --- | --- |
|
|
88
|
+
| V4-Flash | 空闲 | 0.05 | 1.5 | 4.5 |
|
|
89
|
+
| V4-Flash | 高峰 | 0.10 | 3.0 | 9.0 |
|
|
90
|
+
| V4-Pro | 空闲 | 0.15 | 4.5 | 13.5 |
|
|
91
|
+
| V4-Pro | 高峰 | 0.30 | 9.0 | 27.0 |
|
|
92
|
+
|
|
93
|
+
`deepseek-chat` / `deepseek-reasoner` 别名分别映射到 Flash / Pro 价格。成本为**估算值**,实际以官方账单为准。
|
|
94
|
+
|
|
95
|
+
## 验证
|
|
96
|
+
|
|
97
|
+
- 配置树:`dsh --profile web --dump-config` 应出现 `balance-widget` 条目
|
|
98
|
+
- 余额路由:重启 dsh web 后 `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` 应返回 `{ ok, balance_infos, modelId }`
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# dsh-balance-widget bundle patch: inserts the dual-face plugin row into the
|
|
2
|
+
# web profile roster. The row is a bare plugin by package name: the node half
|
|
3
|
+
# (exports ".") runs in the host process and registers the balance/cost API
|
|
4
|
+
# routes; the `dsh.client` declaration in package.json makes the browser half
|
|
5
|
+
# (exports "./client", served at /plugins/<id>/client.js) load in the web GUI.
|
|
6
|
+
- insert:
|
|
7
|
+
- id: balance-widget
|
|
8
|
+
name: 'dsh-balance-widget'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-balance-widget",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
8
|
+
let react = require("react");
|
|
9
|
+
//#region lib/types/client/styles.js
|
|
10
|
+
const css = ".dshbw_wrap{position:relative;display:inline-flex;align-items:center}.dshbw_btn{background:none;border:none;cursor:pointer;color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border-radius:6px;font-size:12px;line-height:18px;font-family:inherit}.dshbw_btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.dshbw_pop{position:absolute;top:calc(100% + 6px);right:0;z-index:60;min-width:230px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l3);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.18);padding:12px 14px;font-size:12px;line-height:20px;color:var(--dsw-alias-label-primary)}.dshbw_row{display:flex;justify-content:space-between;gap:12px;padding:2px 0}.dshbw_val{font-weight:600;color:var(--dsw-alias-label-primary)}.dshbw_err{color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px;margin-top:6px}.dshbw_hint{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;margin-top:6px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:6px}.dshbw_foot{margin-top:8px;text-align:right}";
|
|
11
|
+
const tagId = "dsh-balance-widget/styles.css";
|
|
12
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
13
|
+
const tag = document.createElement("style");
|
|
14
|
+
tag.dataset.plugin = "dsh-balance-widget";
|
|
15
|
+
tag.dataset.pluginCss = tagId;
|
|
16
|
+
tag.textContent = css;
|
|
17
|
+
document.head.appendChild(tag);
|
|
18
|
+
}
|
|
19
|
+
var styles = {
|
|
20
|
+
"wrap": "dshbw_wrap",
|
|
21
|
+
"btn": "dshbw_btn",
|
|
22
|
+
"pop": "dshbw_pop",
|
|
23
|
+
"row": "dshbw_row",
|
|
24
|
+
"val": "dshbw_val",
|
|
25
|
+
"err": "dshbw_err",
|
|
26
|
+
"hint": "dshbw_hint",
|
|
27
|
+
"foot": "dshbw_foot"
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region lib/types/client/pricing.js
|
|
31
|
+
/**
|
|
32
|
+
* DeepSeek official pricing, in CNY per 1M tokens, effective 2026-08-17
|
|
33
|
+
* (peak/off-peak two-tier billing). Peak windows are Beijing time:
|
|
34
|
+
* 09:00-12:00 and 14:00-18:00; off-peak is half the peak price.
|
|
35
|
+
* Prices are hard-coded from the official announcement and are estimates
|
|
36
|
+
* for display — actual billing is authoritative from the provider.
|
|
37
|
+
*/
|
|
38
|
+
const PRICING = {
|
|
39
|
+
"deepseek-v4-flash": {
|
|
40
|
+
peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 },
|
|
41
|
+
offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 }
|
|
42
|
+
},
|
|
43
|
+
"deepseek-v4-pro": {
|
|
44
|
+
peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 },
|
|
45
|
+
offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 }
|
|
46
|
+
},
|
|
47
|
+
"deepseek-chat": {
|
|
48
|
+
peak: { inputMiss: 3.0, inputHit: 0.10, output: 9.0 },
|
|
49
|
+
offPeak: { inputMiss: 1.5, inputHit: 0.05, output: 4.5 }
|
|
50
|
+
},
|
|
51
|
+
"deepseek-reasoner": {
|
|
52
|
+
peak: { inputMiss: 9.0, inputHit: 0.30, output: 27.0 },
|
|
53
|
+
offPeak: { inputMiss: 4.5, inputHit: 0.15, output: 13.5 }
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/** Fall back to Flash pricing for unknown model ids. */
|
|
57
|
+
function pricingFor(modelId) {
|
|
58
|
+
return PRICING[modelId] ?? PRICING["deepseek-v4-flash"];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Whether `date` falls inside a Beijing-time peak window.
|
|
62
|
+
* The host's local timezone is used; the widget is for a CN deployment.
|
|
63
|
+
*/
|
|
64
|
+
function isPeak(date) {
|
|
65
|
+
const hour = date.getHours();
|
|
66
|
+
return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Price one session's token usage into a CNY estimate.
|
|
70
|
+
* @param usage - tokenUsage projection value.
|
|
71
|
+
* @param modelId - provider model id used for pricing.
|
|
72
|
+
* @returns { input, output, total } in CNY, or null when usage is absent.
|
|
73
|
+
*/
|
|
74
|
+
function priceSession(usage, modelId) {
|
|
75
|
+
if (usage === void 0 || usage === null) return null;
|
|
76
|
+
const input = usage.uncachedInputTokens ?? 0;
|
|
77
|
+
const hit = usage.cacheReadTokens ?? 0;
|
|
78
|
+
const write = usage.cacheWriteTokens ?? 0;
|
|
79
|
+
const output = usage.outputTokens ?? 0;
|
|
80
|
+
if (input + hit + write + output <= 0) return null;
|
|
81
|
+
const table = isPeak(new Date()) ? pricingFor(modelId).peak : pricingFor(modelId).offPeak;
|
|
82
|
+
const inputCost = (input * table.inputMiss + (hit + write) * table.inputHit) / 1e6;
|
|
83
|
+
const outputCost = (output * table.output) / 1e6;
|
|
84
|
+
return {
|
|
85
|
+
input: inputCost,
|
|
86
|
+
output: outputCost,
|
|
87
|
+
total: inputCost + outputCost
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Format a CNY amount: ¥ + 3 significant decimals (drop trailing zeros). */
|
|
91
|
+
function formatCny(value) {
|
|
92
|
+
if (value === void 0 || value === null || !Number.isFinite(value)) return "—";
|
|
93
|
+
const rounded = Math.round(value * 1e4) / 1e4;
|
|
94
|
+
return `¥${rounded.toLocaleString("zh-CN", { minimumFractionDigits: 0, maximumFractionDigits: 4 })}`;
|
|
95
|
+
}
|
|
96
|
+
/** Format token counts compactly: 1234 -> 1.2K. */
|
|
97
|
+
function formatTokens(value) {
|
|
98
|
+
if (value === void 0 || value === null) return "—";
|
|
99
|
+
if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
|
|
100
|
+
if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
|
|
101
|
+
return String(value);
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region lib/types/client/balance-api.js
|
|
105
|
+
/** Same-origin balance route (host half registers it over ctx.webServer). */
|
|
106
|
+
const BALANCE_ROUTE = "/api/dsh-balance/balance";
|
|
107
|
+
/**
|
|
108
|
+
* Fetch the account balance from the host proxy.
|
|
109
|
+
* @returns { ok, balanceInfos?, error? }
|
|
110
|
+
*/
|
|
111
|
+
async function fetchBalance() {
|
|
112
|
+
try {
|
|
113
|
+
const response = await fetch(BALANCE_ROUTE, { headers: { accept: "application/json" } });
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
let detail = "";
|
|
116
|
+
try {
|
|
117
|
+
detail = (await response.json()).error ?? "";
|
|
118
|
+
} catch (_error) {
|
|
119
|
+
/* ignore body parse failure */
|
|
120
|
+
}
|
|
121
|
+
return { ok: false, error: detail || `balance endpoint responded ${response.status}` };
|
|
122
|
+
}
|
|
123
|
+
const payload = await response.json();
|
|
124
|
+
if (payload.ok === false) return { ok: false, error: payload.error ?? "unknown balance error" };
|
|
125
|
+
return { ok: true, balanceInfos: payload.balance_infos ?? [], modelId: payload.modelId };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Pick the display balance from balance_infos (prefer CNY, else first). */
|
|
131
|
+
function displayBalance(balanceInfos) {
|
|
132
|
+
if (!Array.isArray(balanceInfos) || balanceInfos.length === 0) return null;
|
|
133
|
+
const cny = balanceInfos.find((entry) => (entry.currency ?? "").toUpperCase() === "CNY");
|
|
134
|
+
const entry = cny ?? balanceInfos[0];
|
|
135
|
+
const total = Number(entry.total_balance);
|
|
136
|
+
if (!Number.isFinite(total)) return null;
|
|
137
|
+
const symbol = (entry.currency ?? "").toUpperCase() === "CNY" ? "¥" : (entry.currency ?? "") + " ";
|
|
138
|
+
return { symbol, total, currency: entry.currency };
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region lib/types/client/BalanceWidget.js
|
|
142
|
+
/**
|
|
143
|
+
* Corner widget: a small balance button in the session header utilities
|
|
144
|
+
* slot. Clicking opens a popover with the account balance and the current
|
|
145
|
+
* session's estimated spend (priced from the tokenUsage projection).
|
|
146
|
+
* @param {Object} props - useProjection (framework), sessionId (slot scope).
|
|
147
|
+
*/
|
|
148
|
+
function BalanceWidget({ useProjection, sessionId, t }) {
|
|
149
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
150
|
+
const [balance, setBalance] = (0, react.useState)(null);
|
|
151
|
+
const [modelId, setModelId] = (0, react.useState)("deepseek-v4-flash");
|
|
152
|
+
const [error, setError] = (0, react.useState)(null);
|
|
153
|
+
const [loading, setLoading] = (0, react.useState)(false);
|
|
154
|
+
const usage = useProjection("tokenUsage");
|
|
155
|
+
const priced = priceSession(usage, modelId);
|
|
156
|
+
const toggle = async () => {
|
|
157
|
+
const next = !open;
|
|
158
|
+
setOpen(next);
|
|
159
|
+
if (next) {
|
|
160
|
+
setLoading(true);
|
|
161
|
+
setError(null);
|
|
162
|
+
const result = await fetchBalance();
|
|
163
|
+
if (result.ok) {
|
|
164
|
+
setBalance(displayBalance(result.balanceInfos));
|
|
165
|
+
if (typeof result.modelId === "string" && result.modelId !== "") setModelId(result.modelId);
|
|
166
|
+
} else {
|
|
167
|
+
setBalance(null);
|
|
168
|
+
setError(result.error);
|
|
169
|
+
}
|
|
170
|
+
setLoading(false);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
return (0, react_jsx_runtime.jsx)("div", {
|
|
174
|
+
className: styles.wrap,
|
|
175
|
+
children: (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, {
|
|
176
|
+
children: [(0, react_jsx_runtime.jsxs)("button", {
|
|
177
|
+
type: "button",
|
|
178
|
+
className: styles.btn,
|
|
179
|
+
"aria-label": t("aria"),
|
|
180
|
+
title: t("title"),
|
|
181
|
+
onClick: toggle,
|
|
182
|
+
children: ["💰", balance !== null && (0, react_jsx_runtime.jsx)("span", {
|
|
183
|
+
children: `${balance.symbol}${balance.total.toFixed(2)}`
|
|
184
|
+
})]
|
|
185
|
+
}), open && (0, react_jsx_runtime.jsx)("div", {
|
|
186
|
+
className: styles.pop,
|
|
187
|
+
role: "dialog",
|
|
188
|
+
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
189
|
+
children: [(0, react_jsx_runtime.jsx)("div", {
|
|
190
|
+
className: styles.row,
|
|
191
|
+
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
192
|
+
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
193
|
+
children: t("balance")
|
|
194
|
+
}), (0, react_jsx_runtime.jsx)("span", {
|
|
195
|
+
className: styles.val,
|
|
196
|
+
children: loading ? "…" : balance !== null ? `${balance.symbol}${balance.total.toFixed(2)}` : "—"
|
|
197
|
+
})]
|
|
198
|
+
})
|
|
199
|
+
}), (0, react_jsx_runtime.jsx)("div", {
|
|
200
|
+
className: styles.row,
|
|
201
|
+
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
202
|
+
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
203
|
+
children: t("sessionCost")
|
|
204
|
+
}), (0, react_jsx_runtime.jsx)("span", {
|
|
205
|
+
className: styles.val,
|
|
206
|
+
children: priced !== null ? formatCny(priced.total) : "—"
|
|
207
|
+
})]
|
|
208
|
+
})
|
|
209
|
+
}), priced !== null && (0, react_jsx_runtime.jsx)("div", {
|
|
210
|
+
className: styles.row,
|
|
211
|
+
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
212
|
+
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
213
|
+
children: t("tokens")
|
|
214
|
+
}), (0, react_jsx_runtime.jsx)("span", {
|
|
215
|
+
className: styles.val,
|
|
216
|
+
children: `${formatTokens((usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0))} in / ${formatTokens(usage.outputTokens ?? 0)} out`
|
|
217
|
+
})]
|
|
218
|
+
})
|
|
219
|
+
}), error !== null && (0, react_jsx_runtime.jsx)("div", {
|
|
220
|
+
className: styles.err,
|
|
221
|
+
role: "status",
|
|
222
|
+
children: error
|
|
223
|
+
}), (0, react_jsx_runtime.jsx)("div", {
|
|
224
|
+
className: styles.hint,
|
|
225
|
+
children: t("hint")
|
|
226
|
+
}), (0, react_jsx_runtime.jsx)("div", {
|
|
227
|
+
className: styles.foot,
|
|
228
|
+
children: (0, react_jsx_runtime.jsx)("button", {
|
|
229
|
+
type: "button",
|
|
230
|
+
className: styles.btn,
|
|
231
|
+
onClick: () => setOpen(false),
|
|
232
|
+
children: t("close")
|
|
233
|
+
})
|
|
234
|
+
})]
|
|
235
|
+
})
|
|
236
|
+
})]
|
|
237
|
+
})
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region lib/types/client/locales.js
|
|
242
|
+
/** Dictionary namespace owned by this plugin. */
|
|
243
|
+
const NS = "balance-widget";
|
|
244
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
245
|
+
const zh = {
|
|
246
|
+
"aria": "查看账户余额与本轮成本",
|
|
247
|
+
"title": "余额与成本",
|
|
248
|
+
"balance": "账户余额",
|
|
249
|
+
"sessionCost": "本会话成本(估算)",
|
|
250
|
+
"tokens": "Token 用量",
|
|
251
|
+
"hint": "价格按 DeepSeek 官方峰谷定价估算,实际以账单为准。",
|
|
252
|
+
"close": "关闭"
|
|
253
|
+
};
|
|
254
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
255
|
+
const en = {
|
|
256
|
+
"aria": "View account balance and session cost",
|
|
257
|
+
"title": "Balance & cost",
|
|
258
|
+
"balance": "Account balance",
|
|
259
|
+
"sessionCost": "This session cost (est.)",
|
|
260
|
+
"tokens": "Token usage",
|
|
261
|
+
"hint": "Prices estimated from DeepSeek official peak/off-peak rates; billing is authoritative.",
|
|
262
|
+
"close": "Close"
|
|
263
|
+
};
|
|
264
|
+
//#endregion
|
|
265
|
+
//#region lib/types/client/index.js
|
|
266
|
+
/** Required services: the seat's slot registry and locale registry. */
|
|
267
|
+
const inject = [
|
|
268
|
+
"slots",
|
|
269
|
+
"locale"
|
|
270
|
+
];
|
|
271
|
+
/**
|
|
272
|
+
* Client plugin body: register the corner balance widget in the
|
|
273
|
+
* conversation session header utilities slot.
|
|
274
|
+
* @param ctx - client root context.
|
|
275
|
+
*/
|
|
276
|
+
function apply(ctx) {
|
|
277
|
+
ctx.effect(() => ctx.locale.register(NS, {
|
|
278
|
+
zh,
|
|
279
|
+
en
|
|
280
|
+
}), "dsh-balance-widget: dictionaries");
|
|
281
|
+
ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
|
|
282
|
+
name: "conversation.session.header.utilities",
|
|
283
|
+
id: "balance-widget",
|
|
284
|
+
order: 90,
|
|
285
|
+
locale: NS,
|
|
286
|
+
inject: (sessionId) => ({ sessionId })
|
|
287
|
+
}, BalanceWidget));
|
|
288
|
+
}
|
|
289
|
+
//#endregion
|
|
290
|
+
exports.apply = apply;
|
|
291
|
+
exports.inject = inject;
|
|
292
|
+
return module.exports;
|
|
293
|
+
}
|
|
294
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-balance-widget — host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers two loopback-only API routes over ctx.webServer:
|
|
5
|
+
* GET /api/dsh-balance/balance → DeepSeek account balance (official /user/balance)
|
|
6
|
+
* GET /api/dsh-balance/cost → cost endpoint (reserved; pricing is client-side)
|
|
7
|
+
*
|
|
8
|
+
* The API key is resolved through the host's credentials service (the same
|
|
9
|
+
* seam dsh-llm uses) and never leaves the host process: the browser only
|
|
10
|
+
* talks to these same-origin routes. Routes are guarded loopback-only,
|
|
11
|
+
* mirroring dsh-ssh's pattern. This module deliberately imports NO
|
|
12
|
+
* @deepseek-ai/* packages so it resolves from any profile layout (npm,
|
|
13
|
+
* workspace link, or flat fallback).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Stable cordis plugin name (also the client bundle mount id). */
|
|
17
|
+
export const name = "balance-widget";
|
|
18
|
+
|
|
19
|
+
/** Services required before the balance surface can mount. */
|
|
20
|
+
export const inject = ["webServer"];
|
|
21
|
+
|
|
22
|
+
/** Route paths shared with the browser half (spelled here, not imported). */
|
|
23
|
+
export const API = {
|
|
24
|
+
balance: "/api/dsh-balance/balance",
|
|
25
|
+
cost: "/api/dsh-balance/cost"
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Default DeepSeek API base (mirrors dsh-llm-deepseek's PUBLIC_BASE_URL). */
|
|
29
|
+
const DEFAULT_BASE_URL = "https://api.deepseek.com";
|
|
30
|
+
/** Default credential ref, mirrors dsh-llm-deepseek's DEFAULT_API_KEY_ENV. */
|
|
31
|
+
const DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
32
|
+
/** Default model used to price client-side session cost. */
|
|
33
|
+
const DEFAULT_MODEL_ID = "deepseek-v4-flash";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Config schema exposed to the cordis loader. Cordis calls
|
|
37
|
+
* `Config["~standard"].validate(config)` and expects a Standard Schema
|
|
38
|
+
* (version 1) result — the same interface @deepseek-ai/schemastery produces.
|
|
39
|
+
* We hand-build it here so the host half keeps ZERO external imports (the
|
|
40
|
+
* workspace-link layout cannot resolve @deepseek-ai/* from the plugin's own
|
|
41
|
+
* path, and importing nothing sidesteps that entirely).
|
|
42
|
+
*/
|
|
43
|
+
const CONFIG_FIELDS = {
|
|
44
|
+
balanceBaseURL: "string",
|
|
45
|
+
balanceApiKeyEnv: "string",
|
|
46
|
+
requestTimeoutMs: "number",
|
|
47
|
+
modelId: "string"
|
|
48
|
+
};
|
|
49
|
+
const CONFIG_DEFAULTS = {
|
|
50
|
+
balanceBaseURL: DEFAULT_BASE_URL,
|
|
51
|
+
balanceApiKeyEnv: DEFAULT_API_KEY_ENV,
|
|
52
|
+
requestTimeoutMs: 5000,
|
|
53
|
+
modelId: DEFAULT_MODEL_ID
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const Config = {
|
|
57
|
+
"~standard": {
|
|
58
|
+
version: 1,
|
|
59
|
+
vendor: "dsh-balance-widget",
|
|
60
|
+
validate(value) {
|
|
61
|
+
if (value === void 0 || value === null) value = {};
|
|
62
|
+
if (typeof value !== "object") {
|
|
63
|
+
return { issues: [{ message: "config must be an object", path: [] }] };
|
|
64
|
+
}
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [key, type] of Object.entries(CONFIG_FIELDS)) {
|
|
67
|
+
const raw = value[key];
|
|
68
|
+
const fallback = CONFIG_DEFAULTS[key];
|
|
69
|
+
if (raw === void 0 || raw === null) {
|
|
70
|
+
out[key] = fallback;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (type === "string" && typeof raw === "string" && raw !== "") {
|
|
74
|
+
out[key] = raw;
|
|
75
|
+
} else if (type === "number" && typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
|
|
76
|
+
out[key] = raw;
|
|
77
|
+
} else {
|
|
78
|
+
out[key] = fallback;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { value: out };
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
shape: CONFIG_FIELDS,
|
|
85
|
+
defaults: CONFIG_DEFAULTS
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Resolve config with defaults applied. */
|
|
89
|
+
function resolveConfig(raw) {
|
|
90
|
+
const value = raw ?? {};
|
|
91
|
+
return {
|
|
92
|
+
balanceBaseURL: typeof value.balanceBaseURL === "string" && value.balanceBaseURL !== ""
|
|
93
|
+
? value.balanceBaseURL
|
|
94
|
+
: Config.defaults.balanceBaseURL,
|
|
95
|
+
balanceApiKeyEnv: typeof value.balanceApiKeyEnv === "string" && value.balanceApiKeyEnv !== ""
|
|
96
|
+
? value.balanceApiKeyEnv
|
|
97
|
+
: Config.defaults.balanceApiKeyEnv,
|
|
98
|
+
requestTimeoutMs: typeof value.requestTimeoutMs === "number" && value.requestTimeoutMs > 0
|
|
99
|
+
? value.requestTimeoutMs
|
|
100
|
+
: Config.defaults.requestTimeoutMs,
|
|
101
|
+
modelId: typeof value.modelId === "string" && value.modelId !== ""
|
|
102
|
+
? value.modelId
|
|
103
|
+
: Config.defaults.modelId
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Write a JSON response with a status code. */
|
|
108
|
+
function writeJson(res, status, body) {
|
|
109
|
+
const payload = JSON.stringify(body);
|
|
110
|
+
res.writeHead(status, {
|
|
111
|
+
"content-type": "application/json; charset=utf-8",
|
|
112
|
+
"content-length": Buffer.byteLength(payload)
|
|
113
|
+
});
|
|
114
|
+
res.end(payload);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Whether the request comes from the loopback interface. */
|
|
118
|
+
function isLoopbackRequest(req) {
|
|
119
|
+
const address = req.socket?.remoteAddress ?? "";
|
|
120
|
+
return address === "::1"
|
|
121
|
+
|| address === "::ffff:127.0.0.1"
|
|
122
|
+
|| address === "127.0.0.1"
|
|
123
|
+
|| address === "localhost"
|
|
124
|
+
|| address.startsWith("::ffff:127.");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Guard helper: loopback fence + method check. */
|
|
128
|
+
function guard(req, res, method) {
|
|
129
|
+
if (!isLoopbackRequest(req)) {
|
|
130
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
if ((req.method ?? "GET") !== method) {
|
|
134
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve the DeepSeek API key through the host credentials service.
|
|
142
|
+
* Falls back to the launch environment, then process env. Returns undefined
|
|
143
|
+
* when absent everywhere.
|
|
144
|
+
*/
|
|
145
|
+
async function resolveApiKey(ctx, config) {
|
|
146
|
+
try {
|
|
147
|
+
const credentials = ctx.get("credentials");
|
|
148
|
+
if (credentials !== void 0) {
|
|
149
|
+
const hit = await credentials.resolve(config.balanceApiKeyEnv);
|
|
150
|
+
if (hit !== void 0 && hit.value !== void 0 && hit.value !== "") return hit.value;
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
ctx.logger?.warn?.("[dsh-balance-widget] credentials resolve failed: %s", error instanceof Error ? error.message : String(error));
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const environment = ctx.get("launchEnvironment");
|
|
157
|
+
if (environment !== void 0) {
|
|
158
|
+
const entry = environment.get(config.balanceApiKeyEnv);
|
|
159
|
+
if (entry !== void 0 && entry.value.length > 0) return entry.value;
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
/* fall through to process env */
|
|
163
|
+
}
|
|
164
|
+
const ambient = process.env[config.balanceApiKeyEnv];
|
|
165
|
+
if (ambient !== void 0 && ambient !== "") return ambient;
|
|
166
|
+
return void 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Query the official DeepSeek /user/balance endpoint.
|
|
171
|
+
* @returns { ok: true, ...payload } | { ok: false, error }
|
|
172
|
+
*/
|
|
173
|
+
async function fetchBalance(ctx, config) {
|
|
174
|
+
let apiKey;
|
|
175
|
+
try {
|
|
176
|
+
apiKey = await resolveApiKey(ctx, config);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
179
|
+
}
|
|
180
|
+
if (apiKey === void 0) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
error: `no API key for ${config.balanceApiKeyEnv}; store it through the credentials service or export it in the launching environment`
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
const timer = setTimeout(() => controller.abort(), config.requestTimeoutMs);
|
|
188
|
+
try {
|
|
189
|
+
const response = await fetch(`${config.balanceBaseURL}/user/balance`, {
|
|
190
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
191
|
+
signal: controller.signal
|
|
192
|
+
});
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
const text = await response.text().catch(() => "");
|
|
195
|
+
return { ok: false, error: `balance endpoint responded ${response.status}: ${text.slice(0, 200)}` };
|
|
196
|
+
}
|
|
197
|
+
const payload = await response.json();
|
|
198
|
+
return { ok: true, ...payload };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
error: error instanceof Error ? error.message : String(error)
|
|
203
|
+
};
|
|
204
|
+
} finally {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Build the route table for the balance widget. */
|
|
210
|
+
function makeRoutes(ctx, config) {
|
|
211
|
+
return [
|
|
212
|
+
{
|
|
213
|
+
kind: "exact",
|
|
214
|
+
path: API.balance,
|
|
215
|
+
handler: async (req, res) => {
|
|
216
|
+
if (!guard(req, res, "GET")) return;
|
|
217
|
+
const result = await fetchBalance(ctx, config);
|
|
218
|
+
if (result.ok) {
|
|
219
|
+
writeJson(res, 200, { ...result, modelId: config.modelId });
|
|
220
|
+
} else {
|
|
221
|
+
writeJson(res, 502, { error: result.error, modelId: config.modelId });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
kind: "exact",
|
|
227
|
+
path: API.cost,
|
|
228
|
+
handler: async (req, res) => {
|
|
229
|
+
if (!guard(req, res, "GET")) return;
|
|
230
|
+
// Cost is priced client-side from the tokenUsage projection; this
|
|
231
|
+
// route exists as the canonical ask point for future server pricing.
|
|
232
|
+
writeJson(res, 200, { ok: true, note: "cost is priced client-side from tokenUsage projection" });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Cordis plugin apply: register routes on the host webServer. */
|
|
239
|
+
export function apply(ctx, config) {
|
|
240
|
+
const resolved = resolveConfig(config);
|
|
241
|
+
const routes = makeRoutes(ctx, resolved);
|
|
242
|
+
const disposers = routes.map((route) => ctx.webServer.register(route));
|
|
243
|
+
ctx.effect(() => () => {
|
|
244
|
+
for (const dispose of disposers) dispose();
|
|
245
|
+
}, "dsh-balance-widget: routes");
|
|
246
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-balance-widget",
|
|
3
|
+
"description": "Balance & cost widget for the dsh web GUI: a corner icon in the conversation session header that shows the DeepSeek account balance and the current session's estimated spend on click. Host half proxies the official /user/balance API over ctx.webServer (loopback-only); client half renders the icon and popover. Zero external dependencies — works on Node 24.",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"cordis.patch.yml",
|
|
10
|
+
"README.md",
|
|
11
|
+
"README.en.md"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"default": "./lib/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./client": {
|
|
18
|
+
"default": "./lib/client.js"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"dsh": {
|
|
23
|
+
"bundle": {
|
|
24
|
+
"patch": "./cordis.patch.yml"
|
|
25
|
+
},
|
|
26
|
+
"client": {
|
|
27
|
+
"inject": [
|
|
28
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
29
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
30
|
+
],
|
|
31
|
+
"platform": "web"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": "^18.2.0"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"deepseek",
|
|
39
|
+
"dsh",
|
|
40
|
+
"deepseek-harness",
|
|
41
|
+
"plugin",
|
|
42
|
+
"balance",
|
|
43
|
+
"cost",
|
|
44
|
+
"token",
|
|
45
|
+
"web-ui"
|
|
46
|
+
],
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/LL-cmyk-so/dsh-balance-widget.git"
|
|
51
|
+
},
|
|
52
|
+
"author": "YOUR_NAME <YOUR_EMAIL>"
|
|
53
|
+
}
|