@yyfather/dsh-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/LICENSE +21 -0
- package/README.md +55 -0
- package/README_EN.md +55 -0
- package/client.js +245 -0
- package/cordis.patch.yml +3 -0
- package/index.js +341 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 YYfather
|
|
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
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# dsh-balance
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness 余额与开销插件。在对话底部状态栏(composer dock)显示 DeepSeek / MiMo 账户余额与话费开销,支持**逐请求计价**、**高峰/空闲分时段**、**多模型价目**与**花费超线提醒**(点击状态栏即可设置)。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- **余额**:实时查询 DeepSeek 账户余额(仅本机可访问,密钥只存在于 Host 侧;当前会话为 MiMo 等外部模型时不显示 DeepSeek 余额)
|
|
8
|
+
- **开销(逐请求精确计价)**:按每个请求时间戳选高峰/空闲档价、按该请求的模型选价、同一步骤的 usage 样本按官方投影替换规则去重
|
|
9
|
+
- `本会话` = 当前会话从创建至今的全部花费
|
|
10
|
+
- `本次活跃` = 自本次插件启用以来新产生的花费
|
|
11
|
+
- `最近一次` = 当前会话最新一步请求的花费
|
|
12
|
+
- `上次对话` = 当前会话之前的上一会话花费(MiMo 会话按小米美元价 × 汇率折算)
|
|
13
|
+
- **多模型价目**:内置 DeepSeek V4 Flash / V4 Flash Vision Exp / V4 Pro(官方价,空闲/高峰双档)与小米 MiMo V2.5 系(美元价),可在点击面板中按模型+时段编辑,美元模型按可配置汇率换算
|
|
14
|
+
- **提醒**:本次活跃花费 ≥ 提醒线(默认 ¥1)时状态栏变橙色徽标;DeepSeek 余额 < 提醒线(默认 ¥10)同样提醒;点击面板可改两线
|
|
15
|
+
- **刷新**:每次对话结束后自动刷新 + 每 5 分钟自动刷新(可在面板中开关)
|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
1. 克隆仓库到 DSH 的 profile 依赖目录(`%USERPROFILE%\.dsh\profiles\node_modules\@yyfather\dsh-balance` 或你的 `$DSH_HOME/profiles/<name>/node_modules/@yyfather/dsh-balance`):
|
|
20
|
+
```sh
|
|
21
|
+
git clone https://github.com/YYfather/dsh-balance.git "$USERPROFILE\.dsh\profiles\node_modules\@yyfather\dsh-balance"
|
|
22
|
+
```
|
|
23
|
+
2. 在 profile 的 `cordis.patch.yml` 末尾追加(该文件是用户 patch 层,重装/升级不会覆盖;`%USERPROFILE%\.dsh\profiles\desktop\cordis.patch.yml`):
|
|
24
|
+
```yaml
|
|
25
|
+
- insert:
|
|
26
|
+
- id: dsh-balance
|
|
27
|
+
name: '@yyfather/dsh-balance'
|
|
28
|
+
```
|
|
29
|
+
3. 重启 DSH Desktop。状态栏底部即出现余额/开销一行;点击可打开设置面板。
|
|
30
|
+
|
|
31
|
+
### 依赖
|
|
32
|
+
|
|
33
|
+
- Host 侧:`webServer`、`credentials`(DeepSeek 密钥在 设置→模型 保存,默认引用 `DEEPSEEK_API_KEY`)、`sessions` / `sessionQuery`(会话与历史投影)、`timer`
|
|
34
|
+
- Client 侧:`slots`(`conversation.composer.dock`)、React
|
|
35
|
+
- Node ≥ 20(Host 使用原生 `fetch`)
|
|
36
|
+
|
|
37
|
+
## 配置(Host `cordis.patch.yml` 行内可覆盖)
|
|
38
|
+
|
|
39
|
+
| 字段 | 默认 | 说明 |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `apiKeyRef` | `DEEPSEEK_API_KEY` | 余额查询所用凭据引用 |
|
|
42
|
+
| `baseUrl` | `https://api.deepseek.com` | DeepSeek API 根地址 |
|
|
43
|
+
| `timeoutMs` | `20000` | 上游查询超时 |
|
|
44
|
+
| `allowRemote` | `false` | 不允许非本机访问插件路由(安全默认,勿随意外开) |
|
|
45
|
+
|
|
46
|
+
运行时价格/提醒线在点击面板中修改(进程内生效)。
|
|
47
|
+
|
|
48
|
+
## 安全
|
|
49
|
+
|
|
50
|
+
- 密钥仅在 Host 侧解析,浏览器端只访问 `dsh-balance/api/*` 本机同源路由(默认拒绝本机以外的来源)
|
|
51
|
+
- 路由响应不含任何上游原文、密钥或未经校验的字段
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
package/README_EN.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# dsh-balance
|
|
2
|
+
|
|
3
|
+
A DeepSeek Harness plugin that shows DeepSeek / MiMo account balance and per-request conversation cost in the composer dock, with model-aware peak/off-peak pricing and spend alerts (click the dock readout to configure).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Balance**: live DeepSeek account balance (loopback-only routes; the credential never leaves the Host; hidden when the current session uses an external provider such as Xiaomi MiMo)
|
|
8
|
+
- **Per-request cost**: each usage sample is priced by its own request timestamp (peak/off-peak) and its own model, with the same `(turn, step)` replacement rule as the official token-usage projection
|
|
9
|
+
- `本会话 / this conversation` = full cost of the current session since its creation
|
|
10
|
+
- `本次活跃 / active period` = cost of requests since this plugin instance started
|
|
11
|
+
- `最近一次 / last request` = cost of the most recent request
|
|
12
|
+
- `上次对话 / previous conversation` = cost of the immediately preceding session (MiMo sessions converted via a configurable USD rate)
|
|
13
|
+
- **Model price table**: DeepSeek V4 Flash / V4 Flash Vision Exp / V4 Pro (official CNY, off-peak/peak tiers) and Xiaomi MiMo V2.5 family (USD, editable), editable per model + tier in the click panel; USD entries converted at a configurable rate
|
|
14
|
+
- **Alerts**: orange pill badge when active-period spend ≥ spend line (default ¥1) or DeepSeek balance < balance line (default ¥10); both editable in the panel
|
|
15
|
+
- **Refresh**: after each conversation turn and every 5 minutes (toggleable)
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
1. Clone into the profile dependency folder (`$DSH_HOME/profiles/<name>/node_modules/@yyfather/dsh-balance`):
|
|
20
|
+
```sh
|
|
21
|
+
git clone https://github.com/YYfather/dsh-balance.git "$HOME/.dsh/profiles/node_modules/@yyfather/dsh-balance"
|
|
22
|
+
```
|
|
23
|
+
2. Append to the profile's `cordis.patch.yml` (the user patch layer; survives reinstalls):
|
|
24
|
+
```yaml
|
|
25
|
+
- insert:
|
|
26
|
+
- id: dsh-balance
|
|
27
|
+
name: '@yyfather/dsh-balance'
|
|
28
|
+
```
|
|
29
|
+
3. Restart DSH Desktop. The dock readout appears; click it to open the settings panel.
|
|
30
|
+
|
|
31
|
+
### Requirements
|
|
32
|
+
|
|
33
|
+
- Host: `webServer`, `credentials` (store the DeepSeek key in Settings → Models, default ref `DEEPSEEK_API_KEY`), `sessions` / `sessionQuery`, `timer`
|
|
34
|
+
- Client: `slots` (`conversation.composer.dock`), React
|
|
35
|
+
- Node ≥ 20 (native fetch)
|
|
36
|
+
|
|
37
|
+
## Host config (overridable in the plugin row)
|
|
38
|
+
|
|
39
|
+
| Field | Default | Notes |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `apiKeyRef` | `DEEPSEEK_API_KEY` | credential reference for balance queries |
|
|
42
|
+
| `baseUrl` | `https://api.deepseek.com` | DeepSeek API root |
|
|
43
|
+
| `timeoutMs` | `20000` | upstream timeout |
|
|
44
|
+
| `allowRemote` | `false` | keep false; routes are loopback-only by design |
|
|
45
|
+
|
|
46
|
+
Run-time pricing and alert lines are edited in the click panel (in-memory).
|
|
47
|
+
|
|
48
|
+
## Security
|
|
49
|
+
|
|
50
|
+
- Credentials resolve only on the Host; the browser talks exclusively to the loopback-only `dsh-balance/api/*` same-origin routes
|
|
51
|
+
- Responses never include upstream bodies, credentials, or unvalidated fields
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
package/client.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-balance — Client half (browser).
|
|
3
|
+
*
|
|
4
|
+
* Registers the ambient composition dock readout. All data comes from the
|
|
5
|
+
* Host's loopback-only same-origin routes; no credential ever reaches the page.
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react'
|
|
8
|
+
|
|
9
|
+
export const inject = ['slots']
|
|
10
|
+
|
|
11
|
+
const STATE_ROUTE = '/dsh-balance/api/state'
|
|
12
|
+
const CONFIG_ROUTE = '/dsh-balance/api/config'
|
|
13
|
+
|
|
14
|
+
const CSS = [
|
|
15
|
+
'.dsh-balance-dock{position:relative;display:inline-flex}',
|
|
16
|
+
'.dsh-balance-dock-line{font-size:12px;opacity:.85;white-space:nowrap}',
|
|
17
|
+
'.dsh-balance-dock-line.warn{opacity:1;color:#f59e0b;font-weight:500;background:rgba(245,158,11,.13);border:1px solid rgba(245,158,11,.35);border-radius:999px;padding:1px 8px}',
|
|
18
|
+
'.dsh-balance-pop{position:absolute;bottom:calc(100% + 10px);left:0;z-index:60;background:var(--dsh-panel-bg,#1f1f1f);color:var(--dsh-text,#e8e8e8);border:1px solid rgba(128,128,128,.35);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:8px;font-size:12px;white-space:nowrap;min-width:280px;box-shadow:0 8px 28px rgba(0,0,0,.4)}',
|
|
19
|
+
'.dsh-balance-pop .title-row{display:flex;justify-content:space-between;align-items:center;font-weight:600;font-size:12.5px;opacity:.95}',
|
|
20
|
+
'.dsh-balance-pop .xbtn{border:none;background:none;color:inherit;opacity:.6;cursor:pointer;font-size:12px;padding:0}',
|
|
21
|
+
'.dsh-balance-pop label{display:flex;gap:8px;align-items:center;justify-content:space-between}',
|
|
22
|
+
'.dsh-balance-pop .field-name{opacity:.8}',
|
|
23
|
+
'.dsh-balance-pop input[type=number]{width:72px;font-size:12px;background:rgba(128,128,128,.08);color:inherit;border:1px solid rgba(128,128,128,.4);border-radius:6px;padding:3px 8px}',
|
|
24
|
+
'.dsh-balance-pop input[type=checkbox]{accent-color:#f59e0b}',
|
|
25
|
+
'.dsh-balance-pop select{font-size:12px;max-width:200px;background:rgba(128,128,128,.08);color:inherit;border:1px solid rgba(128,128,128,.4);border-radius:6px;padding:3px 6px}',
|
|
26
|
+
'.dsh-balance-bar{width:100%;height:5px;background:rgba(128,128,128,.22);border-radius:999px;overflow:hidden}',
|
|
27
|
+
'.dsh-balance-bar>div{height:100%;background:#f59e0b;border-radius:999px;transition:width .3s}',
|
|
28
|
+
'.dsh-balance-pop .actions{display:flex;gap:8px;align-items:center}',
|
|
29
|
+
'.dsh-balance-pop .actions button{font-size:12px;border-radius:6px;padding:3px 12px;cursor:pointer;border:1px solid rgba(128,128,128,.4);background:rgba(128,128,128,.1);color:inherit}',
|
|
30
|
+
'.dsh-balance-pop .actions button.primary{background:#f59e0b;color:#1a1a1a;font-weight:600;border:none}',
|
|
31
|
+
'.dsh-balance-pop .muted{opacity:.65}',
|
|
32
|
+
'.dsh-balance-pop .section{border-top:1px solid rgba(128,128,128,.25);padding-top:8px}',
|
|
33
|
+
].join('')
|
|
34
|
+
|
|
35
|
+
const symbolOf = (currency) => currency === 'CNY' ? '¥' : currency === 'USD' ? '$' : (currency || '') + ' '
|
|
36
|
+
const round2 = (n) => Math.round(n * 100) / 100
|
|
37
|
+
|
|
38
|
+
async function loadState(sessionId, force, signal) {
|
|
39
|
+
const params = new URLSearchParams()
|
|
40
|
+
if (sessionId) params.set('session', sessionId)
|
|
41
|
+
if (force) params.set('refresh', '1')
|
|
42
|
+
const qs = params.toString()
|
|
43
|
+
const resp = await fetch(STATE_ROUTE + (qs ? '?' + qs : ''), {
|
|
44
|
+
headers: { accept: 'application/json' },
|
|
45
|
+
cache: 'no-store',
|
|
46
|
+
...signal ? { signal } : {},
|
|
47
|
+
})
|
|
48
|
+
const value = await resp.json()
|
|
49
|
+
if (value === null || typeof value !== 'object' || value.ok !== true) {
|
|
50
|
+
throw new Error((value && value.message) || 'Host 响应异常')
|
|
51
|
+
}
|
|
52
|
+
return value
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function saveConfig(patch) {
|
|
56
|
+
const resp = await fetch(CONFIG_ROUTE, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
59
|
+
body: JSON.stringify(patch),
|
|
60
|
+
})
|
|
61
|
+
const value = await resp.json()
|
|
62
|
+
if (value === null || typeof value !== 'object' || value.ok !== true) {
|
|
63
|
+
throw new Error((value && value.message) || 'Host 响应异常')
|
|
64
|
+
}
|
|
65
|
+
return value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Click-to-set panel under the dock readout: alert lines + per-model pricing. */
|
|
69
|
+
function BalancePanel(props) {
|
|
70
|
+
const data = props.data
|
|
71
|
+
const st = data.state || {}
|
|
72
|
+
const cfg = data.config || {}
|
|
73
|
+
const [spendThreshold, setSpendThreshold] = React.useState(String(cfg.spendThreshold ?? 1))
|
|
74
|
+
const [spendAlert, setSpendAlert] = React.useState(cfg.spendAlert !== false)
|
|
75
|
+
const [threshold, setThreshold] = React.useState(String(cfg.threshold ?? 10))
|
|
76
|
+
const [priceKey, setPriceKey] = React.useState('default')
|
|
77
|
+
const [tierEdit, setTierEdit] = React.useState('off')
|
|
78
|
+
const [priceIn, setPriceIn] = React.useState('1.5')
|
|
79
|
+
const [priceCache, setPriceCache] = React.useState('0.05')
|
|
80
|
+
const [priceOut, setPriceOut] = React.useState('4.5')
|
|
81
|
+
const [usdRate, setUsdRate] = React.useState(String(cfg.usdRate ?? 7.2))
|
|
82
|
+
const active = st.cost || 0
|
|
83
|
+
const thr = Math.max(0, Number(spendThreshold) || 1)
|
|
84
|
+
const pct = thr > 0 ? Math.min(100, Math.round(active / thr * 100)) : 0
|
|
85
|
+
const over = thr > 0 && active >= thr
|
|
86
|
+
const keys = cfg.prices ? Object.keys(cfg.prices) : ['default']
|
|
87
|
+
const labelOf = (k) => {
|
|
88
|
+
const p = (cfg.prices && (cfg.prices[k] || cfg.prices.default)) || {}
|
|
89
|
+
return k === 'default' ? '默认' : k + (p.currency === 'USD' ? ' ($)' : '')
|
|
90
|
+
}
|
|
91
|
+
const syncPrice = (key, tier) => {
|
|
92
|
+
const p = (cfg.prices && (cfg.prices[key] || cfg.prices.default)) || {}
|
|
93
|
+
const t = tier === 'peak' ? { in: p.peakIn, cache: p.peakCache, out: p.peakOut } : { in: p.in, cache: p.cache, out: p.out }
|
|
94
|
+
setPriceIn(String(t.in ?? 1.5)); setPriceCache(String(t.cache ?? 0.05)); setPriceOut(String(t.out ?? 4.5))
|
|
95
|
+
}
|
|
96
|
+
const num = (v, d) => { const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : d }
|
|
97
|
+
const save = () => {
|
|
98
|
+
const rt = Number(usdRate)
|
|
99
|
+
saveConfig({
|
|
100
|
+
threshold: num(threshold, 10),
|
|
101
|
+
spendAlert,
|
|
102
|
+
spendThreshold: num(spendThreshold, 1),
|
|
103
|
+
usdRate: Number.isFinite(rt) && rt > 0 ? rt : 7.2,
|
|
104
|
+
priceKey: priceKey || 'default',
|
|
105
|
+
tier: tierEdit === 'peak' ? 'peak' : 'off',
|
|
106
|
+
price: { in: num(priceIn, 1.5), cache: num(priceCache, 0.05), out: num(priceOut, 4.5) },
|
|
107
|
+
}).then((v) => { if (props.onChanged) props.onChanged(v) }).catch(() => {})
|
|
108
|
+
}
|
|
109
|
+
const refresh = () => {
|
|
110
|
+
loadState(props.sessionId, true).then((v) => { if (props.onChanged) props.onChanged(v) }).catch(() => {})
|
|
111
|
+
}
|
|
112
|
+
return React.createElement('div', { className: 'dsh-balance-pop' },
|
|
113
|
+
React.createElement('div', { className: 'title-row' },
|
|
114
|
+
React.createElement('span', null, '余额开销设置'),
|
|
115
|
+
React.createElement('button', { className: 'xbtn', onClick: props.onClose }, '✕')
|
|
116
|
+
),
|
|
117
|
+
React.createElement('label', null,
|
|
118
|
+
React.createElement('span', { className: 'field-name' }, '花费提醒线 ¥'),
|
|
119
|
+
React.createElement('input', { type: 'number', min: 0, step: 0.1, value: spendThreshold, onChange: (e) => setSpendThreshold(e.target.value) })
|
|
120
|
+
),
|
|
121
|
+
React.createElement('label', null,
|
|
122
|
+
React.createElement('span', { className: 'field-name' }, '启用花费提醒(按本次活跃)'),
|
|
123
|
+
React.createElement('input', { type: 'checkbox', checked: spendAlert, onChange: (e) => setSpendAlert(e.target.checked) })
|
|
124
|
+
),
|
|
125
|
+
React.createElement('div', { className: 'dsh-balance-bar' }, React.createElement('div', { style: { width: pct + '%' } })),
|
|
126
|
+
React.createElement('span', { className: 'muted' }, '本次活跃 ' + symbolOf(st.currency || 'CNY') + String(active) + ' / ' + symbolOf(st.currency || 'CNY') + String(thr) + (over ? ' ⚠ 已超线' : '')),
|
|
127
|
+
React.createElement('label', null,
|
|
128
|
+
React.createElement('span', { className: 'field-name' }, '余额提醒线 ¥'),
|
|
129
|
+
React.createElement('input', { type: 'number', min: 0, step: 1, value: threshold, onChange: (e) => setThreshold(e.target.value) })
|
|
130
|
+
),
|
|
131
|
+
React.createElement('div', { className: 'section' },
|
|
132
|
+
React.createElement('label', null,
|
|
133
|
+
React.createElement('span', { className: 'field-name' }, '模型价目'),
|
|
134
|
+
React.createElement('select', { value: priceKey, onChange: (e) => { setPriceKey(e.target.value); syncPrice(e.target.value, tierEdit) } }, keys.map((k) => React.createElement('option', { value: k, key: k }, labelOf(k))))
|
|
135
|
+
),
|
|
136
|
+
React.createElement('label', null,
|
|
137
|
+
React.createElement('span', { className: 'field-name' }, '时段'),
|
|
138
|
+
React.createElement('select', { value: tierEdit, onChange: (e) => { setTierEdit(e.target.value); syncPrice(priceKey, e.target.value) } },
|
|
139
|
+
React.createElement('option', { value: 'off' }, '空闲'), React.createElement('option', { value: 'peak' }, '高峰'))
|
|
140
|
+
),
|
|
141
|
+
React.createElement('label', null,
|
|
142
|
+
React.createElement('span', { className: 'field-name' }, '输入/缓存/输出 ¥每百万'),
|
|
143
|
+
React.createElement('span', { style: { display: 'inline-flex', gap: 4 } },
|
|
144
|
+
React.createElement('input', { type: 'number', min: 0, step: 0.01, value: priceIn, onChange: (e) => setPriceIn(e.target.value) }),
|
|
145
|
+
React.createElement('input', { type: 'number', min: 0, step: 0.01, value: priceCache, onChange: (e) => setPriceCache(e.target.value) }),
|
|
146
|
+
React.createElement('input', { type: 'number', min: 0, step: 0.01, value: priceOut, onChange: (e) => setPriceOut(e.target.value) })
|
|
147
|
+
)
|
|
148
|
+
),
|
|
149
|
+
React.createElement('label', null,
|
|
150
|
+
React.createElement('span', { className: 'field-name' }, '美元汇率 ¥/$'),
|
|
151
|
+
React.createElement('input', { type: 'number', min: 0, step: 0.1, value: usdRate, onChange: (e) => setUsdRate(e.target.value) })
|
|
152
|
+
)
|
|
153
|
+
),
|
|
154
|
+
React.createElement('div', { className: 'actions' },
|
|
155
|
+
React.createElement('button', { className: 'primary', onClick: save }, '保存'),
|
|
156
|
+
React.createElement('button', { onClick: refresh }, '立即刷新')
|
|
157
|
+
),
|
|
158
|
+
React.createElement('span', { className: 'muted' }, '本次活跃 = 自本次插件启用以来的新花费;本会话 = 当前会话全部花费')
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function BalanceDock(props) {
|
|
163
|
+
const sessionId = props && props.sessionId
|
|
164
|
+
const [data, setData] = React.useState(null)
|
|
165
|
+
const [open, setOpen] = React.useState(false)
|
|
166
|
+
React.useEffect(() => {
|
|
167
|
+
let alive = true
|
|
168
|
+
const load = (force) => {
|
|
169
|
+
loadState(sessionId, force).then((v) => { if (alive) setData(v) }).catch(() => { if (alive) setData({ state: { status: 'error', error: '连接失败' } }) })
|
|
170
|
+
}
|
|
171
|
+
load(false)
|
|
172
|
+
const timer = window.setInterval(() => load(false), 5000)
|
|
173
|
+
return () => { alive = false; window.clearInterval(timer) }
|
|
174
|
+
}, [sessionId])
|
|
175
|
+
if (data === null) return null
|
|
176
|
+
const st = data.state || {}
|
|
177
|
+
const cfg = data.config || {}
|
|
178
|
+
const below = st.status === 'ok' && !st.external && typeof st.balance === 'number' && st.balance < (cfg.threshold ?? 10)
|
|
179
|
+
const costWarn = st.status === 'ok' && st.spendAlert === true
|
|
180
|
+
const warn = below || costWarn
|
|
181
|
+
let text
|
|
182
|
+
if (st.status === 'loading') text = '余额 …'
|
|
183
|
+
else if (st.status === 'unconfigured') text = '余额未配置'
|
|
184
|
+
else if (st.status === 'error') text = '余额获取失败'
|
|
185
|
+
else if (warn) {
|
|
186
|
+
const pieces = []
|
|
187
|
+
if (costWarn) pieces.push('本次活跃 ' + symbolOf(st.currency) + String(st.cost || 0) + '/' + symbolOf(st.currency) + String(cfg.spendThreshold ?? 1))
|
|
188
|
+
if (below) pieces.push('余额 ' + symbolOf(st.currency) + String(st.balance) + '/' + symbolOf(st.currency) + String(cfg.threshold ?? 10))
|
|
189
|
+
text = '⚠ ' + pieces.join(' · ')
|
|
190
|
+
} else {
|
|
191
|
+
const total = st.costTotal || 0
|
|
192
|
+
const active = st.cost || 0
|
|
193
|
+
const last = st.lastCost !== null && st.lastCost !== undefined ? st.lastCost : null
|
|
194
|
+
const prev = st.prevCost !== null && st.prevCost !== undefined ? st.prevCost : null
|
|
195
|
+
text = ''
|
|
196
|
+
if (!st.external) text += '余额 ' + symbolOf(st.currency) + String(st.balance) + ' · '
|
|
197
|
+
text += '本会话 ' + symbolOf(st.currency) + String(total)
|
|
198
|
+
text += ' · 本次活跃 ' + symbolOf(st.currency) + String(active)
|
|
199
|
+
if (last !== null) text += ' · 最近一次 ' + symbolOf(st.currency) + String(last)
|
|
200
|
+
if (prev !== null) text += ' · 上次对话 ' + symbolOf(st.currency) + String(prev)
|
|
201
|
+
}
|
|
202
|
+
let title = ''
|
|
203
|
+
if (st.status === 'ok') {
|
|
204
|
+
const total = st.costTotal || 0
|
|
205
|
+
const active = st.cost || 0
|
|
206
|
+
const last = st.lastCost !== null && st.lastCost !== undefined ? st.lastCost : null
|
|
207
|
+
const prev = st.prevCost !== null && st.prevCost !== undefined ? st.prevCost : null
|
|
208
|
+
title = st.external ? '当前会话为小米 MiMo 计费(不适用 DeepSeek 余额)' : 'DeepSeek 账户余额 ' + (st.currency || '') + ' · 更新于 ' + new Date(st.updatedAt || Date.now()).toLocaleTimeString()
|
|
209
|
+
if (st.tokens) title += ' · 本会话token: 输入 ' + st.tokens.input + ' · 缓存读 ' + st.tokens.cacheRead + ' · 输出 ' + st.tokens.output
|
|
210
|
+
title += ' · 本会话(' + (st.model || '默认') + ') ' + symbolOf(st.currency) + total + ' · 本次活跃 ' + symbolOf(st.currency) + active + ' (' + (st.samples || 0) + '次新请求)'
|
|
211
|
+
if (last !== null) title += ' · 最近一次 ' + symbolOf(st.currency) + last
|
|
212
|
+
if (prev !== null) title += ' · 上次对话(' + (st.prevModel || '默认') + ') ' + symbolOf(st.currency) + prev
|
|
213
|
+
if (costWarn) title += ' · ⚠ 本次活跃 ≥ ' + symbolOf(st.currency) + String(cfg.spendThreshold ?? 1)
|
|
214
|
+
if (below) title += ' · ⚠ 余额 < ' + symbolOf(st.currency) + String(cfg.threshold ?? 10)
|
|
215
|
+
} else if (st.status === 'unconfigured') {
|
|
216
|
+
title = '未找到 DEEPSEEK_API_KEY,请在 设置 → 模型 中配置'
|
|
217
|
+
} else if (st.status === 'error') {
|
|
218
|
+
title = st.error || '查询失败'
|
|
219
|
+
}
|
|
220
|
+
return React.createElement('div', { className: 'dsh-balance-dock', title },
|
|
221
|
+
React.createElement('span', {
|
|
222
|
+
className: 'dsh-balance-dock-line' + (warn ? ' warn' : ''),
|
|
223
|
+
onClick: () => setOpen(!open),
|
|
224
|
+
style: { cursor: 'pointer' },
|
|
225
|
+
}, text),
|
|
226
|
+
open ? React.createElement(BalancePanel, { data, sessionId, onChanged: setData, onClose: () => setOpen(false) }) : null
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function apply(ctx) {
|
|
231
|
+
ctx.effect(() => {
|
|
232
|
+
const style = document.createElement('style')
|
|
233
|
+
style.dataset.plugin = 'dsh-balance'
|
|
234
|
+
style.textContent = CSS
|
|
235
|
+
document.head.append(style)
|
|
236
|
+
return () => { style.remove() }
|
|
237
|
+
}, 'dsh-balance: styles')
|
|
238
|
+
|
|
239
|
+
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
240
|
+
name: 'conversation.composer.dock',
|
|
241
|
+
id: 'dsh-balance',
|
|
242
|
+
order: 10,
|
|
243
|
+
inject: () => ({ loadState, saveConfig }),
|
|
244
|
+
}, BalanceDock))
|
|
245
|
+
}
|
package/cordis.patch.yml
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-balance — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Standard DSH plugin (namespace export: name / inject / Config / apply).
|
|
5
|
+
* The Host owns every credential and upstream call; the browser client only
|
|
6
|
+
* talks to two loopback-only same-origin routes exposed via `ctx.webServer`.
|
|
7
|
+
*/
|
|
8
|
+
import z from '@deepseek-ai/schemastery'
|
|
9
|
+
|
|
10
|
+
export const name = 'dsh-balance'
|
|
11
|
+
export const inject = ['webServer', 'credentials', 'timer']
|
|
12
|
+
|
|
13
|
+
export const Config = z.object({
|
|
14
|
+
apiKeyRef: z.string().role('credential-ref').default('DEEPSEEK_API_KEY'),
|
|
15
|
+
baseUrl: z.string().default('https://api.deepseek.com'),
|
|
16
|
+
timeoutMs: z.number().step(1).min(1000).max(60000).default(20000),
|
|
17
|
+
allowRemote: z.boolean().default(false),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const STATE_ROUTE = '/dsh-balance/api/state'
|
|
21
|
+
const CONFIG_ROUTE = '/dsh-balance/api/config'
|
|
22
|
+
|
|
23
|
+
const BASE_PRICES = {
|
|
24
|
+
default: { currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 },
|
|
25
|
+
'deepseek-v4-flash': { currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 },
|
|
26
|
+
'deepseek-v4-flash-vision-exp': { currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 },
|
|
27
|
+
'deepseek-v4-pro': { currency: 'CNY', in: 4.5, cache: 0.15, out: 13.5, peakIn: 9.0, peakCache: 0.30, peakOut: 27.0 },
|
|
28
|
+
'mimo-v2.5': { currency: 'USD', in: 0.10, cache: 0.02, out: 0.40, peakIn: 0.10, peakCache: 0.02, peakOut: 0.40 },
|
|
29
|
+
'mimo-v2.5-pro': { currency: 'USD', in: 1.00, cache: 0.20, out: 3.00, peakIn: 1.00, peakCache: 0.20, peakOut: 3.00 },
|
|
30
|
+
'mimo-v2.5-pro-ultraspeed': { currency: 'USD', in: 1.00, cache: 0.20, out: 3.00, peakIn: 1.00, peakCache: 0.20, peakOut: 3.00 },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isLoopbackRequest(req) {
|
|
34
|
+
const host = req.headers.host
|
|
35
|
+
if (host === undefined) return false
|
|
36
|
+
try {
|
|
37
|
+
const hostname = new URL(`http://${host}`).hostname
|
|
38
|
+
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'
|
|
39
|
+
} catch {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sendJson(res, status, body) {
|
|
45
|
+
res.writeHead(status, {
|
|
46
|
+
'cache-control': 'no-store',
|
|
47
|
+
'content-type': 'application/json; charset=utf-8',
|
|
48
|
+
'x-content-type-options': 'nosniff',
|
|
49
|
+
})
|
|
50
|
+
res.end(JSON.stringify(body))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readJsonBody(req, maxBytes = 65536) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
let size = 0
|
|
56
|
+
const chunks = []
|
|
57
|
+
req.on('data', (chunk) => {
|
|
58
|
+
size += chunk.length
|
|
59
|
+
if (size > maxBytes) {
|
|
60
|
+
reject(new Error('body too large'))
|
|
61
|
+
req.destroy()
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
chunks.push(chunk)
|
|
65
|
+
})
|
|
66
|
+
req.on('end', () => {
|
|
67
|
+
if (chunks.length === 0) { resolve({}); return }
|
|
68
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch (error) { reject(error) }
|
|
69
|
+
})
|
|
70
|
+
req.on('error', reject)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const round2 = (n) => Math.round(n * 100) / 100
|
|
75
|
+
|
|
76
|
+
function tierOfTime(timeMs) {
|
|
77
|
+
const b = new Date(timeMs + 8 * 3600e3)
|
|
78
|
+
const day = b.getUTCDay()
|
|
79
|
+
const h = b.getUTCHours()
|
|
80
|
+
return day >= 1 && day <= 5 && ((h >= 9 && h < 12) || (h >= 14 && h < 18)) ? 'peak' : 'off'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isExternal = (provider) => provider !== null && provider !== 'deepseek-official'
|
|
84
|
+
|
|
85
|
+
/** Fold a session event stream into per-request cost buckets (model + tier per request time). */
|
|
86
|
+
function foldCost(events, since) {
|
|
87
|
+
const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
|
|
88
|
+
let cost = 0
|
|
89
|
+
let costActive = 0
|
|
90
|
+
let external = false
|
|
91
|
+
let lastModel = null
|
|
92
|
+
let lastProvider = null
|
|
93
|
+
let lastSample = null
|
|
94
|
+
let samples = 0
|
|
95
|
+
let samplesActive = 0
|
|
96
|
+
let lastCost = 0
|
|
97
|
+
let lastTime = 0
|
|
98
|
+
let lastTokens = null
|
|
99
|
+
if (Array.isArray(events)) {
|
|
100
|
+
for (const e of events) {
|
|
101
|
+
if (e === null || typeof e !== 'object' || e.data === null || typeof e.data !== 'object') continue
|
|
102
|
+
if (e.type === 'request/context') {
|
|
103
|
+
if (e.data.model) lastModel = String(e.data.model)
|
|
104
|
+
if (e.data.provider) lastProvider = String(e.data.provider)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
let turn; let step; let usage
|
|
108
|
+
if (e.type === 'assistant/chunk' && e.data.chunk !== null && typeof e.data.chunk === 'object' && e.data.chunk.type === 'usage') {
|
|
109
|
+
turn = e.data.turn; step = e.data.step; usage = e.data.chunk.usage
|
|
110
|
+
} else if (e.type === 'assistant/message' && e.data.usage !== undefined) {
|
|
111
|
+
turn = e.data.turn; step = e.data.step; usage = e.data.usage
|
|
112
|
+
} else continue
|
|
113
|
+
if (usage === null || typeof usage !== 'object') continue
|
|
114
|
+
const b = { input: usage.inputTokens || 0, cacheRead: usage.cacheReadTokens || 0, cacheWrite: usage.cacheWriteTokens || 0, output: usage.outputTokens || 0 }
|
|
115
|
+
const ext = isExternal(lastProvider)
|
|
116
|
+
const tms = typeof e.time === 'number' ? e.time : Date.now()
|
|
117
|
+
const inActive = since === undefined || tms >= since
|
|
118
|
+
const sc = costOfAt(b, lastModel, tms, configRuntime)
|
|
119
|
+
const scA = inActive ? sc : 0
|
|
120
|
+
if (lastSample !== null && lastSample.turn === turn && lastSample.step === step) {
|
|
121
|
+
totals.input = Math.max(0, totals.input - lastSample.buckets.input + b.input)
|
|
122
|
+
totals.cacheRead = Math.max(0, totals.cacheRead - lastSample.buckets.cacheRead + b.cacheRead)
|
|
123
|
+
totals.cacheWrite = Math.max(0, totals.cacheWrite - lastSample.buckets.cacheWrite + b.cacheWrite)
|
|
124
|
+
totals.output = Math.max(0, totals.output - lastSample.buckets.output + b.output)
|
|
125
|
+
cost = Math.max(0, round2(cost - lastSample.cost + sc))
|
|
126
|
+
costActive = Math.max(0, round2(costActive - lastSample.costActive + scA))
|
|
127
|
+
lastSample = { turn, step, buckets: b, cost: sc, costActive: scA }
|
|
128
|
+
} else {
|
|
129
|
+
totals.input += b.input; totals.cacheRead += b.cacheRead; totals.cacheWrite += b.cacheWrite; totals.output += b.output
|
|
130
|
+
cost = round2(cost + sc)
|
|
131
|
+
costActive = round2(costActive + scA)
|
|
132
|
+
lastSample = { turn, step, buckets: b, cost: sc, costActive: scA }
|
|
133
|
+
samples += 1
|
|
134
|
+
if (inActive) samplesActive += 1
|
|
135
|
+
}
|
|
136
|
+
lastCost = sc
|
|
137
|
+
lastTime = tms
|
|
138
|
+
lastTokens = b
|
|
139
|
+
if (ext) external = true
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { totals, cost, costActive, external, model: lastModel, samples, samplesActive, lastCost, lastTime, lastTokens }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Runtime-adjustable pricing (edited from the click panel); priceOf/costOfAt read it. */
|
|
146
|
+
let configRuntime = {
|
|
147
|
+
threshold: 10,
|
|
148
|
+
spendAlert: true,
|
|
149
|
+
spendThreshold: 1,
|
|
150
|
+
usdRate: 7.2,
|
|
151
|
+
afterTurn: true,
|
|
152
|
+
every5min: true,
|
|
153
|
+
prices: structuredClone(BASE_PRICES),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function priceOf(model, rt) { return rt.prices[model] || rt.prices.default }
|
|
157
|
+
function costOfAt(t, model, timeMs, rt) {
|
|
158
|
+
const p = priceOf(model, rt)
|
|
159
|
+
const peak = tierOfTime(timeMs) === 'peak'
|
|
160
|
+
const rate = p.currency === 'USD' ? rt.usdRate : 1
|
|
161
|
+
const inP = (peak ? p.peakIn : p.in) * rate
|
|
162
|
+
const cacheP = (peak ? p.peakCache : p.cache) * rate
|
|
163
|
+
const outP = (peak ? p.peakOut : p.out) * rate
|
|
164
|
+
return round2((t.input * inP + t.cacheRead * cacheP + t.cacheWrite * inP + t.output * outP) / 1e6)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function apply(ctx, config) {
|
|
168
|
+
const activeSince = Date.now()
|
|
169
|
+
let refreshing = false
|
|
170
|
+
let sessionId = undefined
|
|
171
|
+
let measuredFor = undefined
|
|
172
|
+
let lastBalance = null
|
|
173
|
+
const state = {
|
|
174
|
+
status: 'loading', balance: null, currency: 'CNY', updatedAt: 0, delta: null,
|
|
175
|
+
cost: 0, costTotal: 0, tokens: null, model: null, samples: 0, samplesTotal: 0,
|
|
176
|
+
external: false, spendAlert: false, lastCost: null, lastTime: 0, lastTokens: null,
|
|
177
|
+
prevCost: null, prevModel: null, prevSamples: 0, prevExternal: false,
|
|
178
|
+
error: '',
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const patch = (p) => Object.assign(state, p)
|
|
182
|
+
|
|
183
|
+
const snapshot = () => ({ state: { ...state }, config: structuredClone(configRuntime) })
|
|
184
|
+
|
|
185
|
+
const applyTokens = (sid) => {
|
|
186
|
+
if (!sid) return
|
|
187
|
+
const sessions = ctx.get('sessions')
|
|
188
|
+
if (sessions === undefined) return
|
|
189
|
+
const session = sessions.get(sid)
|
|
190
|
+
if (session === undefined) return
|
|
191
|
+
measuredFor = sid
|
|
192
|
+
const f = foldCost(session.events, activeSince)
|
|
193
|
+
patch({
|
|
194
|
+
tokens: f.totals, model: f.model, external: f.external,
|
|
195
|
+
cost: f.costActive, costTotal: f.cost,
|
|
196
|
+
samples: f.samplesActive, samplesTotal: f.samples,
|
|
197
|
+
lastCost: f.samples > 0 ? f.lastCost : null, lastTime: f.lastTime, lastTokens: f.lastTokens,
|
|
198
|
+
spendAlert: configRuntime.spendAlert && f.costActive >= configRuntime.spendThreshold,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const computePrev = async (sid) => {
|
|
203
|
+
if (!sid) return
|
|
204
|
+
const sessionQuery = ctx.get('sessionQuery')
|
|
205
|
+
const sessions = ctx.get('sessions')
|
|
206
|
+
if (sessionQuery === undefined || sessions === undefined) return
|
|
207
|
+
const cur = sessions.get(sid)
|
|
208
|
+
if (cur === undefined) return
|
|
209
|
+
const cwd = cur.header.cwd
|
|
210
|
+
const curCreated = cur.header.createdAt
|
|
211
|
+
let records
|
|
212
|
+
try { records = await sessionQuery.listSessions() } catch { return }
|
|
213
|
+
let prev = null
|
|
214
|
+
for (const r of records) {
|
|
215
|
+
if (r.header.id === sid) continue
|
|
216
|
+
if (r.header.origin === 'subagent' || r.header.parentSession !== undefined) continue
|
|
217
|
+
if (cwd !== undefined && cwd !== r.header.cwd) continue
|
|
218
|
+
if (curCreated !== undefined && curCreated !== null && typeof r.header.createdAt === 'number' && r.header.createdAt >= curCreated) continue
|
|
219
|
+
prev = r.header
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
if (prev === null) { patch({ prevSessionId: undefined, prevCost: null, prevModel: null, prevExternal: false, prevSamples: 0 }); return }
|
|
223
|
+
try {
|
|
224
|
+
const load = await sessionQuery.load(prev.id)
|
|
225
|
+
if (load === null || typeof load !== 'object') { patch({ prevSessionId: prev.id, prevModel: null, prevExternal: false, prevCost: null, prevSamples: 0 }); return }
|
|
226
|
+
const f = foldCost(load.events, undefined)
|
|
227
|
+
patch({ prevSessionId: prev.id, prevModel: f.model, prevExternal: f.external, prevCost: f.cost, prevSamples: f.samples })
|
|
228
|
+
} catch {
|
|
229
|
+
patch({ prevSessionId: prev.id, prevModel: null, prevExternal: false, prevCost: null, prevSamples: 0 })
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fetchBalance = async () => {
|
|
234
|
+
const hit = await ctx.credentials.resolve(config.apiKeyRef)
|
|
235
|
+
if (hit === undefined || !hit.value) {
|
|
236
|
+
patch({ status: 'unconfigured', balance: null, delta: null, error: '' })
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
const controller = new AbortController()
|
|
240
|
+
const timer = setTimeout(() => controller.abort(), config.timeoutMs)
|
|
241
|
+
let payload
|
|
242
|
+
try {
|
|
243
|
+
const resp = await fetch(`${config.baseUrl.replace(/\/+$/u, '')}/user/balance`, {
|
|
244
|
+
headers: { authorization: `Bearer ${hit.value}` },
|
|
245
|
+
signal: controller.signal,
|
|
246
|
+
})
|
|
247
|
+
payload = await resp.json()
|
|
248
|
+
} finally {
|
|
249
|
+
clearTimeout(timer)
|
|
250
|
+
}
|
|
251
|
+
if (payload === null || typeof payload !== 'object' || payload.is_available !== true || !Array.isArray(payload.balance_infos) || payload.balance_infos.length < 1) {
|
|
252
|
+
throw new Error('余额接口响应格式异常')
|
|
253
|
+
}
|
|
254
|
+
const info = payload.balance_infos[0]
|
|
255
|
+
const balance = Number(info.total_balance)
|
|
256
|
+
if (!Number.isFinite(balance)) throw new Error('余额字段异常')
|
|
257
|
+
const prev = lastBalance
|
|
258
|
+
lastBalance = balance
|
|
259
|
+
patch({
|
|
260
|
+
status: 'ok', balance, currency: info.currency || 'CNY', updatedAt: Date.now(),
|
|
261
|
+
delta: prev === null ? null : round2(balance - prev), error: '',
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const refresh = async () => {
|
|
266
|
+
if (refreshing) return
|
|
267
|
+
refreshing = true
|
|
268
|
+
try {
|
|
269
|
+
await fetchBalance()
|
|
270
|
+
applyTokens(sessionId)
|
|
271
|
+
await computePrev(sessionId)
|
|
272
|
+
} catch (error) {
|
|
273
|
+
patch({ status: 'error', error: error instanceof Error ? error.message : String(error), delta: null })
|
|
274
|
+
} finally {
|
|
275
|
+
refreshing = false
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const stateHandler = async (req, res) => {
|
|
280
|
+
if (req.method !== 'GET') { sendJson(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED', message: '仅支持 GET' }); return }
|
|
281
|
+
if (!config.allowRemote && !isLoopbackRequest(req)) { sendJson(res, 403, { ok: false, code: 'FORBIDDEN', message: '仅允许本机访问' }); return }
|
|
282
|
+
try {
|
|
283
|
+
const url = new URL(req.url ?? STATE_ROUTE, 'http://localhost')
|
|
284
|
+
const sid = url.searchParams.get('session') || sessionId
|
|
285
|
+
if (sid && (measuredFor !== sid || url.searchParams.get('refresh') === '1')) {
|
|
286
|
+
sessionId = sid
|
|
287
|
+
applyTokens(sid)
|
|
288
|
+
await computePrev(sid)
|
|
289
|
+
} else if (url.searchParams.get('refresh') === '1') {
|
|
290
|
+
await refresh()
|
|
291
|
+
}
|
|
292
|
+
sendJson(res, 200, { ok: true, ...snapshot() })
|
|
293
|
+
} catch (error) {
|
|
294
|
+
sendJson(res, 500, { ok: false, code: 'HOST_ERROR', message: error instanceof Error ? error.message : String(error) })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const configHandler = async (req, res) => {
|
|
299
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED', message: '仅支持 POST' }); return }
|
|
300
|
+
if (!config.allowRemote && !isLoopbackRequest(req)) { sendJson(res, 403, { ok: false, code: 'FORBIDDEN', message: '仅允许本机访问' }); return }
|
|
301
|
+
try {
|
|
302
|
+
const a = await readJsonBody(req)
|
|
303
|
+
if (typeof a.threshold === 'number' && Number.isFinite(a.threshold) && a.threshold >= 0) configRuntime.threshold = a.threshold
|
|
304
|
+
if (typeof a.spendAlert === 'boolean') configRuntime.spendAlert = a.spendAlert
|
|
305
|
+
if (typeof a.spendThreshold === 'number' && Number.isFinite(a.spendThreshold) && a.spendThreshold >= 0) configRuntime.spendThreshold = a.spendThreshold
|
|
306
|
+
if (typeof a.usdRate === 'number' && Number.isFinite(a.usdRate) && a.usdRate > 0) configRuntime.usdRate = a.usdRate
|
|
307
|
+
if (typeof a.afterTurn === 'boolean') configRuntime.afterTurn = a.afterTurn
|
|
308
|
+
if (typeof a.every5min === 'boolean') configRuntime.every5min = a.every5min
|
|
309
|
+
if (typeof a.priceKey === 'string' && a.priceKey !== '' && a.price !== null && typeof a.price === 'object') {
|
|
310
|
+
const p = a.price
|
|
311
|
+
const inV = Number(p.in); const cacheV = Number(p.cache); const outV = Number(p.out)
|
|
312
|
+
if (Number.isFinite(inV) && inV >= 0 && Number.isFinite(cacheV) && cacheV >= 0 && Number.isFinite(outV) && outV >= 0) {
|
|
313
|
+
const cur = configRuntime.prices[a.priceKey] || structuredClone({ currency: 'CNY', in: 1.5, cache: 0.05, out: 4.5, peakIn: 3.0, peakCache: 0.10, peakOut: 9.0 })
|
|
314
|
+
if (a.tier === 'peak') { cur.peakIn = inV; cur.peakCache = cacheV; cur.peakOut = outV }
|
|
315
|
+
else { cur.in = inV; cur.cache = cacheV; cur.out = outV }
|
|
316
|
+
configRuntime.prices[a.priceKey] = cur
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
applyTokens(sessionId)
|
|
320
|
+
await computePrev(sessionId)
|
|
321
|
+
sendJson(res, 200, { ok: true, ...snapshot() })
|
|
322
|
+
} catch (error) {
|
|
323
|
+
sendJson(res, 500, { ok: false, code: 'HOST_ERROR', message: error instanceof Error ? error.message : String(error) })
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: STATE_ROUTE, handler: stateHandler }), 'dsh-balance: state route')
|
|
328
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: CONFIG_ROUTE, handler: configHandler }), 'dsh-balance: config route')
|
|
329
|
+
|
|
330
|
+
// Refresh after each conversation turn ends, and every 5 minutes.
|
|
331
|
+
ctx.on('agent/turn-stopping', (payload) => {
|
|
332
|
+
if (payload && payload.agent && payload.agent.id) sessionId = payload.agent.id
|
|
333
|
+
if (!configRuntime.afterTurn) return
|
|
334
|
+
void refresh()
|
|
335
|
+
})
|
|
336
|
+
ctx.interval(() => {
|
|
337
|
+
if (configRuntime.every5min) void refresh()
|
|
338
|
+
}, 5 * 60 * 1000)
|
|
339
|
+
|
|
340
|
+
void refresh()
|
|
341
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yyfather/dsh-balance",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness 余额与开销插件:状态栏显示 DeepSeek/MiMo 余额与花费(本会话/本次活跃/最近一次/上次对话),逐请求按模型+峰谷时段计价,支持花费超线提醒(点击设置)",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/YYfather/dsh-balance.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/YYfather/dsh-balance#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/YYfather/dsh-balance/issues"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"deepseek",
|
|
15
|
+
"deepseek-harness",
|
|
16
|
+
"dsh-plugin",
|
|
17
|
+
"dsh-plugins",
|
|
18
|
+
"balance",
|
|
19
|
+
"cost",
|
|
20
|
+
"usage"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./index.js",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./index.js",
|
|
26
|
+
"./client": "./client.js",
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"index.js",
|
|
31
|
+
"client.js",
|
|
32
|
+
"cordis.patch.yml",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"README.md",
|
|
35
|
+
"README_EN.md"
|
|
36
|
+
],
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
},
|
|
41
|
+
"client": {
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
55
|
+
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
|
|
56
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
57
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
58
|
+
"react": "^18.2.0"
|
|
59
|
+
}
|
|
60
|
+
}
|