dsh-billing-badge 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/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +473 -0
- package/lib/index.js +159 -0
- package/lib/season.js +148 -0
- package/package.json +62 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
|
6
|
+
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-09-13
|
|
9
|
+
|
|
10
|
+
First public release.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- A pill in the composer statistics row, appended after the native cache-hit reading.
|
|
15
|
+
It shows a season dot (amber for peak, green for off-peak), the season name, and the
|
|
16
|
+
countdown to the next flip.
|
|
17
|
+
- A panel on click, styled with the native dialog tokens: billing season, next switch
|
|
18
|
+
with the Beijing wall clock, current Beijing time, account balance with its currency,
|
|
19
|
+
the granted and topped-up split, a warning row when the API reports the balance as
|
|
20
|
+
insufficient for calls, and a refresh button.
|
|
21
|
+
- A read-only host route, `GET /plugins/billing-badge/balance`, which resolves the API
|
|
22
|
+
key through the DSH credentials seam, caches a reading for 60 seconds, and dedupes
|
|
23
|
+
concurrent reads. It requires the `x-dsh-billing-badge: 1` header and rejects a
|
|
24
|
+
cross-origin `Origin`.
|
|
25
|
+
- The season rule as a tested module: peak is Beijing time, Monday to Friday,
|
|
26
|
+
09:00-12:00 and 14:00-18:00, everything else including the whole weekend is off-peak
|
|
27
|
+
at half price. The next switch is computed by comparing each candidate boundary with
|
|
28
|
+
the instant before it, so only a real state flip counts.
|
|
29
|
+
- 30 tests covering the season rule, a countdown invariant across nine days, bundle
|
|
30
|
+
and source sync, the host route and its guards, and the panel rows.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- `is_available` is a top-level field of the balance response, a sibling of
|
|
35
|
+
`balance_infos`, not a member of the entry. Reading it from the entry made the flag
|
|
36
|
+
false for every account. The regression test pins the documented location.
|
|
37
|
+
|
|
38
|
+
MIT.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Vega
|
|
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,161 @@
|
|
|
1
|
+
# dsh-billing-badge
|
|
2
|
+
|
|
3
|
+
Billing season and account balance for the DeepSeek Harness web GUI. A pill in the
|
|
4
|
+
composer's statistics row, immediately after the native **Cache hit** reading, that
|
|
5
|
+
opens a small panel with the full picture.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
2288M tok · Cache hit 99.8% · ● Off-peak · 2h13m
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What it shows
|
|
12
|
+
|
|
13
|
+
| Where | What |
|
|
14
|
+
|---|---|
|
|
15
|
+
| The pill | a coloured dot (amber for peak, green for off-peak), the season, and the time until it flips |
|
|
16
|
+
| The panel (click) | billing season, next switch with the Beijing wall clock, current Beijing time, account balance with its currency, granted and topped-up split, and a refresh button |
|
|
17
|
+
|
|
18
|
+
The season rule is the published one: peak is Beijing time, Monday to Friday,
|
|
19
|
+
09:00-12:00 and 14:00-18:00. Everything else, including all of Saturday and Sunday,
|
|
20
|
+
is off-peak at half price.
|
|
21
|
+
|
|
22
|
+
## The balance
|
|
23
|
+
|
|
24
|
+
The numbers come from the official `GET /user/balance` endpoint, and the three of them
|
|
25
|
+
mean different things:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
total_balance = granted_balance + topped_up_balance
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `total_balance`, shown as **Account balance**, is everything you can spend.
|
|
32
|
+
- `granted_balance`, shown as **Granted**, is credit DeepSeek gave you. The endpoint
|
|
33
|
+
reports only the part that has not expired, so a lapsed grant disappears from this
|
|
34
|
+
row on its own.
|
|
35
|
+
- `topped_up_balance`, shown as **Topped up**, is money you paid in.
|
|
36
|
+
|
|
37
|
+
When the two upper rows are equal there is no grant on the account. `is_available`
|
|
38
|
+
is a top-level field of the response and answers one question: is the balance enough
|
|
39
|
+
for API calls. The panel adds a warning row when the answer is no, and stays quiet
|
|
40
|
+
otherwise, because the flag is true for every funded account.
|
|
41
|
+
|
|
42
|
+
The currency is taken from the response, never assumed: an account reporting USD is
|
|
43
|
+
not labelled with a CNY sign.
|
|
44
|
+
|
|
45
|
+
## Why another one
|
|
46
|
+
|
|
47
|
+
Two community plugins cover parts of this, and both taught something:
|
|
48
|
+
|
|
49
|
+
- [dsh-price-phase](https://github.com/lijunyu726/dsh-price-phase) shows the season.
|
|
50
|
+
Its countdown once pointed at Saturday 09:00 after Friday close, an event that does
|
|
51
|
+
not happen, and it centres its badge on a hashed CSS class, which collides with the
|
|
52
|
+
model chip when the model name is long. This plugin compares each candidate boundary
|
|
53
|
+
with the instant before it and only counts a real state flip, and its chip is an
|
|
54
|
+
ordinary flex child in the native statistics row.
|
|
55
|
+
- [dsh-usage-monitor](https://github.com/liyiersan/dsh-usage-monitor) shows the
|
|
56
|
+
balance, but formats it as CNY whatever the API reports.
|
|
57
|
+
|
|
58
|
+
This plugin deliberately does **not** do cost or token accounting.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
dsh plugin --profile web add github:devacc8/dsh-billing-badge
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
then restart `dsh web`. The package declares `dsh.bundle.patch`, so the host half is
|
|
67
|
+
reconciled into the profile's bundle list automatically.
|
|
68
|
+
|
|
69
|
+
Working on the plugin itself, install the checkout by path instead:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
dsh plugin --profile web add link:/absolute/path/to/dsh-billing-badge
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Security
|
|
76
|
+
|
|
77
|
+
A balance is a small surface, so it stays small:
|
|
78
|
+
|
|
79
|
+
- the API key is resolved in the host through the DSH credentials seam
|
|
80
|
+
(`ctx.credentials.resolve('DEEPSEEK_API_KEY')`, environment fallback) and never
|
|
81
|
+
reaches the browser;
|
|
82
|
+
- the single route requires the `x-dsh-billing-badge: 1` header and rejects a
|
|
83
|
+
cross-origin `Origin`, so a cross-site page cannot reach it;
|
|
84
|
+
- nothing is written to disk, and no endpoint other than `api.deepseek.com` is
|
|
85
|
+
contacted;
|
|
86
|
+
- a missing key, an HTTP error or a network failure all degrade to a state the panel
|
|
87
|
+
renders, never to a throw.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
The season logic lives in `lib/season.js` as a plain ESM module so it can be tested
|
|
92
|
+
directly. A browser bundle cannot import a sibling file (the loader resolves only
|
|
93
|
+
platform seeds, materialized packages and registered factories, and a self-subpath
|
|
94
|
+
`require` throws "missed the module table"), so `scripts/inline-season.mjs` copies the
|
|
95
|
+
module into `lib/client.js` between two markers with `export ` stripped, and
|
|
96
|
+
`test/client-sync.test.mjs` fails if the copy drifts.
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
npm test # 30 tests: season rule, countdown invariant, host route, bundle
|
|
100
|
+
npm run sync # re-inline season.js into the bundle
|
|
101
|
+
npm run check # sync check plus a syntax check of both halves
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The countdown has an invariant test rather than fixtures: every 13 minutes across nine
|
|
105
|
+
days, the reported target must be in the future, must change the season, and the
|
|
106
|
+
season must not change before it.
|
|
107
|
+
|
|
108
|
+
## Layout
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
lib/season.js season rule, countdown, formatting (source of truth, tested)
|
|
112
|
+
lib/index.js host half: the balance route
|
|
113
|
+
lib/client.js browser half: the pill and its panel, with season.js inlined
|
|
114
|
+
cordis.patch.yml mounts the host half into the profile
|
|
115
|
+
scripts/ the inliner
|
|
116
|
+
test/ season, sync, host and bundle tests
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
GitHub Actions runs `npm test` and `npm run check` on Node 20 and 22.
|
|
120
|
+
|
|
121
|
+
## 中文说明
|
|
122
|
+
|
|
123
|
+
DeepSeek Harness 网页界面的计费时段与账户余额插件。它在输入框下方的统计行里、原生 **Cache hit** 之后加一个小胶囊,点击后展开一个小面板。
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
2288M tok · Cache hit 99.8% · ● Off-peak · 2h13m
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- **胶囊**:一个圆点(高峰为琥珀色,非高峰为绿色)、当前时段,以及距离切换的倒计时。
|
|
130
|
+
- **面板**(点击展开):计费时段、下次切换时间与北京时间、当前北京时间、账户余额及其货币、赠送额度与充值额度的拆分,以及刷新按钮。
|
|
131
|
+
|
|
132
|
+
计费时段采用官方公布的规则:高峰为北京时间周一至周五 09:00-12:00 与 14:00-18:00,其余时间(含整个周六与周日)均为非高峰,价格为半价。
|
|
133
|
+
|
|
134
|
+
余额来自官方 `GET /user/balance` 接口,其中三个数字含义不同:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
total_balance = granted_balance + topped_up_balance
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- `total_balance`(Account balance):可用总额。
|
|
141
|
+
- `granted_balance`(Granted):官方赠送的额度,接口只返回尚未过期的部分,过期的赠送额度会自动从这一行消失。
|
|
142
|
+
- `topped_up_balance`(Topped up):你自己充值的金额。
|
|
143
|
+
|
|
144
|
+
前两行数值相等时,说明账户没有赠送额度。`is_available` 是响应顶层的字段,回答一个问题:余额是否足够调用接口。只有接口报告余额不足时,面板才会加一行警告;该字段对任何有余额的账户都是 true,因此平时不显示。货币一律取自接口返回值,不做假设:返回 USD 的账户不会被标上人民币符号。
|
|
145
|
+
|
|
146
|
+
### 安装
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
dsh plugin --profile web add github:devacc8/dsh-billing-badge
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
然后重启 `dsh web`。包内声明了 `dsh.bundle.patch`,宿主部分会自动写入 profile 的 bundle 列表。
|
|
153
|
+
|
|
154
|
+
### 安全
|
|
155
|
+
|
|
156
|
+
- API key 只在宿主进程中通过 DSH credentials 接口读取(`ctx.credentials.resolve('DEEPSEEK_API_KEY')`,环境变量兜底),不会进入浏览器;
|
|
157
|
+
- 唯一的路由要求请求头 `x-dsh-billing-badge: 1`,并拒绝跨站 `Origin`;
|
|
158
|
+
- 不写任何文件,除 `api.deepseek.com` 外不访问其他地址;
|
|
159
|
+
- 缺少 key、HTTP 错误或网络故障都会降级为面板可显示的状态,不会抛异常。
|
|
160
|
+
|
|
161
|
+
MIT.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# dsh-billing-badge bundle patch: mounts the host half into the profile's host
|
|
2
|
+
# composition. Applied by `dsh plugin --profile web add <this package>`, which
|
|
3
|
+
# reconciles the package into dsh.profile.bundles automatically.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: billing-badge
|
|
6
|
+
name: dsh-billing-badge
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-billing-badge",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
const react = require("react");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* dsh-billing-badge, browser half.
|
|
10
|
+
*
|
|
11
|
+
* Shows the current DeepSeek billing season and the account balance in the
|
|
12
|
+
* composer's statistics row, immediately after the native "Cache hit" pill,
|
|
13
|
+
* and opens a small panel with the full reading on click.
|
|
14
|
+
*
|
|
15
|
+
* The native row is a single component (`[data-composer-stats]`) rather than a
|
|
16
|
+
* slot, and the composer container is a column, so a sibling in the slot would
|
|
17
|
+
* land underneath instead of beside it. The slot registration below is
|
|
18
|
+
* therefore a mount point only: it renders nothing and appends the chip into
|
|
19
|
+
* the native row. The data attribute is deliberate, unlike the hashed class an
|
|
20
|
+
* earlier community plugin centred itself on (which collided with the model
|
|
21
|
+
* chip), and the chip is an ordinary flex child so it cannot overlap anything.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// >>> billing-badge:season (generated from lib/season.js by scripts/inline-season.mjs) >>>
|
|
25
|
+
/**
|
|
26
|
+
* Billing season logic for DeepSeek's time-of-day pricing.
|
|
27
|
+
*
|
|
28
|
+
* The rule is fixed and published: peak windows are Beijing time, Monday to
|
|
29
|
+
* Friday, 09:00-12:00 and 14:00-18:00. Everything else, including all of
|
|
30
|
+
* Saturday and Sunday, is off-peak and billed at half the peak price.
|
|
31
|
+
*
|
|
32
|
+
* Timezone handling: the rule is defined in Beijing time (UTC+8), so every
|
|
33
|
+
* calculation shifts the instant by a fixed offset and reads the UTC fields.
|
|
34
|
+
* Local timezones are never used for the decision, only for display.
|
|
35
|
+
*
|
|
36
|
+
* @module season
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Beijing time is UTC+8. */
|
|
40
|
+
const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
/** Peak windows in Beijing time, as [from, to) minutes after midnight. */
|
|
43
|
+
const PEAK_WINDOWS = [
|
|
44
|
+
[9 * 60, 12 * 60],
|
|
45
|
+
[14 * 60, 18 * 60],
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Candidate boundary minutes: midnight plus every window edge. */
|
|
49
|
+
const BOUNDARY_MINUTES = [0, 9 * 60, 12 * 60, 14 * 60, 18 * 60];
|
|
50
|
+
|
|
51
|
+
/** Milliseconds in a day, for candidate generation. */
|
|
52
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether an instant falls in a peak window (Beijing time, Monday to Friday).
|
|
56
|
+
*
|
|
57
|
+
* Weekend days return false before any window is tested. Comparing only the
|
|
58
|
+
* clock would report a peak window every Saturday and Sunday, six hours of
|
|
59
|
+
* false peak per week, which is the mistake this module exists to avoid.
|
|
60
|
+
*
|
|
61
|
+
* @param {Date} [at] - Instant to test, defaults to now.
|
|
62
|
+
* @returns {boolean} True when the instant is peak.
|
|
63
|
+
*/
|
|
64
|
+
function isPeak(at = new Date()) {
|
|
65
|
+
const beijing = new Date(at.getTime() + BEIJING_OFFSET_MS);
|
|
66
|
+
const day = beijing.getUTCDay();
|
|
67
|
+
if (day === 0 || day === 6) return false;
|
|
68
|
+
const minutes = beijing.getUTCHours() * 60 + beijing.getUTCMinutes();
|
|
69
|
+
return PEAK_WINDOWS.some(([from, to]) => minutes >= from && minutes < to);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Beijing midnight of the day that contains an instant, as an absolute time.
|
|
74
|
+
*
|
|
75
|
+
* @param {number} timeMs - Instant in milliseconds.
|
|
76
|
+
* @returns {number} The instant of Beijing midnight.
|
|
77
|
+
*/
|
|
78
|
+
function beijingMidnight(timeMs) {
|
|
79
|
+
const beijing = new Date(timeMs + BEIJING_OFFSET_MS);
|
|
80
|
+
return Date.UTC(beijing.getUTCFullYear(), beijing.getUTCMonth(), beijing.getUTCDate()) - BEIJING_OFFSET_MS;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The next instant at which the season actually flips.
|
|
85
|
+
*
|
|
86
|
+
* Candidate boundaries are generated for the next ten days, then each is
|
|
87
|
+
* accepted only when the state one millisecond before it differs from the state
|
|
88
|
+
* at it. That filter is the point: Friday 18:00 ends the peak week, but the
|
|
89
|
+
* following boundary, Saturday 09:00, changes nothing because the whole weekend
|
|
90
|
+
* is already off-peak. Reporting it would show a countdown to an event that
|
|
91
|
+
* does not happen, so the honest answer is Monday 09:00.
|
|
92
|
+
*
|
|
93
|
+
* @param {Date} [at] - Instant to search from, defaults to now.
|
|
94
|
+
* @returns {number|null} Absolute time of the flip, or null when none is found.
|
|
95
|
+
*/
|
|
96
|
+
function nextFlip(at = new Date()) {
|
|
97
|
+
const from = at.getTime();
|
|
98
|
+
const midnight = beijingMidnight(from);
|
|
99
|
+
const candidates = [];
|
|
100
|
+
for (let day = 0; day <= 10; day += 1) {
|
|
101
|
+
const base = midnight + day * DAY_MS;
|
|
102
|
+
for (const minutes of BOUNDARY_MINUTES) candidates.push(base + minutes * 60 * 1000);
|
|
103
|
+
}
|
|
104
|
+
candidates.sort((a, b) => a - b);
|
|
105
|
+
for (const candidate of candidates) {
|
|
106
|
+
if (candidate <= from) continue;
|
|
107
|
+
if (isPeak(new Date(candidate)) !== isPeak(new Date(candidate - 1))) return candidate;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Remaining time until an instant, as a compact label.
|
|
114
|
+
*
|
|
115
|
+
* @param {number} ms - Milliseconds remaining.
|
|
116
|
+
* @returns {string} `2h13m`, `45m` or `38s`.
|
|
117
|
+
*/
|
|
118
|
+
function formatCountdown(ms) {
|
|
119
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
120
|
+
const hours = Math.floor(total / 3600);
|
|
121
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
122
|
+
const seconds = total % 60;
|
|
123
|
+
if (hours > 0) return `${hours}h${String(minutes).padStart(2, '0')}m`;
|
|
124
|
+
if (minutes > 0) return `${minutes}m`;
|
|
125
|
+
return `${seconds}s`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Beijing wall clock of an instant, for the popover.
|
|
130
|
+
*
|
|
131
|
+
* @param {Date} [at] - Instant to describe, defaults to now.
|
|
132
|
+
* @returns {{weekday: string, clock: string}} Weekday name and HH:MM.
|
|
133
|
+
*/
|
|
134
|
+
function beijingClock(at = new Date()) {
|
|
135
|
+
const beijing = new Date(at.getTime() + BEIJING_OFFSET_MS);
|
|
136
|
+
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
137
|
+
const hh = String(beijing.getUTCHours()).padStart(2, '0');
|
|
138
|
+
const mm = String(beijing.getUTCMinutes()).padStart(2, '0');
|
|
139
|
+
return { weekday: weekdays[beijing.getUTCDay()], clock: `${hh}:${mm}` };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Everything the chip and its popover need for one instant.
|
|
144
|
+
*
|
|
145
|
+
* @param {Date} [at] - Instant to describe, defaults to now.
|
|
146
|
+
* @returns {object} Phase description.
|
|
147
|
+
*/
|
|
148
|
+
function describePhase(at = new Date()) {
|
|
149
|
+
const peak = isPeak(at);
|
|
150
|
+
const flip = nextFlip(at);
|
|
151
|
+
const remaining = flip === null ? null : flip - at.getTime();
|
|
152
|
+
const countdown = remaining === null ? '' : formatCountdown(remaining);
|
|
153
|
+
const { weekday, clock } = beijingClock(at);
|
|
154
|
+
const label = peak ? 'Peak' : 'Off-peak';
|
|
155
|
+
const compact = peak ? 'Peak' : 'Off-peak';
|
|
156
|
+
const nextLabel = peak ? 'Off-peak' : 'Peak';
|
|
157
|
+
return {
|
|
158
|
+
peak,
|
|
159
|
+
label,
|
|
160
|
+
compact,
|
|
161
|
+
countdown,
|
|
162
|
+
flipAt: flip,
|
|
163
|
+
remainingMs: remaining,
|
|
164
|
+
nextLabel,
|
|
165
|
+
beijing: { weekday, clock },
|
|
166
|
+
/** Single source of truth for the two state colors, used by chip and popover. */
|
|
167
|
+
color: peak ? '#D9A24A' : '#57C07C',
|
|
168
|
+
title: peak
|
|
169
|
+
? `Peak billing until ${countdown} from now (${nextLabel} after that). Beijing time ${weekday} ${clock}.`
|
|
170
|
+
: `Off-peak billing, half price, for another ${countdown}. Beijing time ${weekday} ${clock}.`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// <<< billing-badge:season <<<
|
|
174
|
+
|
|
175
|
+
const NS = "billing-badge";
|
|
176
|
+
const CSS_TAG_ID = "dsh-billing-badge/core.css";
|
|
177
|
+
const HEADER = { "x-dsh-billing-badge": "1" };
|
|
178
|
+
const BALANCE_URL = "/plugins/billing-badge/balance";
|
|
179
|
+
const TICK_MS = 30 * 1000;
|
|
180
|
+
const NARROW = "(max-width: 760px)";
|
|
181
|
+
|
|
182
|
+
/** Styles copied from the native stats pill and panel so the chip reads as part of the row. */
|
|
183
|
+
const CSS = `
|
|
184
|
+
.dsh-billing-badge {
|
|
185
|
+
box-sizing: border-box; max-width: 100%;
|
|
186
|
+
color: var(--dsw-alias-label-tertiary);
|
|
187
|
+
font: inherit; font-variant-numeric: tabular-nums; line-height: inherit;
|
|
188
|
+
white-space: nowrap; background: 0 0; border: none; border-radius: 24px;
|
|
189
|
+
align-items: center; gap: 6px; padding: 1px 8px; display: inline-flex;
|
|
190
|
+
cursor: pointer;
|
|
191
|
+
}
|
|
192
|
+
.dsh-billing-badge:hover, .dsh-billing-badge[aria-expanded="true"] {
|
|
193
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
194
|
+
color: var(--dsw-alias-label-secondary);
|
|
195
|
+
}
|
|
196
|
+
.dsh-billing-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
|
|
197
|
+
.dsh-billing-muted { opacity: .6; }
|
|
198
|
+
.dsh-billing-panel {
|
|
199
|
+
position: fixed; z-index: 1100; box-sizing: border-box;
|
|
200
|
+
background: var(--dsw-specific-menu);
|
|
201
|
+
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
|
202
|
+
box-shadow: var(--dsw-elevation-prominent);
|
|
203
|
+
color: var(--dsw-alias-label-secondary);
|
|
204
|
+
border: 0; border-radius: 12px; padding: 16px;
|
|
205
|
+
font-size: 12px; line-height: 18px;
|
|
206
|
+
min-width: min(300px, 100vw - 24px); max-width: min(420px, 100vw - 24px);
|
|
207
|
+
}
|
|
208
|
+
.dsh-billing-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
209
|
+
.dsh-billing-title { font-weight: 500; color: var(--dsw-alias-label-primary); flex: 1; }
|
|
210
|
+
.dsh-billing-refresh {
|
|
211
|
+
background: 0 0; border: none; padding: 0 2px; cursor: pointer;
|
|
212
|
+
color: var(--dsw-alias-label-tertiary); font: inherit;
|
|
213
|
+
}
|
|
214
|
+
.dsh-billing-refresh:hover { color: var(--dsw-alias-label-primary); }
|
|
215
|
+
.dsh-billing-rows { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; }
|
|
216
|
+
.dsh-billing-rows dt { color: var(--dsw-alias-label-secondary); }
|
|
217
|
+
.dsh-billing-rows dd { margin: 0; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; text-align: right; }
|
|
218
|
+
.dsh-billing-note { margin-top: 10px; color: var(--dsw-alias-label-caption); }
|
|
219
|
+
.dsh-billing-err { color: var(--dsw-alias-state-warn-label); }
|
|
220
|
+
`;
|
|
221
|
+
|
|
222
|
+
/** Inject the stylesheet once per page. */
|
|
223
|
+
function injectCss() {
|
|
224
|
+
if (typeof document === "undefined") return;
|
|
225
|
+
if (document.querySelector('style[data-plugin-css="' + CSS_TAG_ID + '"]') !== null) return;
|
|
226
|
+
const tag = document.createElement("style");
|
|
227
|
+
tag.dataset.plugin = "dsh-billing-badge";
|
|
228
|
+
tag.dataset.pluginCss = CSS_TAG_ID;
|
|
229
|
+
tag.textContent = CSS;
|
|
230
|
+
document.head.appendChild(tag);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Format a money amount the way the API reports it: own currency, own precision. */
|
|
234
|
+
function formatMoney(amount, currency) {
|
|
235
|
+
if (typeof amount !== "number" || !Number.isFinite(amount)) return "--";
|
|
236
|
+
const digits = amount >= 100 ? 2 : amount >= 1 ? 2 : 4;
|
|
237
|
+
const symbol = currency === "CNY" ? "\u00a5" : currency === "USD" ? "$" : currency === "EUR" ? "\u20ac" : "";
|
|
238
|
+
const text = amount.toFixed(digits).replace(/0+$/, "").replace(/\.$/, "");
|
|
239
|
+
return symbol === "" ? `${text} ${currency || ""}`.trim() : `${symbol}${text}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Read the balance through the plugin's own route. */
|
|
243
|
+
async function loadBalance(refresh) {
|
|
244
|
+
try {
|
|
245
|
+
const response = await fetch(BALANCE_URL + (refresh ? "?refresh=1" : ""), { headers: HEADER });
|
|
246
|
+
if (!response.ok) return { ok: false, state: "error", error: `HTTP ${response.status}` };
|
|
247
|
+
return await response.json();
|
|
248
|
+
} catch (err) {
|
|
249
|
+
return { ok: false, state: "error", error: String((err && err.message) || err) };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Build the panel body for one reading. */
|
|
254
|
+
function panelBody(balance) {
|
|
255
|
+
const phase = describePhase(new Date());
|
|
256
|
+
const flip = phase.flipAt === null ? null : new Date(phase.flipAt);
|
|
257
|
+
const rows = [
|
|
258
|
+
["Billing season", phase.peak ? "Peak" : "Off-peak (half price)"],
|
|
259
|
+
["Next switch", phase.countdown === "" ? "--" : `in ${phase.countdown}` + (flip === null ? "" : `, ${beijingClock(flip).weekday} ${beijingClock(flip).clock} Beijing`)],
|
|
260
|
+
["Beijing time", `${phase.beijing.weekday} ${phase.beijing.clock}`],
|
|
261
|
+
];
|
|
262
|
+
if (balance && balance.ok) {
|
|
263
|
+
rows.push(["Account balance", formatMoney(balance.total, balance.currency) + (balance.currency ? ` ${balance.currency}` : "")]);
|
|
264
|
+
rows.push(["Granted", formatMoney(balance.granted, balance.currency)]);
|
|
265
|
+
rows.push(["Topped up", formatMoney(balance.toppedUp, balance.currency)]);
|
|
266
|
+
// `is_available` is true for every funded account, so it earns a row
|
|
267
|
+
// only when the API says the balance is not enough for calls. A
|
|
268
|
+
// permanent "allowed" row would be furniture.
|
|
269
|
+
if (balance.isAvailable === false) {
|
|
270
|
+
rows.push(["API calls", "insufficient balance", "warn"]);
|
|
271
|
+
}
|
|
272
|
+
} else if (balance && balance.state === "no-credential") {
|
|
273
|
+
rows.push(["Account balance", "no API key configured"]);
|
|
274
|
+
} else if (balance) {
|
|
275
|
+
rows.push(["Account balance", "unavailable"]);
|
|
276
|
+
}
|
|
277
|
+
return rows;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Mount the chip and its panel into the native statistics row.
|
|
282
|
+
*
|
|
283
|
+
* @param {HTMLElement} row - The `[data-composer-stats]` element.
|
|
284
|
+
* @returns {() => void} Teardown.
|
|
285
|
+
*/
|
|
286
|
+
function mountChip(row) {
|
|
287
|
+
injectCss();
|
|
288
|
+
|
|
289
|
+
const chip = document.createElement("button");
|
|
290
|
+
chip.type = "button";
|
|
291
|
+
chip.className = "dsh-billing-badge";
|
|
292
|
+
chip.setAttribute("aria-haspopup", "dialog");
|
|
293
|
+
chip.setAttribute("aria-expanded", "false");
|
|
294
|
+
|
|
295
|
+
const dot = document.createElement("span");
|
|
296
|
+
dot.className = "dsh-billing-dot";
|
|
297
|
+
const label = document.createElement("span");
|
|
298
|
+
const count = document.createElement("span");
|
|
299
|
+
count.className = "dsh-billing-muted";
|
|
300
|
+
chip.append(dot, label, count);
|
|
301
|
+
|
|
302
|
+
let panel = null;
|
|
303
|
+
let balance = null;
|
|
304
|
+
let balanceLoaded = false;
|
|
305
|
+
let tick = 0;
|
|
306
|
+
|
|
307
|
+
const render = () => {
|
|
308
|
+
const phase = describePhase(new Date());
|
|
309
|
+
dot.style.background = phase.color;
|
|
310
|
+
label.textContent = phase.compact;
|
|
311
|
+
const narrow = typeof window !== "undefined" && window.matchMedia(NARROW).matches;
|
|
312
|
+
count.textContent = narrow || phase.countdown === "" ? "" : "\u00b7 " + phase.countdown;
|
|
313
|
+
chip.title = phase.title;
|
|
314
|
+
if (panel !== null) fillPanel();
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const fillPanel = () => {
|
|
318
|
+
if (panel === null) return;
|
|
319
|
+
const body = panel.querySelector(".dsh-billing-rows");
|
|
320
|
+
const note = panel.querySelector(".dsh-billing-note");
|
|
321
|
+
body.replaceChildren();
|
|
322
|
+
for (const [key, value, tone] of panelBody(balance)) {
|
|
323
|
+
const dt = document.createElement("dt");
|
|
324
|
+
dt.textContent = key;
|
|
325
|
+
const dd = document.createElement("dd");
|
|
326
|
+
dd.textContent = value;
|
|
327
|
+
if (tone === "warn") dd.style.color = "var(--dsw-alias-state-warn-label)";
|
|
328
|
+
body.append(dt, dd);
|
|
329
|
+
}
|
|
330
|
+
note.textContent = balance && balance.ok === false && balance.error
|
|
331
|
+
? String(balance.error)
|
|
332
|
+
: "Prices are published in CNY per million tokens; the balance keeps the currency the API reports.";
|
|
333
|
+
note.classList.toggle("dsh-billing-err", Boolean(balance && balance.ok === false));
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const placePanel = () => {
|
|
337
|
+
if (panel === null) return;
|
|
338
|
+
const rect = chip.getBoundingClientRect();
|
|
339
|
+
panel.style.visibility = "hidden";
|
|
340
|
+
panel.style.left = "0px";
|
|
341
|
+
panel.style.top = "0px";
|
|
342
|
+
const width = panel.offsetWidth;
|
|
343
|
+
const height = panel.offsetHeight;
|
|
344
|
+
const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8));
|
|
345
|
+
const above = rect.top - height - 8;
|
|
346
|
+
panel.style.left = `${left}px`;
|
|
347
|
+
panel.style.top = `${above >= 8 ? above : rect.bottom + 8}px`;
|
|
348
|
+
panel.style.visibility = "";
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const onDocumentPointer = (event) => {
|
|
352
|
+
if (panel === null) return;
|
|
353
|
+
if (panel.contains(event.target) || chip.contains(event.target)) return;
|
|
354
|
+
closePanel();
|
|
355
|
+
};
|
|
356
|
+
const onKey = (event) => {
|
|
357
|
+
if (event.key === "Escape") closePanel();
|
|
358
|
+
};
|
|
359
|
+
const onViewport = () => closePanel();
|
|
360
|
+
|
|
361
|
+
function closePanel() {
|
|
362
|
+
if (panel === null) return;
|
|
363
|
+
panel.remove();
|
|
364
|
+
panel = null;
|
|
365
|
+
chip.setAttribute("aria-expanded", "false");
|
|
366
|
+
document.removeEventListener("pointerdown", onDocumentPointer, true);
|
|
367
|
+
document.removeEventListener("keydown", onKey, true);
|
|
368
|
+
window.removeEventListener("resize", onViewport);
|
|
369
|
+
window.removeEventListener("scroll", onViewport, true);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function openPanel() {
|
|
373
|
+
if (panel !== null) return;
|
|
374
|
+
panel = document.createElement("div");
|
|
375
|
+
panel.className = "dsh-billing-panel";
|
|
376
|
+
panel.setAttribute("role", "dialog");
|
|
377
|
+
panel.setAttribute("aria-label", "Billing and balance");
|
|
378
|
+
panel.innerHTML =
|
|
379
|
+
'<div class="dsh-billing-head"><span class="dsh-billing-title">Billing and balance</span>' +
|
|
380
|
+
'<button class="dsh-billing-refresh" type="button">Refresh</button></div>' +
|
|
381
|
+
'<dl class="dsh-billing-rows"></dl>' +
|
|
382
|
+
'<div class="dsh-billing-note"></div>';
|
|
383
|
+
panel.querySelector(".dsh-billing-refresh").addEventListener("click", async () => {
|
|
384
|
+
balance = await loadBalance(true);
|
|
385
|
+
fillPanel();
|
|
386
|
+
});
|
|
387
|
+
document.body.appendChild(panel);
|
|
388
|
+
chip.setAttribute("aria-expanded", "true");
|
|
389
|
+
fillPanel();
|
|
390
|
+
placePanel();
|
|
391
|
+
document.addEventListener("pointerdown", onDocumentPointer, true);
|
|
392
|
+
document.addEventListener("keydown", onKey, true);
|
|
393
|
+
window.addEventListener("resize", onViewport);
|
|
394
|
+
window.addEventListener("scroll", onViewport, true);
|
|
395
|
+
if (!balanceLoaded) {
|
|
396
|
+
balanceLoaded = true;
|
|
397
|
+
balance = await loadBalance(false);
|
|
398
|
+
fillPanel();
|
|
399
|
+
placePanel();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
chip.addEventListener("click", () => {
|
|
404
|
+
if (panel === null) void openPanel();
|
|
405
|
+
else closePanel();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
render();
|
|
409
|
+
tick = window.setInterval(render, TICK_MS);
|
|
410
|
+
|
|
411
|
+
row.appendChild(chip);
|
|
412
|
+
|
|
413
|
+
return () => {
|
|
414
|
+
window.clearInterval(tick);
|
|
415
|
+
closePanel();
|
|
416
|
+
chip.remove();
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Renderless slot occupant: its only job is to keep the chip attached to the
|
|
422
|
+
* native statistics row, re-attaching when React recreates that row.
|
|
423
|
+
*/
|
|
424
|
+
function BillingMount() {
|
|
425
|
+
react.useEffect(() => {
|
|
426
|
+
let teardown = null;
|
|
427
|
+
let scheduled = false;
|
|
428
|
+
|
|
429
|
+
const attach = () => {
|
|
430
|
+
if (teardown !== null && document.querySelector(".dsh-billing-badge") !== null) return;
|
|
431
|
+
const row = document.querySelector('[data-composer-stats="true"]');
|
|
432
|
+
if (row === null) return;
|
|
433
|
+
if (teardown !== null) teardown();
|
|
434
|
+
teardown = mountChip(row);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const schedule = () => {
|
|
438
|
+
if (scheduled) return;
|
|
439
|
+
scheduled = true;
|
|
440
|
+
window.requestAnimationFrame(() => {
|
|
441
|
+
scheduled = false;
|
|
442
|
+
attach();
|
|
443
|
+
});
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
attach();
|
|
447
|
+
const observer = new MutationObserver(schedule);
|
|
448
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
449
|
+
return () => {
|
|
450
|
+
observer.disconnect();
|
|
451
|
+
if (teardown !== null) teardown();
|
|
452
|
+
};
|
|
453
|
+
}, []);
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const inject = ["slots"];
|
|
458
|
+
|
|
459
|
+
function apply(ctx) {
|
|
460
|
+
ctx.slots.inject("conversation.composer.dock", () =>
|
|
461
|
+
ctx.slots.register(
|
|
462
|
+
{ name: "conversation.composer.dock", id: "billing-badge", order: 10 },
|
|
463
|
+
BillingMount,
|
|
464
|
+
),
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
exports.apply = apply;
|
|
469
|
+
exports.inject = inject;
|
|
470
|
+
exports.__internal = { mountChip, panelBody };
|
|
471
|
+
return module.exports;
|
|
472
|
+
},
|
|
473
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-billing-badge, host half.
|
|
3
|
+
*
|
|
4
|
+
* Serves the DeepSeek account balance to the chip. One read-only route:
|
|
5
|
+
*
|
|
6
|
+
* GET /plugins/billing-badge/balance
|
|
7
|
+
*
|
|
8
|
+
* Design notes:
|
|
9
|
+
* - The API key is resolved from the credentials seam and never leaves this
|
|
10
|
+
* process. The browser receives balance numbers only.
|
|
11
|
+
* - Nothing is written to disk and no other endpoint is touched.
|
|
12
|
+
* - Requests carry a plugin header and a cross-origin caller is rejected. A
|
|
13
|
+
* cross-origin page cannot set a custom header without a preflight the
|
|
14
|
+
* server never grants, which is the same guard the file explorer uses.
|
|
15
|
+
* - Every failure degrades to a state the chip can render, never to a throw.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-billing-badge
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const name = 'billing-badge'
|
|
21
|
+
|
|
22
|
+
/** Web server for the route, credentials for the API key. */
|
|
23
|
+
export const inject = ['webServer', 'credentials']
|
|
24
|
+
|
|
25
|
+
/** Balance endpoint, the only network destination of this plugin. */
|
|
26
|
+
const BALANCE_ENDPOINT = 'https://api.deepseek.com/user/balance'
|
|
27
|
+
|
|
28
|
+
/** Route path. */
|
|
29
|
+
const ROUTE = '/plugins/billing-badge/balance'
|
|
30
|
+
|
|
31
|
+
/** Header every request must carry. */
|
|
32
|
+
const HEADER = 'x-dsh-billing-badge'
|
|
33
|
+
|
|
34
|
+
/** How long a balance reading is reused. */
|
|
35
|
+
const TTL_MS = 60_000
|
|
36
|
+
|
|
37
|
+
/** Request timeout. */
|
|
38
|
+
const TIMEOUT_MS = 15_000
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Shape one balance reading into the response the chip consumes.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} parsed - Parsed API response.
|
|
44
|
+
* @returns {object} Response body.
|
|
45
|
+
*/
|
|
46
|
+
function readBalance(parsed) {
|
|
47
|
+
const info = Array.isArray(parsed?.balance_infos) ? parsed.balance_infos[0] : undefined
|
|
48
|
+
if (info === undefined) {
|
|
49
|
+
return { ok: false, state: 'empty', error: 'the balance response carried no balance_infos entry' }
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
ok: true,
|
|
53
|
+
state: 'ok',
|
|
54
|
+
at: new Date().toISOString(),
|
|
55
|
+
// The API decides the currency. Never assume one: a USD account labelled
|
|
56
|
+
// with a CNY sign is worse than no number at all.
|
|
57
|
+
currency: String(info.currency ?? ''),
|
|
58
|
+
// `is_available` is a TOP-LEVEL field of the response, a sibling of
|
|
59
|
+
// `balance_infos`, not a member of the entry. Reading it off `info` always
|
|
60
|
+
// produced false, so the chip would have cried wolf forever.
|
|
61
|
+
isAvailable: parsed?.is_available === true,
|
|
62
|
+
total: Number(info.total_balance ?? 0),
|
|
63
|
+
granted: Number(info.granted_balance ?? 0),
|
|
64
|
+
toppedUp: Number(info.topped_up_balance ?? 0),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function apply(ctx) {
|
|
69
|
+
let cached = null
|
|
70
|
+
let inflight = null
|
|
71
|
+
|
|
72
|
+
const message = (err) => String((err && err.message) || err)
|
|
73
|
+
|
|
74
|
+
const sameOrigin = (req) => {
|
|
75
|
+
const headers = req.headers ?? {}
|
|
76
|
+
const origin = headers.origin
|
|
77
|
+
if (origin === undefined) return true
|
|
78
|
+
try {
|
|
79
|
+
return new URL(origin).host === String(headers.host ?? '')
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const send = (res, status, body) => {
|
|
86
|
+
res.writeHead(status, {
|
|
87
|
+
'content-type': 'application/json; charset=utf-8',
|
|
88
|
+
'cache-control': 'no-store',
|
|
89
|
+
})
|
|
90
|
+
res.end(JSON.stringify(body))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Resolve the key through the credentials seam, then the process environment. */
|
|
94
|
+
const resolveKey = async () => {
|
|
95
|
+
try {
|
|
96
|
+
const resolved = await ctx.credentials?.resolve?.('DEEPSEEK_API_KEY')
|
|
97
|
+
if (resolved?.value) return resolved.value
|
|
98
|
+
} catch {
|
|
99
|
+
/* fall through to the environment */
|
|
100
|
+
}
|
|
101
|
+
return process.env.DEEPSEEK_API_KEY || undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const load = async () => {
|
|
105
|
+
const key = await resolveKey()
|
|
106
|
+
if (!key) return { ok: false, state: 'no-credential', error: 'DEEPSEEK_API_KEY is not configured' }
|
|
107
|
+
try {
|
|
108
|
+
const response = await fetch(BALANCE_ENDPOINT, {
|
|
109
|
+
headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
|
|
110
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
111
|
+
})
|
|
112
|
+
const text = await response.text()
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
return { ok: false, state: 'error', error: `HTTP ${response.status}: ${text.slice(0, 200)}` }
|
|
115
|
+
}
|
|
116
|
+
return readBalance(JSON.parse(text))
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return { ok: false, state: 'error', error: message(err) }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const balance = async (force) => {
|
|
123
|
+
if (!force && cached !== null && Date.now() - Date.parse(cached.at) < TTL_MS) return cached
|
|
124
|
+
if (inflight !== null) return inflight
|
|
125
|
+
inflight = load()
|
|
126
|
+
.then((result) => {
|
|
127
|
+
cached = result.at === undefined ? { at: new Date().toISOString(), ...result } : result
|
|
128
|
+
return cached
|
|
129
|
+
})
|
|
130
|
+
.catch((err) => ({ ok: false, state: 'error', error: message(err) }))
|
|
131
|
+
.finally(() => {
|
|
132
|
+
inflight = null
|
|
133
|
+
})
|
|
134
|
+
return inflight
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
ctx.inject(['webServer'], (httpCtx) => {
|
|
138
|
+
httpCtx.effect(
|
|
139
|
+
() =>
|
|
140
|
+
httpCtx.webServer.register({
|
|
141
|
+
kind: 'exact',
|
|
142
|
+
path: ROUTE,
|
|
143
|
+
handler: async (req, res) => {
|
|
144
|
+
if (String((req.headers ?? {})[HEADER] ?? '') !== '1') {
|
|
145
|
+
send(res, 403, { ok: false, state: 'forbidden', error: 'missing plugin header' })
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (!sameOrigin(req)) {
|
|
149
|
+
send(res, 403, { ok: false, state: 'forbidden', error: 'cross-origin request rejected' })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
const url = new URL(req.url ?? ROUTE, 'http://x')
|
|
153
|
+
send(res, 200, await balance(url.searchParams.get('refresh') === '1'))
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
'billing-badge: balance route',
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
}
|
package/lib/season.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing season logic for DeepSeek's time-of-day pricing.
|
|
3
|
+
*
|
|
4
|
+
* The rule is fixed and published: peak windows are Beijing time, Monday to
|
|
5
|
+
* Friday, 09:00-12:00 and 14:00-18:00. Everything else, including all of
|
|
6
|
+
* Saturday and Sunday, is off-peak and billed at half the peak price.
|
|
7
|
+
*
|
|
8
|
+
* Timezone handling: the rule is defined in Beijing time (UTC+8), so every
|
|
9
|
+
* calculation shifts the instant by a fixed offset and reads the UTC fields.
|
|
10
|
+
* Local timezones are never used for the decision, only for display.
|
|
11
|
+
*
|
|
12
|
+
* @module season
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Beijing time is UTC+8. */
|
|
16
|
+
export const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/** Peak windows in Beijing time, as [from, to) minutes after midnight. */
|
|
19
|
+
export const PEAK_WINDOWS = [
|
|
20
|
+
[9 * 60, 12 * 60],
|
|
21
|
+
[14 * 60, 18 * 60],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** Candidate boundary minutes: midnight plus every window edge. */
|
|
25
|
+
const BOUNDARY_MINUTES = [0, 9 * 60, 12 * 60, 14 * 60, 18 * 60];
|
|
26
|
+
|
|
27
|
+
/** Milliseconds in a day, for candidate generation. */
|
|
28
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether an instant falls in a peak window (Beijing time, Monday to Friday).
|
|
32
|
+
*
|
|
33
|
+
* Weekend days return false before any window is tested. Comparing only the
|
|
34
|
+
* clock would report a peak window every Saturday and Sunday, six hours of
|
|
35
|
+
* false peak per week, which is the mistake this module exists to avoid.
|
|
36
|
+
*
|
|
37
|
+
* @param {Date} [at] - Instant to test, defaults to now.
|
|
38
|
+
* @returns {boolean} True when the instant is peak.
|
|
39
|
+
*/
|
|
40
|
+
export function isPeak(at = new Date()) {
|
|
41
|
+
const beijing = new Date(at.getTime() + BEIJING_OFFSET_MS);
|
|
42
|
+
const day = beijing.getUTCDay();
|
|
43
|
+
if (day === 0 || day === 6) return false;
|
|
44
|
+
const minutes = beijing.getUTCHours() * 60 + beijing.getUTCMinutes();
|
|
45
|
+
return PEAK_WINDOWS.some(([from, to]) => minutes >= from && minutes < to);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Beijing midnight of the day that contains an instant, as an absolute time.
|
|
50
|
+
*
|
|
51
|
+
* @param {number} timeMs - Instant in milliseconds.
|
|
52
|
+
* @returns {number} The instant of Beijing midnight.
|
|
53
|
+
*/
|
|
54
|
+
function beijingMidnight(timeMs) {
|
|
55
|
+
const beijing = new Date(timeMs + BEIJING_OFFSET_MS);
|
|
56
|
+
return Date.UTC(beijing.getUTCFullYear(), beijing.getUTCMonth(), beijing.getUTCDate()) - BEIJING_OFFSET_MS;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The next instant at which the season actually flips.
|
|
61
|
+
*
|
|
62
|
+
* Candidate boundaries are generated for the next ten days, then each is
|
|
63
|
+
* accepted only when the state one millisecond before it differs from the state
|
|
64
|
+
* at it. That filter is the point: Friday 18:00 ends the peak week, but the
|
|
65
|
+
* following boundary, Saturday 09:00, changes nothing because the whole weekend
|
|
66
|
+
* is already off-peak. Reporting it would show a countdown to an event that
|
|
67
|
+
* does not happen, so the honest answer is Monday 09:00.
|
|
68
|
+
*
|
|
69
|
+
* @param {Date} [at] - Instant to search from, defaults to now.
|
|
70
|
+
* @returns {number|null} Absolute time of the flip, or null when none is found.
|
|
71
|
+
*/
|
|
72
|
+
export function nextFlip(at = new Date()) {
|
|
73
|
+
const from = at.getTime();
|
|
74
|
+
const midnight = beijingMidnight(from);
|
|
75
|
+
const candidates = [];
|
|
76
|
+
for (let day = 0; day <= 10; day += 1) {
|
|
77
|
+
const base = midnight + day * DAY_MS;
|
|
78
|
+
for (const minutes of BOUNDARY_MINUTES) candidates.push(base + minutes * 60 * 1000);
|
|
79
|
+
}
|
|
80
|
+
candidates.sort((a, b) => a - b);
|
|
81
|
+
for (const candidate of candidates) {
|
|
82
|
+
if (candidate <= from) continue;
|
|
83
|
+
if (isPeak(new Date(candidate)) !== isPeak(new Date(candidate - 1))) return candidate;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Remaining time until an instant, as a compact label.
|
|
90
|
+
*
|
|
91
|
+
* @param {number} ms - Milliseconds remaining.
|
|
92
|
+
* @returns {string} `2h13m`, `45m` or `38s`.
|
|
93
|
+
*/
|
|
94
|
+
export function formatCountdown(ms) {
|
|
95
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
96
|
+
const hours = Math.floor(total / 3600);
|
|
97
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
98
|
+
const seconds = total % 60;
|
|
99
|
+
if (hours > 0) return `${hours}h${String(minutes).padStart(2, '0')}m`;
|
|
100
|
+
if (minutes > 0) return `${minutes}m`;
|
|
101
|
+
return `${seconds}s`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Beijing wall clock of an instant, for the popover.
|
|
106
|
+
*
|
|
107
|
+
* @param {Date} [at] - Instant to describe, defaults to now.
|
|
108
|
+
* @returns {{weekday: string, clock: string}} Weekday name and HH:MM.
|
|
109
|
+
*/
|
|
110
|
+
export function beijingClock(at = new Date()) {
|
|
111
|
+
const beijing = new Date(at.getTime() + BEIJING_OFFSET_MS);
|
|
112
|
+
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
113
|
+
const hh = String(beijing.getUTCHours()).padStart(2, '0');
|
|
114
|
+
const mm = String(beijing.getUTCMinutes()).padStart(2, '0');
|
|
115
|
+
return { weekday: weekdays[beijing.getUTCDay()], clock: `${hh}:${mm}` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Everything the chip and its popover need for one instant.
|
|
120
|
+
*
|
|
121
|
+
* @param {Date} [at] - Instant to describe, defaults to now.
|
|
122
|
+
* @returns {object} Phase description.
|
|
123
|
+
*/
|
|
124
|
+
export function describePhase(at = new Date()) {
|
|
125
|
+
const peak = isPeak(at);
|
|
126
|
+
const flip = nextFlip(at);
|
|
127
|
+
const remaining = flip === null ? null : flip - at.getTime();
|
|
128
|
+
const countdown = remaining === null ? '' : formatCountdown(remaining);
|
|
129
|
+
const { weekday, clock } = beijingClock(at);
|
|
130
|
+
const label = peak ? 'Peak' : 'Off-peak';
|
|
131
|
+
const compact = peak ? 'Peak' : 'Off-peak';
|
|
132
|
+
const nextLabel = peak ? 'Off-peak' : 'Peak';
|
|
133
|
+
return {
|
|
134
|
+
peak,
|
|
135
|
+
label,
|
|
136
|
+
compact,
|
|
137
|
+
countdown,
|
|
138
|
+
flipAt: flip,
|
|
139
|
+
remainingMs: remaining,
|
|
140
|
+
nextLabel,
|
|
141
|
+
beijing: { weekday, clock },
|
|
142
|
+
/** Single source of truth for the two state colors, used by chip and popover. */
|
|
143
|
+
color: peak ? '#D9A24A' : '#57C07C',
|
|
144
|
+
title: peak
|
|
145
|
+
? `Peak billing until ${countdown} from now (${nextLabel} after that). Beijing time ${weekday} ${clock}.`
|
|
146
|
+
: `Off-peak billing, half price, for another ${countdown}. Beijing time ${weekday} ${clock}.`,
|
|
147
|
+
};
|
|
148
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-billing-badge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Billing season and account balance chip for the DeepSeek Harness web GUI composer: a native-looking pill after the cache-hit stat, with a panel on click. DeepSeek Harness 计费时段与余额插件",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"lib",
|
|
19
|
+
"cordis.patch.yml",
|
|
20
|
+
"README.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test",
|
|
29
|
+
"sync": "node scripts/inline-season.mjs",
|
|
30
|
+
"check": "node scripts/inline-season.mjs --check && node --check lib/client.js && node --check lib/index.js"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"dsh",
|
|
34
|
+
"dsh-plugin",
|
|
35
|
+
"deepseek-harness",
|
|
36
|
+
"billing",
|
|
37
|
+
"balance",
|
|
38
|
+
"off-peak",
|
|
39
|
+
"composer"
|
|
40
|
+
],
|
|
41
|
+
"homepage": "https://github.com/devacc8/dsh-billing-badge#readme",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/devacc8/dsh-billing-badge.git"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/devacc8/dsh-billing-badge/issues"
|
|
48
|
+
},
|
|
49
|
+
"author": "devacc8",
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"dsh": {
|
|
52
|
+
"bundle": {
|
|
53
|
+
"patch": "./cordis.patch.yml"
|
|
54
|
+
},
|
|
55
|
+
"client": {
|
|
56
|
+
"inject": [
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
58
|
+
],
|
|
59
|
+
"platform": "web"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|