@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.
@@ -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, method === 'POST' ? { method: 'POST' } : {})
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
- if (seq !== pollSeq) return
113
+ pollSeq = seq
105
114
  store.actions.setSnapshot(snapshot)
106
115
  }, () => {
107
116
  if (seq !== pollSeq) return
@@ -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
- .trend {
171
+ /* 近 30 天提供方/模型条形图:provider 行主导(条宽按窗口内最大 provider
172
+ 归一),模型子行缩进细条(行内归一)。 */
173
+ .chart {
172
174
  display: flex;
173
- align-items: flex-end;
174
- gap: 3px;
175
- height: 56px;
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
- .trendBar {
179
- flex: 1 1 0;
180
- min-width: 4px;
181
- border-radius: 3px 3px 0 0;
182
- background: color-mix(in srgb, currentColor 30%, transparent);
183
- min-height: 2px;
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
- .trendBarToday {
187
- background: color-mix(in srgb, currentColor 65%, transparent);
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 {
@@ -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 (the raw key; env refs are resolved upstream). */
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
- return Number.isNaN(date.getTime()) ? text : date.toISOString()
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.
@@ -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, new Date(dateKey + 'T12:00:00').getTime(), provider, model, revived)
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 a balance/plan adapter exists for this route. */
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
+ }