@linxin666/dsh-usage 0.3.7 → 0.3.9
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 +4 -3
- package/README.md +10 -4
- package/README.zh.md +10 -4
- package/lib/client.js +652 -48
- package/lib/client.js.map +1 -1
- package/lib/index.js +560 -368
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/core/adapters.d.ts +10 -1
- package/lib/types/core/ledger.d.ts +15 -2
- package/lib/types/core/pricing.d.ts +30 -0
- package/lib/types/core/types.d.ts +34 -1
- package/lib/types/host/usage-service.d.ts +72 -11
- package/lib/types/index.d.ts +2 -1
- package/package.json +15 -15
- package/src/client/UsageSectionCard.tsx +138 -55
- package/src/client/index.ts +11 -2
- package/src/client/locales.ts +8 -0
- package/src/client/usage.module.css +71 -12
- package/src/core/adapters.ts +84 -3
- package/src/core/ledger.ts +65 -3
- package/src/core/pricing.ts +109 -0
- package/src/core/types.ts +37 -2
- package/src/host/usage-service.ts +300 -131
- package/src/index.ts +30 -17
package/src/client/index.ts
CHANGED
|
@@ -27,8 +27,14 @@ interface UsageHttpApi {
|
|
|
27
27
|
refresh(): Promise<UsageOverviewView>
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** Hard ceiling for one usage API call; a stalled host must not pile up requests. */
|
|
31
|
+
const USAGE_FETCH_TIMEOUT_MS = 20_000
|
|
32
|
+
|
|
30
33
|
async function usageFetch<T>(path: string, method: 'GET' | 'POST'): Promise<T> {
|
|
31
|
-
const response = await fetch(path,
|
|
34
|
+
const response = await fetch(path, {
|
|
35
|
+
...(method === 'POST' ? { method: 'POST' } : {}),
|
|
36
|
+
signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS),
|
|
37
|
+
})
|
|
32
38
|
if (!response.ok) throw new Error('usage ' + path + ' failed: ' + response.status)
|
|
33
39
|
return (await response.json()) as T
|
|
34
40
|
}
|
|
@@ -97,11 +103,14 @@ export function apply(ctx: ClientContext): void {
|
|
|
97
103
|
store.actions.setState('error', 'usage.overview transport error')
|
|
98
104
|
})
|
|
99
105
|
}
|
|
106
|
+
// The refresh response is authoritative: it reflects the completed probe
|
|
107
|
+
// cycle, so it applies even when a faster section poll already bumped the
|
|
108
|
+
// sequence past it (the poll's pre-cycle snapshot is the stale one).
|
|
100
109
|
const refresh = (): void => {
|
|
101
110
|
const seq = pollSeq + 1
|
|
102
111
|
pollSeq = seq
|
|
103
112
|
usageApi.refresh().then((snapshot) => {
|
|
104
|
-
|
|
113
|
+
pollSeq = seq
|
|
105
114
|
store.actions.setSnapshot(snapshot)
|
|
106
115
|
}, () => {
|
|
107
116
|
if (seq !== pollSeq) return
|
package/src/client/locales.ts
CHANGED
|
@@ -19,6 +19,9 @@ export const zh = {
|
|
|
19
19
|
'usage.error': '加载失败:{error}',
|
|
20
20
|
'usage.current': '当前',
|
|
21
21
|
'usage.today': '今日用量',
|
|
22
|
+
'usage.today.cost': '今日消费(估算)',
|
|
23
|
+
'usage.peak.on': 'DeepSeek 高峰时段:计价 ×2,{time} 结束',
|
|
24
|
+
'usage.peak.off': 'DeepSeek 空闲时段:计价为高峰一半,{time} 进入高峰',
|
|
22
25
|
'usage.calls': '{n} 次调用',
|
|
23
26
|
'usage.tokens.total': '总 tokens',
|
|
24
27
|
'usage.tokens.input': '输入',
|
|
@@ -33,6 +36,7 @@ export const zh = {
|
|
|
33
36
|
'usage.oauth': 'OAuth 凭据,不做余额查询',
|
|
34
37
|
'usage.plan.reset': '{date} 重置',
|
|
35
38
|
'usage.plan.noPlan': '未检测到套餐数据',
|
|
39
|
+
'usage.plan.noneConfigured': '没有已配置的套餐类 provider(如 Kimi、GLM、OpenCode Go、MiniMax、Codex 订阅)',
|
|
36
40
|
'usage.plan.windows.5h': '5 小时',
|
|
37
41
|
'usage.plan.windows.week': '每周',
|
|
38
42
|
'usage.plan.windows.month': '每月',
|
|
@@ -58,6 +62,9 @@ export const en: Record<UsageKey, string> = {
|
|
|
58
62
|
'usage.error': 'Failed to load: {error}',
|
|
59
63
|
'usage.current': 'Current',
|
|
60
64
|
'usage.today': 'Today',
|
|
65
|
+
'usage.today.cost': 'Today spend (estimated)',
|
|
66
|
+
'usage.peak.on': 'DeepSeek peak hours: 2x pricing, ends {time}',
|
|
67
|
+
'usage.peak.off': 'DeepSeek off-peak: half of peak pricing, peak returns {time}',
|
|
61
68
|
'usage.calls': '{n} calls',
|
|
62
69
|
'usage.tokens.total': 'Total tokens',
|
|
63
70
|
'usage.tokens.input': 'Input',
|
|
@@ -72,6 +79,7 @@ export const en: Record<UsageKey, string> = {
|
|
|
72
79
|
'usage.oauth': 'OAuth credential, no balance query',
|
|
73
80
|
'usage.plan.reset': 'resets {date}',
|
|
74
81
|
'usage.plan.noPlan': 'No plan data detected',
|
|
82
|
+
'usage.plan.noneConfigured': 'No plan-capable provider configured (such as Kimi, GLM, OpenCode Go, MiniMax, Codex subscription)',
|
|
75
83
|
'usage.plan.windows.5h': '5 hours',
|
|
76
84
|
'usage.plan.windows.week': 'Weekly',
|
|
77
85
|
'usage.plan.windows.month': 'Monthly',
|
|
@@ -168,23 +168,82 @@
|
|
|
168
168
|
flex: none;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
/* 近 30 天提供方/模型条形图:provider 行主导(条宽按窗口内最大 provider
|
|
172
|
+
归一),模型子行缩进细条(行内归一)。 */
|
|
173
|
+
.chart {
|
|
172
174
|
display: flex;
|
|
173
|
-
|
|
174
|
-
gap:
|
|
175
|
-
|
|
175
|
+
flex-direction: column;
|
|
176
|
+
gap: 12px;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.chartProvider {
|
|
180
|
+
display: flex;
|
|
181
|
+
flex-direction: column;
|
|
182
|
+
gap: 4px;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.chartHead {
|
|
186
|
+
display: flex;
|
|
187
|
+
align-items: baseline;
|
|
188
|
+
justify-content: space-between;
|
|
189
|
+
gap: 10px;
|
|
190
|
+
font-size: 13px;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.chartTokens {
|
|
194
|
+
font-variant-numeric: tabular-nums;
|
|
195
|
+
opacity: 0.65;
|
|
196
|
+
font-size: 11px;
|
|
197
|
+
white-space: nowrap;
|
|
176
198
|
}
|
|
177
199
|
|
|
178
|
-
.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
border-radius:
|
|
182
|
-
background: color-mix(in srgb, currentColor
|
|
183
|
-
|
|
200
|
+
.chartBar {
|
|
201
|
+
display: block;
|
|
202
|
+
height: 8px;
|
|
203
|
+
border-radius: 999px;
|
|
204
|
+
background: color-mix(in srgb, currentColor 8%, transparent);
|
|
205
|
+
overflow: hidden;
|
|
184
206
|
}
|
|
185
207
|
|
|
186
|
-
.
|
|
187
|
-
|
|
208
|
+
.chartFill {
|
|
209
|
+
display: block;
|
|
210
|
+
height: 100%;
|
|
211
|
+
border-radius: 999px;
|
|
212
|
+
background: color-mix(in srgb, currentColor 55%, transparent);
|
|
213
|
+
transition: width 300ms ease;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.chartModel {
|
|
217
|
+
display: grid;
|
|
218
|
+
grid-template-columns: minmax(80px, 180px) 1fr auto;
|
|
219
|
+
align-items: center;
|
|
220
|
+
gap: 8px;
|
|
221
|
+
padding-left: 14px;
|
|
222
|
+
font-size: 11px;
|
|
223
|
+
opacity: 0.8;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.chartModelName {
|
|
227
|
+
overflow: hidden;
|
|
228
|
+
text-overflow: ellipsis;
|
|
229
|
+
white-space: nowrap;
|
|
230
|
+
opacity: 0.8;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.chartModelBar {
|
|
234
|
+
display: block;
|
|
235
|
+
height: 4px;
|
|
236
|
+
border-radius: 999px;
|
|
237
|
+
background: color-mix(in srgb, currentColor 6%, transparent);
|
|
238
|
+
overflow: hidden;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.chartModelFill {
|
|
242
|
+
display: block;
|
|
243
|
+
height: 100%;
|
|
244
|
+
border-radius: 999px;
|
|
245
|
+
background: color-mix(in srgb, currentColor 35%, transparent);
|
|
246
|
+
transition: width 300ms ease;
|
|
188
247
|
}
|
|
189
248
|
|
|
190
249
|
.trendAxis {
|
package/src/core/adapters.ts
CHANGED
|
@@ -18,8 +18,10 @@ export interface ProbeSpec {
|
|
|
18
18
|
|
|
19
19
|
/** Inputs an adapter may use to build a probe. */
|
|
20
20
|
export interface AdapterProbeContext {
|
|
21
|
-
/** Resolved credential value
|
|
21
|
+
/** Resolved credential value: a raw API key, or an OAuth access token for the oauth-aware adapters. */
|
|
22
22
|
apiKey: string
|
|
23
|
+
/** Provider account id riding a dedicated header, when the credential carries one (Codex). */
|
|
24
|
+
accountId?: string
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/** A parsed balance fact. */
|
|
@@ -79,8 +81,13 @@ function toIso(value: unknown): string | undefined {
|
|
|
79
81
|
}
|
|
80
82
|
const text = str(value)
|
|
81
83
|
if (text === undefined) return undefined
|
|
84
|
+
// Epoch instants also arrive as strings ("1756428000"); route them through
|
|
85
|
+
// the numeric branch instead of letting Date() reject them.
|
|
86
|
+
if (/^\d+$/.test(text)) return toIso(Number(text))
|
|
82
87
|
const date = new Date(text)
|
|
83
|
-
|
|
88
|
+
// The contract is "normalized ISO 8601, else undefined": returning the raw
|
|
89
|
+
// text would render as "Invalid Date" downstream.
|
|
90
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
/** Used-percent helper guarding zero/absent limits. */
|
|
@@ -95,8 +102,14 @@ function bearer(apiKey: string): Record<string, string> {
|
|
|
95
102
|
return { authorization: `Bearer ${apiKey}` }
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The official pay-as-you-go balance. `deepseek` is the configurable-catalog
|
|
107
|
+
* route key; `deepseek-official` is the live provider route the llm-deepseek
|
|
108
|
+
* adapter registers (sessions and agent-default-model carry it), so both ids
|
|
109
|
+
* must resolve here or the current provider would never be probed.
|
|
110
|
+
*/
|
|
98
111
|
const DEEPSEEK: ProviderAdapter = {
|
|
99
|
-
ids: ['deepseek'],
|
|
112
|
+
ids: ['deepseek', 'deepseek-official'],
|
|
100
113
|
displayName: 'DeepSeek',
|
|
101
114
|
balance: {
|
|
102
115
|
build: ({ apiKey }) => ({ url: 'https://api.deepseek.com/user/balance', headers: bearer(apiKey) }),
|
|
@@ -328,6 +341,63 @@ const ZENMUX: ProviderAdapter = {
|
|
|
328
341
|
},
|
|
329
342
|
}
|
|
330
343
|
|
|
344
|
+
/**
|
|
345
|
+
* OpenAI Codex (ChatGPT subscription) quota over the OAuth access token the
|
|
346
|
+
* pi-ai grant stores — the one plan adapter whose credential is an OAuth
|
|
347
|
+
* token rather than an API key. A 401 here means the stored access token has
|
|
348
|
+
* expired; it refreshes when the harness next runs a Codex request, so the
|
|
349
|
+
* error line tells the user to use Codex once and refresh.
|
|
350
|
+
*/
|
|
351
|
+
const OPENAI_CODEX: ProviderAdapter = {
|
|
352
|
+
ids: ['openai-codex'],
|
|
353
|
+
displayName: 'Codex (ChatGPT 订阅)',
|
|
354
|
+
plan: {
|
|
355
|
+
build: ({ apiKey, accountId }) => ({
|
|
356
|
+
url: 'https://chatgpt.com/backend-api/wham/usage',
|
|
357
|
+
headers: {
|
|
358
|
+
authorization: `Bearer ${apiKey}`,
|
|
359
|
+
'user-agent': 'codex-cli',
|
|
360
|
+
accept: 'application/json',
|
|
361
|
+
...(accountId !== undefined ? { 'chatgpt-account-id': accountId } : {}),
|
|
362
|
+
},
|
|
363
|
+
}),
|
|
364
|
+
parse: (status, body) => {
|
|
365
|
+
if (status !== 200 || typeof body !== 'object' || body === null) return undefined
|
|
366
|
+
const rateLimit = (body as Record<string, unknown>).rate_limit
|
|
367
|
+
if (typeof rateLimit !== 'object' || rateLimit === null) return undefined
|
|
368
|
+
const windows: PlanWindowView[] = []
|
|
369
|
+
const rows = (rateLimit as Record<string, unknown>).primary_window !== undefined || (rateLimit as Record<string, unknown>).secondary_window !== undefined
|
|
370
|
+
? [(rateLimit as Record<string, unknown>).primary_window, (rateLimit as Record<string, unknown>).secondary_window]
|
|
371
|
+
: []
|
|
372
|
+
for (const entry of rows) {
|
|
373
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
374
|
+
const row = entry as Record<string, unknown>
|
|
375
|
+
const percent = toNum(row.used_percent)
|
|
376
|
+
if (percent === undefined) continue
|
|
377
|
+
const seconds = toNum(row.limit_window_seconds)
|
|
378
|
+
windows.push({
|
|
379
|
+
key: codexWindowKey(seconds),
|
|
380
|
+
percent: Math.max(0, Math.min(100, percent)),
|
|
381
|
+
// reset_at is unix seconds; toIso scales sub-millisecond-epoch values.
|
|
382
|
+
resetsAt: toIso(row.reset_at),
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
if (windows.length === 0) return undefined
|
|
386
|
+
return { windows }
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Map a Codex window length in seconds onto the shared window key vocabulary. */
|
|
392
|
+
function codexWindowKey(seconds: number | undefined): string {
|
|
393
|
+
if (seconds === undefined) return 'window'
|
|
394
|
+
if (seconds === 18000) return '5h'
|
|
395
|
+
if (seconds === 604800) return 'week'
|
|
396
|
+
if (seconds === 2_592_000) return 'month'
|
|
397
|
+
const hours = seconds / 3600
|
|
398
|
+
return hours >= 24 ? `${Math.round(hours / 24)}_day` : `${Math.round(hours)}_hour`
|
|
399
|
+
}
|
|
400
|
+
|
|
331
401
|
/**
|
|
332
402
|
* The adapter registry, in no particular order. Route keys come from the
|
|
333
403
|
* pi-ai provider catalog plus the routes this deployment observed in user
|
|
@@ -343,6 +413,7 @@ export const PROVIDER_ADAPTERS: readonly ProviderAdapter[] = [
|
|
|
343
413
|
OPENCODE_GO,
|
|
344
414
|
minimaxPlan('api.minimaxi.com', ['minimax-cn']),
|
|
345
415
|
minimaxPlan('api.minimax.io', ['minimax']),
|
|
416
|
+
OPENAI_CODEX,
|
|
346
417
|
OPENROUTER,
|
|
347
418
|
siliconFlow('api.siliconflow.cn', ['siliconflow', 'siliconflow-cn'], 'CNY'),
|
|
348
419
|
siliconFlow('api.siliconflow.com', ['siliconflow-intl'], 'USD'),
|
|
@@ -354,6 +425,16 @@ export function adapterFor(provider: string): ProviderAdapter | undefined {
|
|
|
354
425
|
return PROVIDER_ADAPTERS.find((adapter) => adapter.ids.includes(provider))
|
|
355
426
|
}
|
|
356
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Whether a provider route belongs to the official DeepSeek family: the only
|
|
430
|
+
* family with a spend price book and a settings-section-owned env credential
|
|
431
|
+
* (llm-deepseek) rather than a pi-ai profile. Drives the env fallback in
|
|
432
|
+
* credential resolution and the fold-time cost stamping.
|
|
433
|
+
*/
|
|
434
|
+
export function isDeepSeekProviderRoute(provider: string): boolean {
|
|
435
|
+
return adapterFor(provider) === DEEPSEEK
|
|
436
|
+
}
|
|
437
|
+
|
|
357
438
|
/**
|
|
358
439
|
* Best-effort human message from a provider error body, for the per-provider
|
|
359
440
|
* error line. Never throws; truncated to one short sentence.
|
package/src/core/ledger.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* @module @linxin666/dsh-usage/core/ledger
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { addTotals, emptyTotals, type UsageLedgerDocument, type UsageTokenTotals } from './types.ts'
|
|
8
|
+
import { addTotals, emptyTotals, type UsageLedgerDocument, type UsageProviderSummary, type UsageTokenTotals } from './types.ts'
|
|
9
9
|
|
|
10
10
|
/** Local-date key (`YYYY-MM-DD`) for an epoch ms timestamp. */
|
|
11
11
|
export function localDateKey(ms: number): string {
|
|
@@ -20,9 +20,20 @@ export function createLedgerDocument(): UsageLedgerDocument {
|
|
|
20
20
|
return { version: 1, days: {} }
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Bucket keys come from untrusted directions (persisted JSON, provider and
|
|
25
|
+
* model ids out of session events), so they must never collide with
|
|
26
|
+
* `Object.prototype` plumbing: assigning `day['__proto__']` or reading
|
|
27
|
+
* `day['constructor']` would pollute every object in the host process.
|
|
28
|
+
*/
|
|
29
|
+
function isSafeBucketKey(key: string): boolean {
|
|
30
|
+
return key !== '__proto__' && key !== 'constructor' && key !== 'prototype'
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/**
|
|
24
34
|
* Fold one usage report into the ledger in place. `provider` is the route key
|
|
25
|
-
* and `model` the provider-owned model id the step ran under.
|
|
35
|
+
* and `model` the provider-owned model id the step ran under. Reports keyed
|
|
36
|
+
* by prototype-plumbing names are dropped.
|
|
26
37
|
*/
|
|
27
38
|
export function foldUsage(
|
|
28
39
|
doc: UsageLedgerDocument,
|
|
@@ -31,6 +42,7 @@ export function foldUsage(
|
|
|
31
42
|
model: string,
|
|
32
43
|
usage: Readonly<UsageTokenTotals>,
|
|
33
44
|
): void {
|
|
45
|
+
if (!isSafeBucketKey(provider) || !isSafeBucketKey(model)) return
|
|
34
46
|
if (usage.calls <= 0 && usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens <= 0) return
|
|
35
47
|
const dayKey = localDateKey(atMs)
|
|
36
48
|
const day = doc.days[dayKey] ?? {}
|
|
@@ -47,6 +59,50 @@ export function totalTokens(totals: Readonly<UsageTokenTotals>): number {
|
|
|
47
59
|
return totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens
|
|
48
60
|
}
|
|
49
61
|
|
|
62
|
+
/** How many model rows one summarized provider keeps. */
|
|
63
|
+
export const SUMMARY_MODEL_CAP = 12
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Aggregate whole per-day maps (each `provider -> model -> totals`) into the
|
|
67
|
+
* per-provider/per-model summary the overview serves for one day or a range:
|
|
68
|
+
* disjoint bucket sums per provider, per-model rows heaviest first, and the
|
|
69
|
+
* grand total. Pure: reads its inputs, allocates fresh buckets.
|
|
70
|
+
*/
|
|
71
|
+
export function summarizeDays(days: ReadonlyArray<Record<string, Record<string, UsageTokenTotals>>>): {
|
|
72
|
+
totals: UsageTokenTotals
|
|
73
|
+
providers: UsageProviderSummary[]
|
|
74
|
+
} {
|
|
75
|
+
const merged = new Map<string, Map<string, UsageTokenTotals>>()
|
|
76
|
+
for (const day of days) {
|
|
77
|
+
for (const [provider, models] of Object.entries(day)) {
|
|
78
|
+
if (!isSafeBucketKey(provider) || typeof models !== 'object' || models === null) continue
|
|
79
|
+
let modelMap = merged.get(provider)
|
|
80
|
+
if (modelMap === undefined) {
|
|
81
|
+
modelMap = new Map()
|
|
82
|
+
merged.set(provider, modelMap)
|
|
83
|
+
}
|
|
84
|
+
for (const [model, totals] of Object.entries(models)) {
|
|
85
|
+
if (typeof totals !== 'object' || totals === null) continue
|
|
86
|
+
const bucket = modelMap.get(model) ?? emptyTotals()
|
|
87
|
+
addTotals(bucket, totals)
|
|
88
|
+
modelMap.set(model, bucket)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const providers: UsageProviderSummary[] = []
|
|
93
|
+
const totals = emptyTotals()
|
|
94
|
+
for (const [provider, modelMap] of merged) {
|
|
95
|
+
const providerTotals = emptyTotals()
|
|
96
|
+
const modelRows = [...modelMap].map(([model, modelTotals]) => ({ model, totals: modelTotals }))
|
|
97
|
+
for (const row of modelRows) addTotals(providerTotals, row.totals)
|
|
98
|
+
modelRows.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals))
|
|
99
|
+
providers.push({ provider, totals: providerTotals, models: modelRows.slice(0, SUMMARY_MODEL_CAP) })
|
|
100
|
+
}
|
|
101
|
+
providers.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals))
|
|
102
|
+
for (const row of providers) addTotals(totals, row.totals)
|
|
103
|
+
return { totals, providers }
|
|
104
|
+
}
|
|
105
|
+
|
|
50
106
|
/** All local-date keys in the ledger, ascending. */
|
|
51
107
|
export function ledgerDayKeys(doc: Readonly<UsageLedgerDocument>): string[] {
|
|
52
108
|
return Object.keys(doc.days).sort()
|
|
@@ -81,6 +137,7 @@ function reviveTotals(value: unknown): UsageTokenTotals | undefined {
|
|
|
81
137
|
cacheWriteTokens: num('cacheWriteTokens'),
|
|
82
138
|
reasoningTokens: num('reasoningTokens'),
|
|
83
139
|
calls: num('calls'),
|
|
140
|
+
cost: num('cost'),
|
|
84
141
|
}
|
|
85
142
|
}
|
|
86
143
|
|
|
@@ -96,11 +153,16 @@ export function deserializeLedger(value: unknown): UsageLedgerDocument {
|
|
|
96
153
|
if (typeof days !== 'object' || days === null) return doc
|
|
97
154
|
for (const [dateKey, providers] of Object.entries(days as Record<string, unknown>)) {
|
|
98
155
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey) || typeof providers !== 'object' || providers === null) continue
|
|
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.
|
|
159
|
+
const atMs = new Date(dateKey + 'T12:00:00').getTime()
|
|
160
|
+
if (!Number.isFinite(atMs) || localDateKey(atMs) !== dateKey) continue
|
|
99
161
|
for (const [provider, models] of Object.entries(providers as Record<string, unknown>)) {
|
|
100
162
|
if (typeof models !== 'object' || models === null) continue
|
|
101
163
|
for (const [model, totals] of Object.entries(models as Record<string, unknown>)) {
|
|
102
164
|
const revived = reviveTotals(totals)
|
|
103
|
-
if (revived !== undefined) foldUsage(doc,
|
|
165
|
+
if (revived !== undefined) foldUsage(doc, atMs, provider, model, revived)
|
|
104
166
|
}
|
|
105
167
|
}
|
|
106
168
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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).
|
|
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.
|
|
11
|
+
* @module @linxin666/dsh-usage/core/pricing
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { UsageTokenTotals } from './types.ts'
|
|
15
|
+
|
|
16
|
+
/** One peak window in minutes-of-day, Beijing time. */
|
|
17
|
+
interface PeakWindow {
|
|
18
|
+
/** Inclusive start, minutes since midnight. */
|
|
19
|
+
from: number
|
|
20
|
+
/** Exclusive end, minutes since midnight. */
|
|
21
|
+
to: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The published peak windows: weekday 09:00-12:00 and 14:00-18:00. */
|
|
25
|
+
const PEAK_WINDOWS: readonly PeakWindow[] = [
|
|
26
|
+
{ from: 9 * 60, to: 12 * 60 },
|
|
27
|
+
{ from: 14 * 60, to: 18 * 60 },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
/** Beijing is UTC+8 year-round (no DST), so a fixed shift is exact. */
|
|
31
|
+
const BEIJING_UTC_OFFSET_MS = 8 * 3_600_000
|
|
32
|
+
|
|
33
|
+
/** One price row in CNY per million tokens, split by billing period. */
|
|
34
|
+
interface ModelPrice {
|
|
35
|
+
/** Input served from the provider prompt cache. */
|
|
36
|
+
cacheHit: { offPeak: number; peak: number }
|
|
37
|
+
/** Uncached input (DeepSeek reports no separate cache-write class). */
|
|
38
|
+
inputMiss: { offPeak: number; peak: number }
|
|
39
|
+
/** Output; reasoning tokens bill as output. */
|
|
40
|
+
output: { offPeak: number; peak: number }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** deepseek-v4-flash (and the flash-class vision experiment). */
|
|
44
|
+
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 },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** deepseek-v4-pro. */
|
|
51
|
+
const PRO_PRICE: ModelPrice = {
|
|
52
|
+
cacheHit: { offPeak: 0.15, peak: 0.3 },
|
|
53
|
+
inputMiss: { offPeak: 4.5, peak: 9.0 },
|
|
54
|
+
output: { offPeak: 13.5, peak: 27.0 },
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function withinWindow(minuteOfDay: number): PeakWindow | undefined {
|
|
58
|
+
return PEAK_WINDOWS.find((window) => minuteOfDay >= window.from && minuteOfDay < window.to)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The DeepSeek billing period at `ms`, plus when it next flips. The clock is
|
|
63
|
+
* Beijing time regardless of the host timezone (UTC+8 has no DST, so a fixed
|
|
64
|
+
* shift is exact). `boundaryMs` is the instant the current period ends — the
|
|
65
|
+
* window's close while peaking, the next window's open otherwise.
|
|
66
|
+
*/
|
|
67
|
+
export function deepseekPeriodAt(ms: number): { peak: boolean; boundaryMs: number } {
|
|
68
|
+
const shifted = new Date(ms + BEIJING_UTC_OFFSET_MS)
|
|
69
|
+
const weekday = shifted.getUTCDay()
|
|
70
|
+
const minuteOfDay = shifted.getUTCHours() * 60 + shifted.getUTCMinutes()
|
|
71
|
+
const current = weekday >= 1 && weekday <= 5 ? withinWindow(minuteOfDay) : undefined
|
|
72
|
+
if (current !== undefined) {
|
|
73
|
+
return { peak: true, boundaryMs: ms + (current.to - minuteOfDay) * 60_000 - shifted.getUTCSeconds() * 1000 - shifted.getUTCMilliseconds() }
|
|
74
|
+
}
|
|
75
|
+
// Next window start: later today (weekday only), else the following days'
|
|
76
|
+
// first morning window; the scan bound makes a malformed clock terminate.
|
|
77
|
+
for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
|
|
78
|
+
const day = new Date(ms + BEIJING_UTC_OFFSET_MS + dayOffset * 86_400_000)
|
|
79
|
+
if (day.getUTCDay() < 1 || day.getUTCDay() > 5) continue
|
|
80
|
+
const realDayStart = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()) - BEIJING_UTC_OFFSET_MS
|
|
81
|
+
for (const window of PEAK_WINDOWS) {
|
|
82
|
+
if (dayOffset === 0 && window.from <= minuteOfDay) continue
|
|
83
|
+
return { peak: false, boundaryMs: realDayStart + window.from * 60_000 }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Unreachable (the scan covers a full week); a conservative off-peak answer.
|
|
87
|
+
return { peak: false, boundaryMs: ms + 86_400_000 }
|
|
88
|
+
}
|
|
89
|
+
|
|
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
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Estimate one call's DeepSeek spend in CNY from its token totals, priced in
|
|
97
|
+
* the billing period at `atMs`. Uncatalogued model ids take the flash-class
|
|
98
|
+
* row; ids from other providers never reach this function (the service gates
|
|
99
|
+
* by route family). Rounded to micro-CNY so the ledger stays readable.
|
|
100
|
+
*/
|
|
101
|
+
export function deepseekModelSpend(model: string, totals: Readonly<UsageTokenTotals>, atMs: number): number {
|
|
102
|
+
const price = priceFor(model)
|
|
103
|
+
const period = deepseekPeriodAt(atMs)
|
|
104
|
+
const column = period.peak ? 'peak' : 'offPeak'
|
|
105
|
+
const spend = (totals.cacheReadTokens * price.cacheHit[column]
|
|
106
|
+
+ (totals.inputTokens + totals.cacheWriteTokens) * price.inputMiss[column]
|
|
107
|
+
+ totals.outputTokens * price.output[column]) / 1_000_000
|
|
108
|
+
return Math.round(spend * 1e6) / 1e6
|
|
109
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -20,11 +20,18 @@ export interface UsageTokenTotals {
|
|
|
20
20
|
reasoningTokens: number
|
|
21
21
|
/** Provider calls that reported usage. */
|
|
22
22
|
calls: number
|
|
23
|
+
/**
|
|
24
|
+
* Spend estimate stamped at fold time, in the priced provider's billing
|
|
25
|
+
* currency (currently DeepSeek official only, CNY); every other provider
|
|
26
|
+
* stays 0 = unpriced, so the sum is never a mixed-currency total. Persisted
|
|
27
|
+
* buckets recorded before a price-book change keep the old pricing.
|
|
28
|
+
*/
|
|
29
|
+
cost: number
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/** A zeroed totals bucket. */
|
|
26
33
|
export function emptyTotals(): UsageTokenTotals {
|
|
27
|
-
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, calls: 0 }
|
|
34
|
+
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, calls: 0, cost: 0 }
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
/** Add `right` into `left` in place. */
|
|
@@ -35,6 +42,7 @@ export function addTotals(left: UsageTokenTotals, right: Readonly<UsageTokenTota
|
|
|
35
42
|
left.cacheWriteTokens += right.cacheWriteTokens
|
|
36
43
|
left.reasoningTokens += right.reasoningTokens
|
|
37
44
|
left.calls += right.calls
|
|
45
|
+
left.cost += right.cost
|
|
38
46
|
return left
|
|
39
47
|
}
|
|
40
48
|
|
|
@@ -99,8 +107,12 @@ export interface ProviderSnapshotView {
|
|
|
99
107
|
/** Display name from the LLM runtime, else the adapter's, else the route key. */
|
|
100
108
|
displayName: string
|
|
101
109
|
credential: CredentialKind
|
|
102
|
-
/** Whether
|
|
110
|
+
/** Whether any balance/plan adapter exists for this route. */
|
|
103
111
|
supported: boolean
|
|
112
|
+
/** A balance adapter exists for this route (the Usage tab's balance card). */
|
|
113
|
+
balanceSupported?: boolean
|
|
114
|
+
/** A coding-plan adapter exists for this route — the Plans tab lists these only. */
|
|
115
|
+
planSupported?: boolean
|
|
104
116
|
balance?: BalanceView
|
|
105
117
|
plan?: PlanView
|
|
106
118
|
/** Last probe failure, cleared on the next success. */
|
|
@@ -123,5 +135,28 @@ export interface UsageOverviewView {
|
|
|
123
135
|
today: { date: string; totals: UsageTokenTotals; providers: UsageProviderSummary[] }
|
|
124
136
|
/** Last N local days ascending, including today. */
|
|
125
137
|
days: UsageDaySummary[]
|
|
138
|
+
/**
|
|
139
|
+
* The same window aggregated per provider and model — the trend card's
|
|
140
|
+
* bar-chart data. Optional so an older host document still renders.
|
|
141
|
+
*/
|
|
142
|
+
range?: {
|
|
143
|
+
from: string
|
|
144
|
+
to: string
|
|
145
|
+
totals: UsageTokenTotals
|
|
146
|
+
providers: UsageProviderSummary[]
|
|
147
|
+
}
|
|
126
148
|
}
|
|
127
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Host-side snapshot state: the wire view plus one error slot per probed
|
|
153
|
+
* fact. A probe half only clears its own slot, so a plan success can no
|
|
154
|
+
* longer mask a failing balance probe (and vice versa); the overview
|
|
155
|
+
* collapses them into the single wire `error` line, balance first.
|
|
156
|
+
*/
|
|
157
|
+
export interface ProviderSnapshotState extends ProviderSnapshotView {
|
|
158
|
+
/** Last balance-probe failure; cleared on the next balance success. */
|
|
159
|
+
balanceError?: string
|
|
160
|
+
/** Last plan-probe failure; cleared on the next plan success. */
|
|
161
|
+
planError?: string
|
|
162
|
+
}
|