dsh-usage-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/LICENSE +21 -0
- package/README.md +89 -0
- package/assets/usage-dialog.png +0 -0
- package/cordis.patch.yml +7 -0
- package/docs/design-notes.md +293 -0
- package/lib/client.js +1007 -0
- package/lib/fold.js +512 -0
- package/lib/holidays-cn.json +41 -0
- package/lib/holidays.js +141 -0
- package/lib/index.js +543 -0
- package/lib/official-pricing.js +316 -0
- package/lib/pricing.js +485 -0
- package/package.json +63 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-badge — DeepSeek's published price list.
|
|
3
|
+
*
|
|
4
|
+
* The official pricing page is Docusaurus server-rendered HTML, so it can be read
|
|
5
|
+
* without a browser: one `<table>` holds the model columns and the price rows, and
|
|
6
|
+
* the footnotes carry the policy that the price table alone does not express
|
|
7
|
+
* (peak windows, their time zone, and that holidays are off-peak all day).
|
|
8
|
+
*
|
|
9
|
+
* Parsing a page we do not control is inherently brittle — the shape is asserted
|
|
10
|
+
* and a mismatch raises a descriptive error rather than silently returning wrong
|
|
11
|
+
* prices. `test/fixtures/` keeps the captured pages this parser is verified
|
|
12
|
+
* against, so a change in the page shows up as a failing test.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-usage-badge/official-pricing
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The two published pages; the Chinese one quotes CNY and the English one USD. */
|
|
18
|
+
export const OFFICIAL_SOURCES = {
|
|
19
|
+
'zh-cn': {
|
|
20
|
+
url: 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing',
|
|
21
|
+
currency: 'cny',
|
|
22
|
+
label: '中文页(人民币)',
|
|
23
|
+
},
|
|
24
|
+
en: {
|
|
25
|
+
url: 'https://api-docs.deepseek.com/quick_start/pricing',
|
|
26
|
+
currency: 'usd',
|
|
27
|
+
label: 'English page (USD)',
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fetch timeout, so a hung docs site cannot hold a request open. */
|
|
32
|
+
const FETCH_TIMEOUT_MS = 15000
|
|
33
|
+
|
|
34
|
+
const decodeEntities = (text) =>
|
|
35
|
+
text
|
|
36
|
+
.replace(/ /g, ' ')
|
|
37
|
+
.replace(/&/g, '&')
|
|
38
|
+
.replace(/</g, '<')
|
|
39
|
+
.replace(/>/g, '>')
|
|
40
|
+
.replace(/"/g, '"')
|
|
41
|
+
.replace(/'|'/g, "'")
|
|
42
|
+
|
|
43
|
+
const stripTags = (html) => decodeEntities(html.replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()
|
|
44
|
+
|
|
45
|
+
/** One `<tr>`'s cells, in document order. */
|
|
46
|
+
function parseRows(tableHtml) {
|
|
47
|
+
return tableHtml
|
|
48
|
+
.split(/<tr[^>]*>/i)
|
|
49
|
+
.slice(1)
|
|
50
|
+
.map((row) =>
|
|
51
|
+
row
|
|
52
|
+
.split(/<\/tr>/i)[0]
|
|
53
|
+
.split(/<td[^>]*>/i)
|
|
54
|
+
.slice(1)
|
|
55
|
+
.map((cell) => ({
|
|
56
|
+
html: cell.split(/<\/td>/i)[0],
|
|
57
|
+
text: stripTags(cell.split(/<\/td>/i)[0]),
|
|
58
|
+
}))
|
|
59
|
+
.filter((cell) => cell.text !== '' || cell.html !== ''),
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isPeriod = (text) => /空闲时段|高峰时段|off-?peak|^peak$/i.test(text)
|
|
64
|
+
const periodOf = (text) => (/空闲时段|off-?peak/i.test(text) ? 'offPeak' : 'peak')
|
|
65
|
+
|
|
66
|
+
/** Which token bucket a price row names, or null when the row is not a price row. */
|
|
67
|
+
function priceKindOf(text) {
|
|
68
|
+
if (/缓存命中|cache\s*hit/i.test(text)) return 'cacheRead'
|
|
69
|
+
if (/缓存未命中|cache\s*miss/i.test(text)) return 'input'
|
|
70
|
+
if (/输出|output/i.test(text)) return 'output'
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A price cell like `0.02元` or `$0.003` → its number and the currency it names. */
|
|
75
|
+
function parseAmount(text) {
|
|
76
|
+
const match = /(\d+(?:\.\d+)?)/.exec(text)
|
|
77
|
+
if (!match) return null
|
|
78
|
+
const currency = /元|¥|¥|人民币/.test(text) ? 'cny' : /\$|USD/i.test(text) ? 'usd' : null
|
|
79
|
+
return { value: Number(match[1]), currency }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Shift published peak windows into the host's local hours.
|
|
84
|
+
*
|
|
85
|
+
* The Chinese page quotes 北京时间 and the English page quotes UTC for the same
|
|
86
|
+
* windows, so the printed hours are only meaningful together with the zone. Since
|
|
87
|
+
* the pricing config expresses `peakRanges` in local hours, the windows are
|
|
88
|
+
* converted here and both forms are reported, letting the UI show "official
|
|
89
|
+
* 01:00–04:00 UTC (≈ local 09:00–12:00)" instead of hiding the conversion.
|
|
90
|
+
*/
|
|
91
|
+
function toLocalRanges(ranges, sourceOffsetMinutes, localOffsetMinutes) {
|
|
92
|
+
const delta = localOffsetMinutes - sourceOffsetMinutes
|
|
93
|
+
const round = (value) => Math.round(value * 100) / 100
|
|
94
|
+
const out = []
|
|
95
|
+
for (const [start, end] of ranges) {
|
|
96
|
+
const startMinutes = start * 60 + delta
|
|
97
|
+
const endMinutes = end * 60 + delta
|
|
98
|
+
if (startMinutes >= 0 && endMinutes <= 1440) {
|
|
99
|
+
out.push([round(startMinutes / 60), round(endMinutes / 60)])
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
// The shifted window crosses midnight, so it becomes two ranges.
|
|
103
|
+
const wrap = (minutes) => ((minutes % 1440) + 1440) % 1440
|
|
104
|
+
out.push([round(wrap(startMinutes) / 60), 24])
|
|
105
|
+
out.push([0, round(wrap(endMinutes) / 60)])
|
|
106
|
+
}
|
|
107
|
+
return out.sort((a, b) => a[0] - b[0])
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse one captured or freshly fetched pricing page.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} html - the page body.
|
|
114
|
+
* @param {{url?:string, now?:number, localOffsetMinutes?:number}} [options]
|
|
115
|
+
* @returns {object} the parsed prices and policy.
|
|
116
|
+
* @throws {Error} when the page no longer has the expected shape.
|
|
117
|
+
*/
|
|
118
|
+
export function parseOfficialPricing(html, options = {}) {
|
|
119
|
+
const url = options.url ?? OFFICIAL_SOURCES['zh-cn'].url
|
|
120
|
+
const now = options.now ?? Date.now()
|
|
121
|
+
const localOffsetMinutes = options.localOffsetMinutes ?? -new Date(now).getTimezoneOffset()
|
|
122
|
+
|
|
123
|
+
const tables = html.match(/<table[^>]*>[\s\S]*?<\/table>/gi) ?? []
|
|
124
|
+
const table = tables.find((candidate) => /价格|PRICING/i.test(stripTags(candidate)))
|
|
125
|
+
if (!table) throw new Error('the pricing table was not found on the page (layout changed?)')
|
|
126
|
+
|
|
127
|
+
const rows = parseRows(table)
|
|
128
|
+
const modelRow = rows.find((cells) => /^(模型|MODEL)$/i.test(cells[0]?.text ?? ''))
|
|
129
|
+
if (!modelRow) throw new Error('the model header row was not found in the pricing table (layout changed?)')
|
|
130
|
+
|
|
131
|
+
// The first model may carry a `(1)` marker naming its legacy aliases.
|
|
132
|
+
const models = []
|
|
133
|
+
const footnotedModels = new Map()
|
|
134
|
+
for (const [index, cell] of modelRow.slice(1).entries()) {
|
|
135
|
+
const marker = /\((\d+)\)/.exec(cell.text)
|
|
136
|
+
models.push({ model: cell.text.replace(/\(\d+\)/g, '').trim(), peak: {}, offPeak: {}, cacheWritePerMillion: 0 })
|
|
137
|
+
if (marker) footnotedModels.set(Number(marker[1]), index)
|
|
138
|
+
}
|
|
139
|
+
if (models.length === 0 || models.some((entry) => !entry.model)) {
|
|
140
|
+
throw new Error('the pricing table listed no model columns (layout changed?)')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let currency = null
|
|
144
|
+
let kind = null
|
|
145
|
+
let priceRows = 0
|
|
146
|
+
for (const cells of rows) {
|
|
147
|
+
// The period cell is located rather than assumed: the first price row carries
|
|
148
|
+
// an extra leading `rowspan` cell ("价格"/"PRICING"), while the continuation
|
|
149
|
+
// rows below it start straight at the period. Everything after the period cell
|
|
150
|
+
// is a per-model value, and the cell just before it names the token bucket
|
|
151
|
+
// when this row opens a new one.
|
|
152
|
+
const periodIndex = cells.findIndex((cell) => isPeriod(cell.text))
|
|
153
|
+
if (periodIndex === -1) continue
|
|
154
|
+
if (periodIndex >= 1) {
|
|
155
|
+
const named = priceKindOf(cells[periodIndex - 1].text)
|
|
156
|
+
if (named) kind = named
|
|
157
|
+
}
|
|
158
|
+
if (!kind) continue
|
|
159
|
+
const values = cells.slice(periodIndex + 1)
|
|
160
|
+
if (values.length < models.length) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`a price row listed ${values.length} value(s) for ${models.length} model(s) (layout changed?)`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
for (const [index, model] of models.entries()) {
|
|
166
|
+
const amount = parseAmount(values[index].text)
|
|
167
|
+
if (!amount) continue
|
|
168
|
+
model[periodOf(cells[periodIndex].text)][kind] = amount.value
|
|
169
|
+
if (amount.currency) currency = amount.currency
|
|
170
|
+
}
|
|
171
|
+
priceRows++
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (priceRows === 0) throw new Error('no price rows could be read from the pricing table (layout changed?)')
|
|
175
|
+
|
|
176
|
+
for (const model of models) {
|
|
177
|
+
for (const period of ['peak', 'offPeak']) {
|
|
178
|
+
for (const key of ['input', 'cacheRead', 'cacheWrite', 'output']) {
|
|
179
|
+
if (model[period][key] === undefined) model[period][key] = 0
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── policy footnotes ───────────────────────────────────────────────────────
|
|
185
|
+
const footnotes = new Map()
|
|
186
|
+
for (const match of html.matchAll(/<p>\s*\((\d+)\)([\s\S]*?)<\/p>/gi)) {
|
|
187
|
+
footnotes.set(Number(match[1]), match[2])
|
|
188
|
+
}
|
|
189
|
+
const policyHtml = [...footnotes.entries()].map(([, body]) => body).find((body) => /高峰时段|Peak hours/i.test(body))
|
|
190
|
+
if (!policyHtml) throw new Error('the peak-hours footnote was not found on the page (layout changed?)')
|
|
191
|
+
const policyText = stripTags(policyHtml)
|
|
192
|
+
|
|
193
|
+
const ranges = []
|
|
194
|
+
for (const match of policyText.matchAll(/(\d{1,2}):(\d{2})\s*[-–—~至]\s*(\d{1,2}):(\d{2})/g)) {
|
|
195
|
+
ranges.push([Number(match[1]) + Number(match[2]) / 60, Number(match[3]) + Number(match[4]) / 60])
|
|
196
|
+
}
|
|
197
|
+
if (ranges.length === 0) throw new Error('no peak-hour windows could be read from the footnote (layout changed?)')
|
|
198
|
+
|
|
199
|
+
const sourceZone = /北京时间/.test(policyText) ? { name: 'Asia/Shanghai', offsetMinutes: 480 } : /UTC/.test(policyText) ? { name: 'UTC', offsetMinutes: 0 } : null
|
|
200
|
+
|
|
201
|
+
// Footnote (1) names the retired model ids that are still served and billed at
|
|
202
|
+
// the marked model's price, so they get their own override rows.
|
|
203
|
+
const [markedFootnote, markedIndex] = footnotedModels.entries().next().value ?? []
|
|
204
|
+
const aliases = []
|
|
205
|
+
if (markedFootnote !== undefined) {
|
|
206
|
+
const footnoteHtml = footnotes.get(markedFootnote) ?? ''
|
|
207
|
+
if (/legacy|旧模型名|已下线/i.test(stripTags(footnoteHtml))) {
|
|
208
|
+
for (const match of footnoteHtml.matchAll(/<code>([^<]+)<\/code>/g)) {
|
|
209
|
+
const name = decodeEntities(match[1]).trim()
|
|
210
|
+
if (name && !models.some((model) => model.model === name)) aliases.push(name)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const aliasTarget = models[markedIndex ?? 0].model
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
url,
|
|
219
|
+
fetchedAt: now,
|
|
220
|
+
currency: currency ?? OFFICIAL_SOURCES['zh-cn'].currency,
|
|
221
|
+
models,
|
|
222
|
+
aliases: { target: aliasTarget, names: aliases },
|
|
223
|
+
policy: {
|
|
224
|
+
text: policyText,
|
|
225
|
+
peakRangesSource: ranges,
|
|
226
|
+
sourceZone,
|
|
227
|
+
// Converted into the host's local hours, which is what `timeOfUse.peakRanges` means.
|
|
228
|
+
peakRangesLocal: toLocalRanges(ranges, sourceZone?.offsetMinutes ?? localOffsetMinutes, localOffsetMinutes),
|
|
229
|
+
localOffsetMinutes,
|
|
230
|
+
offPeakIsHalfOfPeak: /一半|half of the peak/i.test(policyText),
|
|
231
|
+
weekendsOffPeak: /周末|weekends/i.test(policyText),
|
|
232
|
+
holidaysOffPeak: /法定节假日|public holidays/i.test(policyText),
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Fetch and parse a published pricing page.
|
|
239
|
+
*
|
|
240
|
+
* @param {{source?:'zh-cn'|'en', url?:string, now?:number, fetchImpl?:Function}} [options]
|
|
241
|
+
* @returns {Promise<object>} the parsed result.
|
|
242
|
+
*/
|
|
243
|
+
export async function fetchOfficialPricing(options = {}) {
|
|
244
|
+
const source = OFFICIAL_SOURCES[options.source] ?? OFFICIAL_SOURCES['zh-cn']
|
|
245
|
+
const url = options.url ?? source.url
|
|
246
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
247
|
+
const response = await doFetch(url, {
|
|
248
|
+
headers: { 'user-agent': 'dsh-usage-badge', accept: 'text/html' },
|
|
249
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
250
|
+
})
|
|
251
|
+
if (!response.ok) throw new Error(`the pricing page answered HTTP ${response.status}`)
|
|
252
|
+
const html = await response.text()
|
|
253
|
+
return parseOfficialPricing(html, { url, now: options.now })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Build the pricing document that applying an official price list produces.
|
|
258
|
+
*
|
|
259
|
+
* The row price is the **off-peak** rate and the peak multiplier is 2, which is
|
|
260
|
+
* the same shape the desktop shell already used and follows the page's own rule
|
|
261
|
+
* that off-peak is half of peak. Applying also switches the holiday calendar on,
|
|
262
|
+
* because the published peak windows are defined as excluding Chinese public
|
|
263
|
+
* holidays — without it a weekday holiday would be billed at peak.
|
|
264
|
+
*
|
|
265
|
+
* Unknown keys in the existing document survive, so hand-written rows elsewhere
|
|
266
|
+
* are not lost.
|
|
267
|
+
*
|
|
268
|
+
* @param {object} current - the raw user pricing document.
|
|
269
|
+
* @param {object} parsed - a {@link parseOfficialPricing} result.
|
|
270
|
+
* @returns {{document:object, applied:{models:string[], aliases:string[], peakRanges:number[][]}}}
|
|
271
|
+
*/
|
|
272
|
+
export function applyOfficialPricing(current, parsed) {
|
|
273
|
+
const next = current && typeof current === 'object' ? { ...current } : {}
|
|
274
|
+
const overrides = { ...(next.overrides ?? {}) }
|
|
275
|
+
const applied = []
|
|
276
|
+
|
|
277
|
+
const rowFor = (base) => ({
|
|
278
|
+
inputPerMillion: base.offPeak.input,
|
|
279
|
+
cacheReadPerMillion: base.offPeak.cacheRead,
|
|
280
|
+
cacheWritePerMillion: base.offPeak.cacheWrite ?? 0,
|
|
281
|
+
outputPerMillion: base.offPeak.output,
|
|
282
|
+
currency: parsed.currency,
|
|
283
|
+
multiplier: 1,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
for (const entry of parsed.models) {
|
|
287
|
+
overrides[entry.model] = { ...(overrides[entry.model] ?? {}), ...rowFor(entry) }
|
|
288
|
+
applied.push(entry.model)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Retired ids the page says are still served and billed at the same price.
|
|
292
|
+
const aliasNames = []
|
|
293
|
+
const target = parsed.models.find((entry) => entry.model === parsed.aliases.target) ?? parsed.models[0]
|
|
294
|
+
for (const name of parsed.aliases.names ?? []) {
|
|
295
|
+
overrides[name] = { ...(overrides[name] ?? {}), ...rowFor(target) }
|
|
296
|
+
aliasNames.push(name)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
next.overrides = overrides
|
|
300
|
+
next.timeOfUse = {
|
|
301
|
+
...(next.timeOfUse ?? {}),
|
|
302
|
+
enabled: true,
|
|
303
|
+
days: 'weekday',
|
|
304
|
+
peakMultiplier: 2,
|
|
305
|
+
valleyMultiplier: 1,
|
|
306
|
+
peakRanges: parsed.policy.peakRangesLocal,
|
|
307
|
+
}
|
|
308
|
+
if (parsed.policy.holidaysOffPeak) {
|
|
309
|
+
next.holidays = { ...(next.holidays ?? {}), source: 'cn' }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
document: next,
|
|
314
|
+
applied: { models: applied, aliases: aliasNames, peakRanges: parsed.policy.peakRangesLocal },
|
|
315
|
+
}
|
|
316
|
+
}
|