@linxin666/dsh-usage 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +21 -5
- package/README.zh.md +21 -5
- package/lib/client.js +466 -32
- package/lib/client.js.map +1 -1
- package/lib/index.js +434 -23
- package/lib/types/client/UsageSectionCard.d.ts +7 -0
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/core/adapters.d.ts +28 -2
- package/lib/types/core/holidays.d.ts +25 -0
- package/lib/types/core/ledger.d.ts +8 -0
- package/lib/types/core/pricing.d.ts +34 -6
- package/lib/types/core/types.d.ts +26 -5
- package/lib/types/host/routes.d.ts +11 -0
- package/lib/types/host/usage-service.d.ts +13 -1
- package/package.json +14 -14
- package/src/client/UsageSectionCard.tsx +174 -26
- package/src/client/index.ts +10 -2
- package/src/client/locales.ts +16 -0
- package/src/client/usage.module.css +47 -0
- package/src/core/adapters.ts +34 -3
- package/src/core/holidays.ts +248 -0
- package/src/core/ledger.ts +14 -5
- package/src/core/pricing.ts +75 -15
- package/src/core/types.ts +28 -1
- package/src/host/routes.ts +31 -0
- package/src/host/usage-service.ts +145 -10
- package/src/index.ts +3 -3
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/host/run-guarded.ts +0 -76
|
@@ -102,6 +102,50 @@
|
|
|
102
102
|
opacity: 0.6;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/* Day picker: a label plus the native select, sized to the card and
|
|
106
|
+
inheriting the section's currentColor chrome. */
|
|
107
|
+
.dayPicker {
|
|
108
|
+
display: flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
gap: 8px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.dayPickerLabel {
|
|
114
|
+
font-size: 11px;
|
|
115
|
+
opacity: 0.6;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.daySelect {
|
|
119
|
+
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
|
|
120
|
+
border-radius: 6px;
|
|
121
|
+
background: transparent;
|
|
122
|
+
color: inherit;
|
|
123
|
+
font: inherit;
|
|
124
|
+
font-size: 12px;
|
|
125
|
+
padding: 3px 8px;
|
|
126
|
+
font-variant-numeric: tabular-nums;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.daySelect:focus-visible {
|
|
130
|
+
outline: none;
|
|
131
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* A native select paints its option list itself: it does NOT inherit the
|
|
135
|
+
control's background, so an unset option background falls back to the
|
|
136
|
+
browser's own (light) list surface while the option text still inherits the
|
|
137
|
+
section's light text color — light text on a light list, unreadable on a
|
|
138
|
+
dark skin. Both the day picker and the settings-row select therefore give
|
|
139
|
+
their option list one explicit surface/label pair, read from the tokens the
|
|
140
|
+
platform and the skins ship (a skin that omits the surface token falls back
|
|
141
|
+
to the platform's, a host with no theme layer to Canvas). Text and
|
|
142
|
+
background always travel together, per theme half. */
|
|
143
|
+
.daySelect option,
|
|
144
|
+
.settingItem select option {
|
|
145
|
+
background-color: var(--dsw-alias-bg-multi-select, Canvas);
|
|
146
|
+
color: var(--dsw-alias-label-primary, CanvasText);
|
|
147
|
+
}
|
|
148
|
+
|
|
105
149
|
.statRow {
|
|
106
150
|
display: flex;
|
|
107
151
|
flex-wrap: wrap;
|
|
@@ -129,6 +173,9 @@
|
|
|
129
173
|
display: flex;
|
|
130
174
|
align-items: center;
|
|
131
175
|
justify-content: space-between;
|
|
176
|
+
/* A long per-day figure (tokens · calls · cost) drops to its own line in a
|
|
177
|
+
narrow panel instead of running into the provider name. */
|
|
178
|
+
flex-wrap: wrap;
|
|
132
179
|
gap: 12px;
|
|
133
180
|
padding: 6px 0;
|
|
134
181
|
border-top: 1px solid color-mix(in srgb, currentColor 8%, transparent);
|
package/src/core/adapters.ts
CHANGED
|
@@ -48,6 +48,24 @@ export interface PlanParse {
|
|
|
48
48
|
export interface ProviderAdapter {
|
|
49
49
|
/** Provider route keys this adapter serves. */
|
|
50
50
|
ids: readonly string[]
|
|
51
|
+
/**
|
|
52
|
+
* Route keys that belong to this family for accounting (token folding, the
|
|
53
|
+
* spend price book, the whale-yuan bank) but must never be probed with this
|
|
54
|
+
* adapter's endpoints.
|
|
55
|
+
*
|
|
56
|
+
* A route reaches the DeepSeek family without an API key when the user is
|
|
57
|
+
* signed in to a DeepSeek account: the llm-deepseek-account plugin registers
|
|
58
|
+
* the route id deepseek-account, which authenticates with the account token
|
|
59
|
+
* rather than a key. Its tokens bill the same account the official route
|
|
60
|
+
* bills, so the family must own it, but the balance endpoint reads an
|
|
61
|
+
* API-key account: probing it would either fail on the account token or,
|
|
62
|
+
* when the user also configured DEEPSEEK_API_KEY, print the API-key account's
|
|
63
|
+
* money under the account route's name (issue #1772, the same
|
|
64
|
+
* mis-attribution class as #1688). adapterFor() deliberately does not
|
|
65
|
+
* resolve these ids, so no probe, credential fallback, or alias fold can
|
|
66
|
+
* reach them; family membership is read through isDeepSeekProviderRoute().
|
|
67
|
+
*/
|
|
68
|
+
familyOnlyIds?: readonly string[]
|
|
51
69
|
/** Fallback display name when the LLM runtime has none. */
|
|
52
70
|
displayName: string
|
|
53
71
|
balance?: {
|
|
@@ -134,6 +152,10 @@ export const DEEPSEEK_API_ORIGIN = 'https://api.deepseek.com'
|
|
|
134
152
|
|
|
135
153
|
const DEEPSEEK: ProviderAdapter = {
|
|
136
154
|
ids: ['deepseek', 'deepseek-official'],
|
|
155
|
+
// The signed-in DeepSeek account route bills the same account but holds no
|
|
156
|
+
// API key, so it joins the family for accounting and stays out of the probe
|
|
157
|
+
// directory (issue #1772).
|
|
158
|
+
familyOnlyIds: ['deepseek-account'],
|
|
137
159
|
displayName: 'DeepSeek',
|
|
138
160
|
balance: {
|
|
139
161
|
origin: DEEPSEEK_API_ORIGIN,
|
|
@@ -518,11 +540,20 @@ export function balanceAppliesToRoute(adapter: ProviderAdapter, baseURL: string
|
|
|
518
540
|
/**
|
|
519
541
|
* Whether a provider route belongs to the official DeepSeek family: the only
|
|
520
542
|
* family with a spend price book and a settings-section-owned env credential
|
|
521
|
-
* (llm-deepseek) rather than a pi-ai profile. Drives the
|
|
522
|
-
*
|
|
543
|
+
* (llm-deepseek) rather than a pi-ai profile. Drives the fold-time cost
|
|
544
|
+
* stamping, the client fold-time spend estimate, and the whale-yuan bank.
|
|
545
|
+
*
|
|
546
|
+
* Family membership is wider than the probe directory. The account route
|
|
547
|
+
* (deepseek-account) bills the same account but authenticates with the account
|
|
548
|
+
* token, so it prices and mints while `adapterFor()` still returns undefined
|
|
549
|
+
* for it and no probe or env-credential fallback can reach it. Every caller of
|
|
550
|
+
* this predicate is an accounting decision; the credential and probing
|
|
551
|
+
* decisions read `adapterFor()` instead, so the two never widen together
|
|
552
|
+
* (issue #1772).
|
|
523
553
|
*/
|
|
524
554
|
export function isDeepSeekProviderRoute(provider: string): boolean {
|
|
525
|
-
|
|
555
|
+
if (adapterFor(provider) === DEEPSEEK) return true
|
|
556
|
+
return DEEPSEEK.familyOnlyIds?.includes(provider) === true
|
|
526
557
|
}
|
|
527
558
|
|
|
528
559
|
/**
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chinese public-holiday dates, Beijing calendar days, as `YYYY-MM-DD`.
|
|
3
|
+
*
|
|
4
|
+
* The DeepSeek peak rule bills a public holiday off-peak in full, and the
|
|
5
|
+
* State Council publishes these dates per year, so they cannot be derived
|
|
6
|
+
* from a rule. This module is that data; `pricing.ts` keeps the arithmetic.
|
|
7
|
+
*
|
|
8
|
+
* Adjusted workdays are deliberately absent: the provider counts the
|
|
9
|
+
* calendar day, so an adjusted Saturday or Sunday is off-peak exactly like
|
|
10
|
+
* any other weekend and listing it would price it as peak.
|
|
11
|
+
*
|
|
12
|
+
* Source: the yearly schedules in bastengao/chinese-holidays-node
|
|
13
|
+
* (`data/<year>.json`, MIT). Only the `holiday` entries are taken; that
|
|
14
|
+
* package's runtime code (which fetches over the network) and its
|
|
15
|
+
* dependencies are not vendored.
|
|
16
|
+
*
|
|
17
|
+
* Coverage ends at the newest year below. A year with no entry is priced as
|
|
18
|
+
* if it had no holidays, which is the documented default of
|
|
19
|
+
* {@link PublicHolidayCalendar} in `pricing.ts`.
|
|
20
|
+
* @module @linxin666/dsh-usage/core/holidays
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Public-holiday dates keyed by Beijing calendar year. */
|
|
24
|
+
export const CHINESE_PUBLIC_HOLIDAYS: Readonly<Record<string, readonly string[]>> = {
|
|
25
|
+
"2020": [
|
|
26
|
+
"2020-01-24",
|
|
27
|
+
"2020-01-25",
|
|
28
|
+
"2020-01-26",
|
|
29
|
+
"2020-01-27",
|
|
30
|
+
"2020-01-28",
|
|
31
|
+
"2020-01-29",
|
|
32
|
+
"2020-01-30",
|
|
33
|
+
"2020-04-04",
|
|
34
|
+
"2020-04-05",
|
|
35
|
+
"2020-04-06",
|
|
36
|
+
"2020-05-01",
|
|
37
|
+
"2020-05-02",
|
|
38
|
+
"2020-05-03",
|
|
39
|
+
"2020-05-04",
|
|
40
|
+
"2020-05-05",
|
|
41
|
+
"2020-06-25",
|
|
42
|
+
"2020-06-26",
|
|
43
|
+
"2020-06-27",
|
|
44
|
+
"2020-10-01",
|
|
45
|
+
"2020-10-02",
|
|
46
|
+
"2020-10-03",
|
|
47
|
+
"2020-10-04",
|
|
48
|
+
"2020-10-05",
|
|
49
|
+
"2020-10-06",
|
|
50
|
+
"2020-10-07",
|
|
51
|
+
"2020-10-08"
|
|
52
|
+
],
|
|
53
|
+
"2021": [
|
|
54
|
+
"2021-01-01",
|
|
55
|
+
"2021-01-02",
|
|
56
|
+
"2021-01-03",
|
|
57
|
+
"2021-02-11",
|
|
58
|
+
"2021-02-12",
|
|
59
|
+
"2021-02-13",
|
|
60
|
+
"2021-02-14",
|
|
61
|
+
"2021-02-15",
|
|
62
|
+
"2021-02-16",
|
|
63
|
+
"2021-02-17",
|
|
64
|
+
"2021-04-03",
|
|
65
|
+
"2021-04-04",
|
|
66
|
+
"2021-04-05",
|
|
67
|
+
"2021-05-01",
|
|
68
|
+
"2021-05-02",
|
|
69
|
+
"2021-05-03",
|
|
70
|
+
"2021-05-04",
|
|
71
|
+
"2021-05-05",
|
|
72
|
+
"2021-06-12",
|
|
73
|
+
"2021-06-13",
|
|
74
|
+
"2021-06-14",
|
|
75
|
+
"2021-09-19",
|
|
76
|
+
"2021-09-20",
|
|
77
|
+
"2021-09-21",
|
|
78
|
+
"2021-10-01",
|
|
79
|
+
"2021-10-02",
|
|
80
|
+
"2021-10-03",
|
|
81
|
+
"2021-10-04",
|
|
82
|
+
"2021-10-05",
|
|
83
|
+
"2021-10-06",
|
|
84
|
+
"2021-10-07"
|
|
85
|
+
],
|
|
86
|
+
"2022": [
|
|
87
|
+
"2022-01-01",
|
|
88
|
+
"2022-01-02",
|
|
89
|
+
"2022-01-03",
|
|
90
|
+
"2022-01-31",
|
|
91
|
+
"2022-02-01",
|
|
92
|
+
"2022-02-02",
|
|
93
|
+
"2022-02-03",
|
|
94
|
+
"2022-02-04",
|
|
95
|
+
"2022-02-05",
|
|
96
|
+
"2022-02-06",
|
|
97
|
+
"2022-04-03",
|
|
98
|
+
"2022-04-04",
|
|
99
|
+
"2022-04-05",
|
|
100
|
+
"2022-04-30",
|
|
101
|
+
"2022-05-01",
|
|
102
|
+
"2022-05-02",
|
|
103
|
+
"2022-05-03",
|
|
104
|
+
"2022-05-04",
|
|
105
|
+
"2022-06-03",
|
|
106
|
+
"2022-06-04",
|
|
107
|
+
"2022-06-05",
|
|
108
|
+
"2022-09-10",
|
|
109
|
+
"2022-09-11",
|
|
110
|
+
"2022-09-12",
|
|
111
|
+
"2022-10-01",
|
|
112
|
+
"2022-10-02",
|
|
113
|
+
"2022-10-03",
|
|
114
|
+
"2022-10-04",
|
|
115
|
+
"2022-10-05",
|
|
116
|
+
"2022-10-06",
|
|
117
|
+
"2022-10-07"
|
|
118
|
+
],
|
|
119
|
+
"2023": [
|
|
120
|
+
"2022-12-31",
|
|
121
|
+
"2023-01-01",
|
|
122
|
+
"2023-01-02",
|
|
123
|
+
"2023-01-21",
|
|
124
|
+
"2023-01-22",
|
|
125
|
+
"2023-01-23",
|
|
126
|
+
"2023-01-24",
|
|
127
|
+
"2023-01-25",
|
|
128
|
+
"2023-01-26",
|
|
129
|
+
"2023-01-27",
|
|
130
|
+
"2023-04-29",
|
|
131
|
+
"2023-04-30",
|
|
132
|
+
"2023-05-01",
|
|
133
|
+
"2023-05-02",
|
|
134
|
+
"2023-05-03",
|
|
135
|
+
"2023-06-22",
|
|
136
|
+
"2023-06-23",
|
|
137
|
+
"2023-06-24",
|
|
138
|
+
"2023-09-29",
|
|
139
|
+
"2023-09-30",
|
|
140
|
+
"2023-10-01",
|
|
141
|
+
"2023-10-02",
|
|
142
|
+
"2023-10-03",
|
|
143
|
+
"2023-10-04",
|
|
144
|
+
"2023-10-05",
|
|
145
|
+
"2023-10-06"
|
|
146
|
+
],
|
|
147
|
+
"2024": [
|
|
148
|
+
"2023-12-30",
|
|
149
|
+
"2023-12-31",
|
|
150
|
+
"2024-01-01",
|
|
151
|
+
"2024-02-10",
|
|
152
|
+
"2024-02-11",
|
|
153
|
+
"2024-02-12",
|
|
154
|
+
"2024-02-13",
|
|
155
|
+
"2024-02-14",
|
|
156
|
+
"2024-02-15",
|
|
157
|
+
"2024-02-16",
|
|
158
|
+
"2024-02-17",
|
|
159
|
+
"2024-04-04",
|
|
160
|
+
"2024-04-05",
|
|
161
|
+
"2024-04-06",
|
|
162
|
+
"2024-05-01",
|
|
163
|
+
"2024-05-02",
|
|
164
|
+
"2024-05-03",
|
|
165
|
+
"2024-05-04",
|
|
166
|
+
"2024-05-05",
|
|
167
|
+
"2024-06-08",
|
|
168
|
+
"2024-06-09",
|
|
169
|
+
"2024-06-10",
|
|
170
|
+
"2024-09-15",
|
|
171
|
+
"2024-09-16",
|
|
172
|
+
"2024-09-17",
|
|
173
|
+
"2024-10-01",
|
|
174
|
+
"2024-10-02",
|
|
175
|
+
"2024-10-03",
|
|
176
|
+
"2024-10-04",
|
|
177
|
+
"2024-10-05",
|
|
178
|
+
"2024-10-06",
|
|
179
|
+
"2024-10-07"
|
|
180
|
+
],
|
|
181
|
+
"2025": [
|
|
182
|
+
"2025-01-28",
|
|
183
|
+
"2025-01-29",
|
|
184
|
+
"2025-01-30",
|
|
185
|
+
"2025-01-31",
|
|
186
|
+
"2025-02-01",
|
|
187
|
+
"2025-02-02",
|
|
188
|
+
"2025-02-03",
|
|
189
|
+
"2025-02-04",
|
|
190
|
+
"2025-04-04",
|
|
191
|
+
"2025-04-05",
|
|
192
|
+
"2025-04-06",
|
|
193
|
+
"2025-05-01",
|
|
194
|
+
"2025-05-02",
|
|
195
|
+
"2025-05-03",
|
|
196
|
+
"2025-05-04",
|
|
197
|
+
"2025-05-05",
|
|
198
|
+
"2025-05-31",
|
|
199
|
+
"2025-06-01",
|
|
200
|
+
"2025-06-02",
|
|
201
|
+
"2025-10-01",
|
|
202
|
+
"2025-10-02",
|
|
203
|
+
"2025-10-03",
|
|
204
|
+
"2025-10-04",
|
|
205
|
+
"2025-10-05",
|
|
206
|
+
"2025-10-06",
|
|
207
|
+
"2025-10-07",
|
|
208
|
+
"2025-10-08"
|
|
209
|
+
],
|
|
210
|
+
"2026": [
|
|
211
|
+
"2026-01-01",
|
|
212
|
+
"2026-01-02",
|
|
213
|
+
"2026-01-03",
|
|
214
|
+
"2026-02-15",
|
|
215
|
+
"2026-02-16",
|
|
216
|
+
"2026-02-17",
|
|
217
|
+
"2026-02-18",
|
|
218
|
+
"2026-02-19",
|
|
219
|
+
"2026-02-20",
|
|
220
|
+
"2026-02-21",
|
|
221
|
+
"2026-02-22",
|
|
222
|
+
"2026-02-23",
|
|
223
|
+
"2026-04-04",
|
|
224
|
+
"2026-04-05",
|
|
225
|
+
"2026-04-06",
|
|
226
|
+
"2026-05-01",
|
|
227
|
+
"2026-05-02",
|
|
228
|
+
"2026-05-03",
|
|
229
|
+
"2026-05-04",
|
|
230
|
+
"2026-05-05",
|
|
231
|
+
"2026-06-19",
|
|
232
|
+
"2026-06-20",
|
|
233
|
+
"2026-06-21",
|
|
234
|
+
"2026-09-25",
|
|
235
|
+
"2026-09-26",
|
|
236
|
+
"2026-09-27",
|
|
237
|
+
"2026-10-01",
|
|
238
|
+
"2026-10-02",
|
|
239
|
+
"2026-10-03",
|
|
240
|
+
"2026-10-04",
|
|
241
|
+
"2026-10-05",
|
|
242
|
+
"2026-10-06",
|
|
243
|
+
"2026-10-07"
|
|
244
|
+
]
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The newest year this table covers; later years carry no entry. */
|
|
248
|
+
export const HOLIDAY_TABLE_LAST_YEAR = 2026
|
package/src/core/ledger.ts
CHANGED
|
@@ -15,6 +15,19 @@ export function localDateKey(ms: number): string {
|
|
|
15
15
|
return `${date.getFullYear()}-${month}-${day}`
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Whether `key` is a real local-date key (`YYYY-MM-DD`). The shape check is
|
|
20
|
+
* round-tripped: an impossible date (9999-99-99) parses to NaN and a rollover
|
|
21
|
+
* (2026-02-30) lands on another day, so both would otherwise fold into a bogus,
|
|
22
|
+
* never-pruned bucket. Shared by the persisted-document reader and the day
|
|
23
|
+
* route's query gate.
|
|
24
|
+
*/
|
|
25
|
+
export function isLedgerDateKey(key: string): boolean {
|
|
26
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return false
|
|
27
|
+
const atMs = new Date(key + 'T12:00:00').getTime()
|
|
28
|
+
return Number.isFinite(atMs) && localDateKey(atMs) === key
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
/** An empty ledger document. */
|
|
19
32
|
export function createLedgerDocument(): UsageLedgerDocument {
|
|
20
33
|
return { version: 1, days: {} }
|
|
@@ -152,12 +165,8 @@ export function deserializeLedger(value: unknown): UsageLedgerDocument {
|
|
|
152
165
|
const days = (value as Record<string, unknown>).days
|
|
153
166
|
if (typeof days !== 'object' || days === null) return doc
|
|
154
167
|
for (const [dateKey, providers] of Object.entries(days as Record<string, unknown>)) {
|
|
155
|
-
if (
|
|
156
|
-
// Round-trip the date key: an impossible date (9999-99-99) parses to NaN
|
|
157
|
-
// and a rollover (2026-02-30) lands on another day; both would otherwise
|
|
158
|
-
// fold into a bogus, never-pruned bucket.
|
|
168
|
+
if (!isLedgerDateKey(dateKey) || typeof providers !== 'object' || providers === null) continue
|
|
159
169
|
const atMs = new Date(dateKey + 'T12:00:00').getTime()
|
|
160
|
-
if (!Number.isFinite(atMs) || localDateKey(atMs) !== dateKey) continue
|
|
161
170
|
for (const [provider, models] of Object.entries(providers as Record<string, unknown>)) {
|
|
162
171
|
if (typeof models !== 'object' || models === null) continue
|
|
163
172
|
for (const [model, totals] of Object.entries(models as Record<string, unknown>)) {
|
package/src/core/pricing.ts
CHANGED
|
@@ -4,10 +4,19 @@
|
|
|
4
4
|
* at fold time (the provider bills each request in the period the request ran
|
|
5
5
|
* in, so pricing at the fold is the honest estimate).
|
|
6
6
|
*
|
|
7
|
-
* Policy (api-docs.deepseek.com pricing page
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Policy (api-docs.deepseek.com pricing page): peak hours are Beijing time
|
|
8
|
+
* Monday-Friday 09:00-12:00 and 14:00-18:00, excluding Chinese public
|
|
9
|
+
* holidays; every other hour is off-peak and billed at half the peak price.
|
|
10
|
+
* The published rule counts a calendar day, not the working schedule: a
|
|
11
|
+
* Saturday or Sunday is off-peak in full even on an adjusted workday, and a
|
|
12
|
+
* public holiday is off-peak in full even on a Monday-Friday. All prices
|
|
13
|
+
* here are CNY per million tokens.
|
|
14
|
+
*
|
|
15
|
+
* Public-holiday dates are not derivable from the rule, so the rule takes them as
|
|
16
|
+
* a `PublicHolidayCalendar`. The default is the published table in `./holidays.ts`,
|
|
17
|
+
* covering 2020 through ${HOLIDAY_TABLE_LAST_YEAR}; a caller with newer dates
|
|
18
|
+
* passes its own. A holiday weekday therefore prices off-peak, which is the half
|
|
19
|
+
* of the published rule that is easy to get wrong.
|
|
11
20
|
*
|
|
12
21
|
* The published rows are `deepseek-flash` (DeepSeek-V4.1-Flash) and
|
|
13
22
|
* `deepseek-v4-pro` (DeepSeek-V4-Pro-0813). The retired flash ids
|
|
@@ -18,6 +27,7 @@
|
|
|
18
27
|
*/
|
|
19
28
|
|
|
20
29
|
import type { UsageTokenTotals } from './types.ts'
|
|
30
|
+
import { CHINESE_PUBLIC_HOLIDAYS, HOLIDAY_TABLE_LAST_YEAR } from './holidays.ts'
|
|
21
31
|
|
|
22
32
|
/** One peak window in minutes-of-day, Beijing time. */
|
|
23
33
|
interface PeakWindow {
|
|
@@ -71,32 +81,82 @@ function withinWindow(minuteOfDay: number): PeakWindow | undefined {
|
|
|
71
81
|
return PEAK_WINDOWS.find((window) => minuteOfDay >= window.from && minuteOfDay < window.to)
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Supplies the Chinese public-holiday dates the peak rule excludes. The dates
|
|
86
|
+
* are not derivable from any rule (the State Council publishes them per year),
|
|
87
|
+
* so this repository ships no table of its own: the default calendar is empty,
|
|
88
|
+
* and a caller that has real dates passes them in. A date is compared as a
|
|
89
|
+
* Beijing-time `YYYY-MM-DD` string, which is the granularity the published
|
|
90
|
+
* rule needs (a holiday is off-peak for the whole day, so times never matter).
|
|
91
|
+
*/
|
|
92
|
+
export interface PublicHolidayCalendar {
|
|
93
|
+
/** True when the Beijing-time calendar day `date` is a Chinese public holiday. */
|
|
94
|
+
isPublicHoliday(date: string): boolean
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The default calendar: the published Chinese public-holiday table, so a holiday
|
|
99
|
+
* weekday prices off-peak instead of over-reporting at the peak rate. A year the
|
|
100
|
+
* table does not cover holds no dates and falls back to peak on weekdays, which
|
|
101
|
+
* is the safe direction to be wrong in: the estimate reads high rather than low.
|
|
102
|
+
*/
|
|
103
|
+
export const CN_PUBLIC_HOLIDAYS: PublicHolidayCalendar = {
|
|
104
|
+
isPublicHoliday: (date) => {
|
|
105
|
+
const year = date.slice(0, 4)
|
|
106
|
+
if (Number(year) > HOLIDAY_TABLE_LAST_YEAR) return false
|
|
107
|
+
return CHINESE_PUBLIC_HOLIDAYS[year]?.includes(date) ?? false
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The Beijing-time `YYYY-MM-DD` calendar day of `ms` (UTC+8 has no DST, so the shift is exact). */
|
|
112
|
+
function beijingDate(ms: number): string {
|
|
113
|
+
const shifted = new Date(ms + BEIJING_UTC_OFFSET_MS)
|
|
114
|
+
const month = `${shifted.getUTCMonth() + 1}`.padStart(2, '0')
|
|
115
|
+
const day = `${shifted.getUTCDate()}`.padStart(2, '0')
|
|
116
|
+
return `${shifted.getUTCFullYear()}-${month}-${day}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether `ms` falls on a peak-eligible Beijing day: Monday-Friday that is
|
|
121
|
+
* not a public holiday. A Saturday or Sunday is never eligible, including on
|
|
122
|
+
* an adjusted workday — the provider counts the calendar day, not the
|
|
123
|
+
* working schedule.
|
|
124
|
+
*/
|
|
125
|
+
function isPeakDay(ms: number, calendar: PublicHolidayCalendar): boolean {
|
|
126
|
+
const shifted = new Date(ms + BEIJING_UTC_OFFSET_MS)
|
|
127
|
+
const weekday = shifted.getUTCDay()
|
|
128
|
+
if (weekday < 1 || weekday > 5) return false
|
|
129
|
+
return !calendar.isPublicHoliday(beijingDate(ms))
|
|
130
|
+
}
|
|
131
|
+
|
|
74
132
|
/**
|
|
75
133
|
* The DeepSeek billing period at `ms`, plus when it next flips. The clock is
|
|
76
134
|
* Beijing time regardless of the host timezone (UTC+8 has no DST, so a fixed
|
|
77
135
|
* shift is exact). `boundaryMs` is the instant the current period ends — the
|
|
78
136
|
* window's close while peaking, the next window's open otherwise.
|
|
79
137
|
*/
|
|
80
|
-
export function deepseekPeriodAt(ms: number): { peak: boolean; boundaryMs: number } {
|
|
138
|
+
export function deepseekPeriodAt(ms: number, calendar: PublicHolidayCalendar = CN_PUBLIC_HOLIDAYS): { peak: boolean; boundaryMs: number } {
|
|
81
139
|
const shifted = new Date(ms + BEIJING_UTC_OFFSET_MS)
|
|
82
|
-
const weekday = shifted.getUTCDay()
|
|
83
140
|
const minuteOfDay = shifted.getUTCHours() * 60 + shifted.getUTCMinutes()
|
|
84
|
-
const current =
|
|
141
|
+
const current = isPeakDay(ms, calendar) ? withinWindow(minuteOfDay) : undefined
|
|
85
142
|
if (current !== undefined) {
|
|
86
143
|
return { peak: true, boundaryMs: ms + (current.to - minuteOfDay) * 60_000 - shifted.getUTCSeconds() * 1000 - shifted.getUTCMilliseconds() }
|
|
87
144
|
}
|
|
88
|
-
// Next window start: later today (
|
|
89
|
-
//
|
|
145
|
+
// Next window start: later today (on a peak-eligible day), else the first
|
|
146
|
+
// peak-eligible day's morning window; the scan bound makes a malformed clock
|
|
147
|
+
// terminate.
|
|
90
148
|
for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
149
|
+
const dayStartMs = ms + dayOffset * 86_400_000
|
|
150
|
+
if (!isPeakDay(dayStartMs, calendar)) continue
|
|
151
|
+
const day = new Date(dayStartMs + BEIJING_UTC_OFFSET_MS)
|
|
93
152
|
const realDayStart = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()) - BEIJING_UTC_OFFSET_MS
|
|
94
153
|
for (const window of PEAK_WINDOWS) {
|
|
95
154
|
if (dayOffset === 0 && window.from <= minuteOfDay) continue
|
|
96
155
|
return { peak: false, boundaryMs: realDayStart + window.from * 60_000 }
|
|
97
156
|
}
|
|
98
157
|
}
|
|
99
|
-
// Unreachable (the scan covers a full week); a
|
|
158
|
+
// Unreachable (the scan covers a full week of peak-eligible days); a
|
|
159
|
+
// conservative off-peak answer.
|
|
100
160
|
return { peak: false, boundaryMs: ms + 86_400_000 }
|
|
101
161
|
}
|
|
102
162
|
|
|
@@ -112,12 +172,12 @@ function priceFor(model: string, atMs: number): ModelPrice {
|
|
|
112
172
|
* row; ids from other providers never reach this function (the service gates
|
|
113
173
|
* by route family). Rounded to micro-CNY so the ledger stays readable.
|
|
114
174
|
*/
|
|
115
|
-
export function deepseekModelSpend(model: string, totals: Readonly<UsageTokenTotals>, atMs: number): number {
|
|
175
|
+
export function deepseekModelSpend(model: string, totals: Readonly<UsageTokenTotals>, atMs: number, calendar: PublicHolidayCalendar = CN_PUBLIC_HOLIDAYS): number {
|
|
116
176
|
const price = priceFor(model, atMs)
|
|
117
|
-
const period = deepseekPeriodAt(atMs)
|
|
177
|
+
const period = deepseekPeriodAt(atMs, calendar)
|
|
118
178
|
const column = period.peak ? 'peak' : 'offPeak'
|
|
119
179
|
const spend = (totals.cacheReadTokens * price.cacheHit[column]
|
|
120
180
|
+ (totals.inputTokens + totals.cacheWriteTokens) * price.inputMiss[column]
|
|
121
181
|
+ totals.outputTokens * price.output[column]) / 1_000_000
|
|
122
182
|
return Math.round(spend * 1e6) / 1e6
|
|
123
|
-
}
|
|
183
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -67,6 +67,26 @@ export interface UsageDaySummary {
|
|
|
67
67
|
totals: UsageTokenTotals
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* One local day aggregated per provider and model. The overview serves today
|
|
72
|
+
* through this shape and the day route serves any retained day with it, so a
|
|
73
|
+
* day the user picks in the browser and the live today card are the same
|
|
74
|
+
* document.
|
|
75
|
+
*/
|
|
76
|
+
export interface UsageDayView {
|
|
77
|
+
/** Local-date key `YYYY-MM-DD`. */
|
|
78
|
+
date: string
|
|
79
|
+
totals: UsageTokenTotals
|
|
80
|
+
/** Per-provider rows, heaviest by total tokens first. */
|
|
81
|
+
providers: UsageProviderSummary[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The day route's response body. */
|
|
85
|
+
export interface UsageDayResponse {
|
|
86
|
+
ok: true
|
|
87
|
+
day: UsageDayView
|
|
88
|
+
}
|
|
89
|
+
|
|
70
90
|
/** A balance fact normalized from a provider probe. */
|
|
71
91
|
export interface BalanceView {
|
|
72
92
|
/** ISO 4217 code the provider bills in (`CNY`, `USD`, ...). */
|
|
@@ -161,9 +181,16 @@ export interface UsageOverviewView {
|
|
|
161
181
|
today?: UsageTokenTotals
|
|
162
182
|
}
|
|
163
183
|
usage: {
|
|
164
|
-
today:
|
|
184
|
+
today: UsageDayView
|
|
165
185
|
/** Last N local days ascending, including today. */
|
|
166
186
|
days: UsageDaySummary[]
|
|
187
|
+
/**
|
|
188
|
+
* Every retained local day that recorded usage, ascending — the day
|
|
189
|
+
* picker's option list, reaching past the trend cap to the whole
|
|
190
|
+
* retention window. Optional so an older host document still renders
|
|
191
|
+
* (the section then offers no picker).
|
|
192
|
+
*/
|
|
193
|
+
availableDays?: string[]
|
|
167
194
|
/**
|
|
168
195
|
* The same window aggregated per provider and model — the trend card's
|
|
169
196
|
* bar-chart data. Optional so an older host document still renders.
|
package/src/host/routes.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
3
3
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
4
4
|
import { isUsageAllowed } from './access.ts'
|
|
5
5
|
import { writeJson } from './http.ts'
|
|
6
|
+
import { isLedgerDateKey } from '../core/ledger.ts'
|
|
7
|
+
import type { UsageDayResponse } from '../core/types.ts'
|
|
6
8
|
import type { UsageService } from './usage-service.ts'
|
|
7
9
|
|
|
8
10
|
export const USAGE_API_PREFIX = '/api/dsh-usage'
|
|
@@ -31,6 +33,35 @@ export function makeUsageOverviewRoute(ctx: Context, service: UsageService): Web
|
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* One retained day route: `GET /api/dsh-usage/day?date=YYYY-MM-DD` answers
|
|
38
|
+
* that local day's per-provider and per-model totals, so the section's day
|
|
39
|
+
* pickers can show a past day instead of only today. Same trust fence as the
|
|
40
|
+
* overview route, and a key that is not a real local date is refused with 400
|
|
41
|
+
* rather than folded into a bogus day.
|
|
42
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
43
|
+
* @param service - the usage service.
|
|
44
|
+
* @returns the route.
|
|
45
|
+
*/
|
|
46
|
+
export function makeUsageDayRoute(ctx: Context, service: UsageService): WebRoute {
|
|
47
|
+
return {
|
|
48
|
+
kind: 'exact',
|
|
49
|
+
path: USAGE_API_PREFIX + '/day',
|
|
50
|
+
handler: (req: IncomingMessage, res: ServerResponse): void => {
|
|
51
|
+
if (!isUsageAllowed(ctx, req)) {
|
|
52
|
+
writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const date = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('date') ?? ''
|
|
56
|
+
if (!isLedgerDateKey(date)) {
|
|
57
|
+
writeJson(res, 400, { ok: false, error: 'date must be YYYY-MM-DD' })
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
writeJson(res, 200, { ok: true, day: service.day(date) } satisfies UsageDayResponse, { 'cache-control': 'no-store' })
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
34
65
|
/**
|
|
35
66
|
* Manual refresh: forces one probe cycle now and answers with the fresh
|
|
36
67
|
* overview. Same trust fence as the overview route.
|