@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.
- package/README.i18n.yaml +2 -2
- package/README.md +6 -1
- package/README.zh.md +6 -1
- package/lib/client.js +347 -34
- package/lib/client.js.map +1 -1
- package/lib/index.js +70 -15
- package/lib/types/client/UsageSectionCard.d.ts +4 -3
- package/lib/types/client/locales.d.ts +12 -0
- package/lib/types/client/voucher-art.generated.d.ts +9 -0
- package/lib/types/client/voucher.d.ts +56 -0
- package/lib/types/core/pricing.d.ts +14 -8
- package/lib/types/core/types.d.ts +28 -6
- package/lib/types/host/usage-service.d.ts +26 -0
- package/package.json +2 -2
- package/src/client/UsageSectionCard.tsx +141 -15
- package/src/client/index.ts +7 -4
- package/src/client/locales.ts +24 -0
- package/src/client/usage.module.css +13 -0
- package/src/client/voucher-art.generated.ts +10 -0
- package/src/client/voucher.ts +144 -0
- package/src/core/pricing.ts +31 -17
- package/src/core/types.ts +30 -6
- package/src/host/usage-service.ts +76 -4
|
@@ -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
|
+
}
|
package/src/core/pricing.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DeepSeek official peak/off-peak pricing: the published
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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-
|
|
8
|
-
* hours are Beijing time Monday-Friday 09:00-12:00 and
|
|
9
|
-
* other hour (nights, weekends) is off-peak and billed at
|
|
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-
|
|
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.
|
|
46
|
-
inputMiss: { offPeak: 1.
|
|
47
|
-
output: { offPeak: 4.
|
|
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
|
|
91
|
-
function priceFor(model: string): ModelPrice {
|
|
92
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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, {
|
|
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 {
|