@linxin666/dsh-usage 0.3.20 → 0.3.21

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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The Whale-yuan voucher (鲸元券): the DeepSeek official family's retained
3
+ * ledger totals stamped onto the banknote artwork as a denomination, ready
4
+ * to save or share. The canvas copy is deliberately locale-neutral (digits,
5
+ * latin captions, ISO dates) so the exported image needs no dictionary;
6
+ * everything user-facing around it lives in the section's locales. The pure
7
+ * helpers are unit-tested; the draw itself degrades to a thrown error the
8
+ * section renders as its failure line.
9
+ * @module @linxin666/dsh-usage/client/voucher
10
+ */
11
+
12
+ import { isDeepSeekProviderRoute } from '../core/adapters.ts'
13
+ import { totalTokens } from '../core/ledger.ts'
14
+ import type { UsageWindowSummary } from '../core/types.ts'
15
+ import { VOUCHER_ART_DATA_URL } from './voucher-art.generated.ts'
16
+
17
+ /** The facts stamped onto one voucher. */
18
+ export interface VoucherData {
19
+ /** DeepSeek official family total tokens over the retained window. */
20
+ tokens: number
21
+ /** Provider calls that reported usage. */
22
+ calls: number
23
+ /** Fold-time spend estimate in CNY (the only priced family). */
24
+ cost: number
25
+ /** Retained window bounds (local date keys, inclusive). */
26
+ from: string
27
+ to: string
28
+ }
29
+
30
+ /** Anti-inflation exchange rate: 1000 tokens mint one whale yuan. */
31
+ export const TOKENS_PER_WHALE_YUAN = 1000
32
+
33
+ /** The note's face value in whale yuan; the smallest denomination is 1. */
34
+ export function faceValue(tokens: number): number {
35
+ return Math.max(1, Math.round(tokens / TOKENS_PER_WHALE_YUAN))
36
+ }
37
+
38
+ /** Local calendar day (`YYYY-MM-DD`) for an epoch ms timestamp. */
39
+ export function formatDay(ms: number): string {
40
+ const date = new Date(ms)
41
+ const month = String(date.getMonth() + 1).padStart(2, '0')
42
+ const day = String(date.getDate()).padStart(2, '0')
43
+ return `${date.getFullYear()}-${month}-${day}`
44
+ }
45
+
46
+ /**
47
+ * Sum the DeepSeek official family rows out of a usage window (both the
48
+ * `deepseek` catalog alias and the live `deepseek-official` route fold into
49
+ * one voucher). Undefined when the window is absent (older host) or the
50
+ * family has no usage — a zero-token note mints nothing.
51
+ */
52
+ export function deepseekVoucherData(window: UsageWindowSummary | undefined): VoucherData | undefined {
53
+ if (window === undefined) return undefined
54
+ let tokens = 0
55
+ let calls = 0
56
+ let cost = 0
57
+ for (const row of window.providers) {
58
+ if (!isDeepSeekProviderRoute(row.provider)) continue
59
+ tokens += totalTokens(row.totals)
60
+ calls += row.totals.calls
61
+ cost += row.totals.cost
62
+ }
63
+ if (tokens <= 0) return undefined
64
+ return { tokens, calls, cost, from: window.from, to: window.to }
65
+ }
66
+
67
+ /** Banknote denomination: full digits with thousands separators. */
68
+ export function formatDenomination(value: number): string {
69
+ return Math.max(0, Math.round(value)).toLocaleString('en-US')
70
+ }
71
+
72
+ /** Deterministic serial number flavor: the face value mod 1e9, zero-padded. */
73
+ export function voucherSerial(face: number): string {
74
+ return String(Math.max(0, Math.round(face)) % 1_000_000_000).padStart(9, '0')
75
+ }
76
+
77
+ /** The decoded note artwork plus its intrinsic size. */
78
+ export interface VoucherArt {
79
+ image: CanvasImageSource
80
+ width: number
81
+ height: number
82
+ }
83
+
84
+ let artPromise: Promise<VoucherArt> | undefined
85
+
86
+ /** Decode the note artwork once per page; a failed decode retries next call. */
87
+ export function loadVoucherArt(): Promise<VoucherArt> {
88
+ artPromise ??= new Promise<VoucherArt>((resolve, reject) => {
89
+ const image = new Image()
90
+ image.onload = () => resolve({ image, width: image.naturalWidth, height: image.naturalHeight })
91
+ image.onerror = () => {
92
+ artPromise = undefined
93
+ reject(new Error('voucher art decode failed'))
94
+ }
95
+ image.src = VOUCHER_ART_DATA_URL
96
+ })
97
+ return artPromise
98
+ }
99
+
100
+ /** Ink and seal-red the note's engraving palette, sampled off the artwork. */
101
+ const INK = '#2b2f38'
102
+ const SEAL_RED = '#9a3b2c'
103
+ const DENOMINATION_FONT = "Georgia, 'Times New Roman', serif"
104
+
105
+ /**
106
+ * Stamp the denomination block onto a canvas sized to the artwork: the
107
+ * minted whale yuan as the face value under the note title, a small
108
+ * `whale yuan` caption, and a seal-red serial line with the minting
109
+ * window. All geometry is relative to the artwork size so a regenerated
110
+ * asset reflows.
111
+ */
112
+ export function drawVoucher(canvas: HTMLCanvasElement, art: VoucherArt, data: VoucherData): void {
113
+ canvas.width = art.width
114
+ canvas.height = art.height
115
+ const ctx = canvas.getContext('2d')
116
+ if (ctx === null) throw new Error('canvas 2d context unavailable')
117
+ ctx.drawImage(art.image, 0, 0, art.width, art.height)
118
+
119
+ const face = faceValue(data.tokens)
120
+ const centerX = Math.round(art.width * 0.67)
121
+ const denomination = formatDenomination(face)
122
+
123
+ // Fit the face value into the free band between the note title and the
124
+ // bottom ornament (the red seal sits to its right).
125
+ let size = Math.round(art.height * 0.115)
126
+ const minSize = Math.round(art.height * 0.055)
127
+ const maxWidth = art.width * 0.28
128
+ ctx.textAlign = 'center'
129
+ ctx.textBaseline = 'alphabetic'
130
+ while (size > minSize) {
131
+ ctx.font = `700 ${size}px ${DENOMINATION_FONT}`
132
+ if (ctx.measureText(denomination).width <= maxWidth) break
133
+ size -= 2
134
+ }
135
+ ctx.fillStyle = INK
136
+ ctx.fillText(denomination, centerX, Math.round(art.height * 0.75))
137
+
138
+ ctx.font = `600 ${Math.round(art.height * 0.032)}px ${DENOMINATION_FONT}`
139
+ ctx.fillText('whale yuan', centerX, Math.round(art.height * 0.8))
140
+
141
+ ctx.fillStyle = SEAL_RED
142
+ ctx.font = `500 ${Math.round(art.height * 0.026)}px ${DENOMINATION_FONT}`
143
+ ctx.fillText(`NO.${voucherSerial(face)} ${data.from} - ${data.to}`, centerX, Math.round(art.height * 0.845))
144
+ }
@@ -1,13 +1,19 @@
1
1
  /**
2
- * DeepSeek official peak/off-peak pricing: the published V4 price book plus
3
- * the peak-window clock, folded into a per-call spend estimate the ledger
4
- * stamps at fold time (the provider bills each request in the period the
5
- * request ran in, so pricing at the fold is the honest estimate).
2
+ * DeepSeek official peak/off-peak pricing: the published price book plus the
3
+ * peak-window clock, folded into a per-call spend estimate the ledger stamps
4
+ * at fold time (the provider bills each request in the period the request ran
5
+ * in, so pricing at the fold is the honest estimate).
6
6
  *
7
- * Policy (api-docs.deepseek.com pricing page, effective 2026-08-17): peak
8
- * hours are Beijing time Monday-Friday 09:00-12:00 and 14:00-18:00; every
9
- * other hour (nights, weekends) is off-peak and billed at half the peak
10
- * price. All prices here are CNY per million tokens.
7
+ * Policy (api-docs.deepseek.com pricing page, effective 2026-09-10 12:00
8
+ * Beijing): peak hours are Beijing time Monday-Friday 09:00-12:00 and
9
+ * 14:00-18:00; every other hour (nights, weekends) is off-peak and billed at
10
+ * half the peak price. All prices here are CNY per million tokens.
11
+ *
12
+ * The published rows are `deepseek-flash` (DeepSeek-V4.1-Flash) and
13
+ * `deepseek-v4-pro` (DeepSeek-V4-Pro-0813). The retired flash ids
14
+ * (`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`) stay accepted and bill
15
+ * at the flash row; DeepSeek routes `deepseek-v4-pro` to V4.1-Flash from the
16
+ * retirement instant below until V4.1 Pro ships.
11
17
  * @module @linxin666/dsh-usage/core/pricing
12
18
  */
13
19
 
@@ -40,20 +46,27 @@ interface ModelPrice {
40
46
  output: { offPeak: number; peak: number }
41
47
  }
42
48
 
43
- /** deepseek-v4-flash (and the flash-class vision experiment). */
49
+ /** deepseek-flash (DeepSeek-V4.1-Flash), including the retired flash-class ids it now serves. */
44
50
  const FLASH_PRICE: ModelPrice = {
45
- cacheHit: { offPeak: 0.05, peak: 0.1 },
46
- inputMiss: { offPeak: 1.5, peak: 3.0 },
47
- output: { offPeak: 4.5, peak: 9.0 },
51
+ cacheHit: { offPeak: 0.02, peak: 0.04 },
52
+ inputMiss: { offPeak: 1.0, peak: 2.0 },
53
+ output: { offPeak: 4.0, peak: 8.0 },
48
54
  }
49
55
 
50
- /** deepseek-v4-pro. */
56
+ /** deepseek-v4-pro (DeepSeek-V4-Pro-0813); superseded by the flash row at the retirement instant. */
51
57
  const PRO_PRICE: ModelPrice = {
52
58
  cacheHit: { offPeak: 0.15, peak: 0.3 },
53
59
  inputMiss: { offPeak: 4.5, peak: 9.0 },
54
60
  output: { offPeak: 13.5, peak: 27.0 },
55
61
  }
56
62
 
63
+ /**
64
+ * The instant DeepSeek starts serving `deepseek-v4-pro` requests from
65
+ * V4.1-Flash and billing the flash row: 2026-09-14 12:00 Beijing (UTC+8).
66
+ * Drop the pro row's time fence once V4.1 Pro ships with a published row.
67
+ */
68
+ const V4_PRO_FOLDED_INTO_FLASH_AT_MS = Date.UTC(2026, 8, 14, 4, 0)
69
+
57
70
  function withinWindow(minuteOfDay: number): PeakWindow | undefined {
58
71
  return PEAK_WINDOWS.find((window) => minuteOfDay >= window.from && minuteOfDay < window.to)
59
72
  }
@@ -87,9 +100,10 @@ export function deepseekPeriodAt(ms: number): { peak: boolean; boundaryMs: numbe
87
100
  return { peak: false, boundaryMs: ms + 86_400_000 }
88
101
  }
89
102
 
90
- /** The price row for a model id; unknown ids take the flash-class row (documented estimate). */
91
- function priceFor(model: string): ModelPrice {
92
- return model.includes('v4-pro') ? PRO_PRICE : FLASH_PRICE
103
+ /** The price row for a model id at `atMs`; unknown ids take the flash-class row (documented estimate). */
104
+ function priceFor(model: string, atMs: number): ModelPrice {
105
+ const proPriced = model.includes('v4-pro') && atMs < V4_PRO_FOLDED_INTO_FLASH_AT_MS
106
+ return proPriced ? PRO_PRICE : FLASH_PRICE
93
107
  }
94
108
 
95
109
  /**
@@ -99,7 +113,7 @@ function priceFor(model: string): ModelPrice {
99
113
  * by route family). Rounded to micro-CNY so the ledger stays readable.
100
114
  */
101
115
  export function deepseekModelSpend(model: string, totals: Readonly<UsageTokenTotals>, atMs: number): number {
102
- const price = priceFor(model)
116
+ const price = priceFor(model, atMs)
103
117
  const period = deepseekPeriodAt(atMs)
104
118
  const column = period.peak ? 'peak' : 'offPeak'
105
119
  const spend = (totals.cacheReadTokens * price.cacheHit[column]
package/src/core/types.ts CHANGED
@@ -100,6 +100,22 @@ export interface PlanView {
100
100
  /** How the credential backing one provider route was resolved. */
101
101
  export type CredentialKind = 'api-key' | 'env' | 'oauth' | 'none'
102
102
 
103
+ /** One aggregated usage window (the 30-day trend, or the whole retained ledger). */
104
+ export interface UsageWindowSummary {
105
+ from: string
106
+ to: string
107
+ totals: UsageTokenTotals
108
+ providers: UsageProviderSummary[]
109
+ }
110
+
111
+ /** Real spend observed from an official balance series rather than priced locally. */
112
+ export interface ObservedSpendView {
113
+ /** Accrued spend in the account currency (CNY for the DeepSeek official watch). */
114
+ cny: number
115
+ /** Epoch ms of the first balance observation the accrual starts at. */
116
+ since: number
117
+ }
118
+
103
119
  /** One provider row of the overview snapshot. */
104
120
  export interface ProviderSnapshotView {
105
121
  /** Provider route key (`deepseek`, `kimi-coding`, custom routes, ...). */
@@ -139,12 +155,20 @@ export interface UsageOverviewView {
139
155
  * The same window aggregated per provider and model — the trend card's
140
156
  * bar-chart data. Optional so an older host document still renders.
141
157
  */
142
- range?: {
143
- from: string
144
- to: string
145
- totals: UsageTokenTotals
146
- providers: UsageProviderSummary[]
147
- }
158
+ range?: UsageWindowSummary
159
+ /**
160
+ * The whole retained ledger (up to `retainDays`, today included)
161
+ * aggregated per provider — the voucher's minted total. Optional for the
162
+ * same older-host tolerance as `range`.
163
+ */
164
+ all?: UsageWindowSummary
165
+ /**
166
+ * Real CNY spend of the official DeepSeek family, accrued from observed
167
+ * decreases of the official balance (top-ups never count). Present once
168
+ * a decrease has been observed; until then the section falls back to the
169
+ * fold-time estimate.
170
+ */
171
+ observedSpend?: ObservedSpendView
148
172
  }
149
173
  }
150
174
 
@@ -18,12 +18,28 @@ import { adapterFor, isDeepSeekProviderRoute, providerErrorMessage } from '../co
18
18
  import type { BalanceParse, PlanParse } from '../core/adapters.ts'
19
19
  import { deepseekModelSpend, deepseekPeriodAt } from '../core/pricing.ts'
20
20
  import { createLedgerDocument, deserializeLedger, foldUsage, ledgerDayKeys, localDateKey, pruneLedger, summarizeDays, totalTokens } from '../core/ledger.ts'
21
- import type { BalanceView, CredentialKind, PlanView, ProviderSnapshotState, ProviderSnapshotView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
21
+ import type { BalanceView, CredentialKind, ObservedSpendView, PlanView, ProviderSnapshotState, ProviderSnapshotView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
22
22
  import { emptyTotals } from '../core/types.ts'
23
23
 
24
24
  /** Source tag the plugin stamps onto pet announcements. */
25
25
  export const USAGE_ANNOUNCE_SOURCE = 'dsh-usage'
26
26
 
27
+ /**
28
+ * Persisted accrual state for the official DeepSeek family's real spend:
29
+ * consecutive observations of the official CNY balance, decreases counted
30
+ * as spent (a rise is a top-up and never accrues). `since` is the first
31
+ * observation; the ledger keeps no such fact, so this watch is the only
32
+ * source of the voucher's observed-spend figure.
33
+ */
34
+ export interface SpendWatch {
35
+ /** Total observed decrease since `since`, in CNY. */
36
+ accruedCny: number
37
+ /** Epoch ms of the first balance observation. */
38
+ since: number
39
+ /** The balance the previous observation ended on, when one exists. */
40
+ lastBalanceCny?: number
41
+ }
42
+
27
43
  /** Per-route credential fact the probes run against. */
28
44
  interface ResolvedCredential {
29
45
  kind: CredentialKind
@@ -225,6 +241,8 @@ export class UsageService {
225
241
 
226
242
  private ledger: UsageLedgerDocument = createLedgerDocument()
227
243
  private readonly snapshots = new Map<string, ProviderSnapshotState>()
244
+ /** The official DeepSeek family's real-spend watch (see SpendWatch). */
245
+ private spendWatch: SpendWatch | undefined
228
246
  /** Per-live-session route attribution (WeakMap: disposed sessions age out). */
229
247
  private readonly sessionRoutes = new WeakMap<Session, { provider: string; model: string }>()
230
248
  /** The most recent route seen this boot; the pet bubble follows it. */
@@ -307,8 +325,10 @@ export class UsageService {
307
325
  })
308
326
  }
309
327
  providers.sort((a, b) => Number(b.supported) - Number(a.supported) || a.displayName.localeCompare(b.displayName))
310
- const days = ledgerDayKeys(this.ledger).slice(-TREND_DAYS)
328
+ const allKeys = ledgerDayKeys(this.ledger)
329
+ const days = allKeys.slice(-TREND_DAYS)
311
330
  const range = summarizeDays(days.map((key) => this.ledger.days[key] ?? {}))
331
+ const all = summarizeDays(allKeys.map((key) => this.ledger.days[key] ?? {}))
312
332
  return {
313
333
  updatedAt: Date.now(),
314
334
  providers,
@@ -325,6 +345,15 @@ export class UsageService {
325
345
  totals: range.totals,
326
346
  providers: range.providers,
327
347
  },
348
+ all: {
349
+ from: allKeys[0] ?? todayKey,
350
+ to: allKeys[allKeys.length - 1] ?? todayKey,
351
+ totals: all.totals,
352
+ providers: all.providers,
353
+ },
354
+ ...(this.spendWatch !== undefined && this.spendWatch.accruedCny > 0
355
+ ? { observedSpend: { cny: this.spendWatch.accruedCny, since: this.spendWatch.since } satisfies ObservedSpendView }
356
+ : {}),
328
357
  },
329
358
  }
330
359
  }
@@ -353,6 +382,33 @@ export class UsageService {
353
382
  return row?.totals ?? emptyTotals()
354
383
  }
355
384
 
385
+ /**
386
+ * Accrue the official DeepSeek family's real spend from observed CNY
387
+ * balance decreases; a balance rise is a top-up and never counts. The
388
+ * family's route ids can alias one account, so the watch follows the
389
+ * largest balance seen this cycle and accrues only decreases of that
390
+ * series — a failed probe keeps the stale balance and accrues nothing
391
+ * until the next success reports the whole drop at once.
392
+ */
393
+ private accrueDeepSeekSpend(): void {
394
+ let balanceCny: number | undefined
395
+ for (const [id, snapshot] of this.snapshots) {
396
+ if (!isDeepSeekProviderRoute(id)) continue
397
+ const balance = snapshot.balance
398
+ if (balance === undefined || balance.currency.toUpperCase() !== 'CNY') continue
399
+ const value = Number(balance.totalBalance)
400
+ if (!Number.isFinite(value)) continue
401
+ balanceCny = balanceCny === undefined ? value : Math.max(balanceCny, value)
402
+ }
403
+ if (balanceCny === undefined) return
404
+ const watch = this.spendWatch ?? { accruedCny: 0, since: Date.now() }
405
+ if (watch.lastBalanceCny !== undefined && balanceCny < watch.lastBalanceCny) {
406
+ watch.accruedCny += watch.lastBalanceCny - balanceCny
407
+ }
408
+ watch.lastBalanceCny = balanceCny
409
+ this.spendWatch = watch
410
+ }
411
+
356
412
  /**
357
413
  * The display name for a route key: the LLM runtime's first, then the
358
414
  * adapter's, then the id itself — the snapshot may not exist for
@@ -437,7 +493,7 @@ export class UsageService {
437
493
  this.lastPrune = { dayKey: localDateKey(Date.now()), retainDays: this.options.retainDays }
438
494
  }
439
495
  if (rawSnapshots !== undefined) {
440
- const parsed = JSON.parse(rawSnapshots) as { providers?: Record<string, ProviderSnapshotState> }
496
+ const parsed = JSON.parse(rawSnapshots) as { providers?: Record<string, ProviderSnapshotState>; spendWatch?: SpendWatch }
441
497
  if (typeof parsed === 'object' && parsed !== null && typeof parsed.providers === 'object' && parsed.providers !== null) {
442
498
  for (const [provider, snapshot] of Object.entries(parsed.providers)) {
443
499
  if (typeof snapshot === 'object' && snapshot !== null && typeof snapshot.provider === 'string') {
@@ -445,6 +501,17 @@ export class UsageService {
445
501
  }
446
502
  }
447
503
  }
504
+ // Revive the spend watch strictly: a corrupt accrual would otherwise
505
+ // quietly rewrite the user's real-spend figure.
506
+ const watch = parsed.spendWatch
507
+ if (typeof watch === 'object' && watch !== null && typeof watch.accruedCny === 'number' && Number.isFinite(watch.accruedCny) && watch.accruedCny >= 0
508
+ && typeof watch.since === 'number' && Number.isFinite(watch.since) && watch.since > 0) {
509
+ this.spendWatch = {
510
+ accruedCny: watch.accruedCny,
511
+ since: watch.since,
512
+ ...(typeof watch.lastBalanceCny === 'number' && Number.isFinite(watch.lastBalanceCny) ? { lastBalanceCny: watch.lastBalanceCny } : {}),
513
+ }
514
+ }
448
515
  }
449
516
  } catch {
450
517
  // Corrupt or unreadable state starts fresh; the next flush rewrites it.
@@ -503,7 +570,11 @@ export class UsageService {
503
570
  if (!this.loaded) return
504
571
  try {
505
572
  await mkdir(dirname(this.snapshotsPath), { recursive: true })
506
- await writeJsonAtomic(this.snapshotsPath, { version: 1, providers: Object.fromEntries(this.snapshots) })
573
+ await writeJsonAtomic(this.snapshotsPath, {
574
+ version: 1,
575
+ providers: Object.fromEntries(this.snapshots),
576
+ ...(this.spendWatch !== undefined ? { spendWatch: this.spendWatch } : {}),
577
+ })
507
578
  } catch {
508
579
  // Same silent degradation as the ledger.
509
580
  }
@@ -549,6 +620,7 @@ export class UsageService {
549
620
  if (!seen.has(id)) this.snapshots.delete(id)
550
621
  }
551
622
  if (this.disposed) return
623
+ this.accrueDeepSeekSpend()
552
624
  await this.persistSnapshots()
553
625
  this.announceCurrent()
554
626
  } finally {