@linxin666/dsh-usage 0.3.23 → 0.3.24

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.
Files changed (38) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -7
  3. package/README.zh.md +5 -7
  4. package/lib/client.js +744 -73
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +171 -201
  7. package/lib/types/client/UsageSectionCard.d.ts +0 -1
  8. package/lib/types/client/UsageSidebarPanel.d.ts +34 -0
  9. package/lib/types/client/body-mutations.d.ts +45 -0
  10. package/lib/types/client/locales.d.ts +9 -4
  11. package/lib/types/client/sidebar-entry-core.d.ts +87 -0
  12. package/lib/types/client/sidebar-entry.d.ts +35 -0
  13. package/lib/types/client/sidebar-panel-mount.d.ts +40 -0
  14. package/lib/types/core/provider-routes.d.ts +26 -0
  15. package/lib/types/core/types.d.ts +14 -1
  16. package/lib/types/host/access.d.ts +17 -0
  17. package/lib/types/host/pair-access.d.ts +34 -0
  18. package/lib/types/host/routes.d.ts +16 -7
  19. package/lib/types/host/usage-service.d.ts +28 -73
  20. package/lib/types/index.d.ts +0 -2
  21. package/package.json +4 -3
  22. package/src/client/UsageSectionCard.tsx +0 -14
  23. package/src/client/UsageSidebarPanel.tsx +196 -0
  24. package/src/client/body-mutations.ts +133 -0
  25. package/src/client/index.ts +22 -1
  26. package/src/client/locales.ts +18 -8
  27. package/src/client/sidebar-entry-core.ts +321 -0
  28. package/src/client/sidebar-entry.ts +101 -0
  29. package/src/client/sidebar-panel-mount.tsx +113 -0
  30. package/src/client/usage.module.css +252 -0
  31. package/src/core/adapters.ts +40 -6
  32. package/src/core/provider-routes.ts +37 -0
  33. package/src/core/types.ts +14 -1
  34. package/src/host/access.ts +21 -0
  35. package/src/host/pair-access.ts +49 -0
  36. package/src/host/routes.ts +19 -10
  37. package/src/host/usage-service.ts +65 -214
  38. package/src/index.ts +1 -6
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The dsh-usage host service: folds live session usage into the persistent
3
3
  * ledger, probes each configured provider's balance/coding-plan endpoint on
4
- * a poll cycle, and announces the current provider's status to the pet
5
- * bubble. Secrets stay in the host process; the browser only ever sees the
6
- * overview document.
4
+ * a poll cycle, and serves the current provider's strip-ready view inside
5
+ * the overview document. Secrets stay in the host process; the browser only
6
+ * ever sees the overview document.
7
7
  * @module @linxin666/dsh-usage/host/usage-service
8
8
  */
9
9
 
@@ -16,13 +16,11 @@ import type { TokenUsage } from '@deepseek-ai/dsh-llm'
16
16
  import { dshHome } from '../dsh-home.ts'
17
17
  import { adapterFor, isDeepSeekProviderRoute, providerErrorMessage } from '../core/adapters.ts'
18
18
  import type { BalanceParse, PlanParse } from '../core/adapters.ts'
19
- import { deepseekModelSpend, deepseekPeriodAt } from '../core/pricing.ts'
19
+ import { foldAliasRoutes } from '../core/provider-routes.ts'
20
+ import { deepseekModelSpend } from '../core/pricing.ts'
20
21
  import { createLedgerDocument, deserializeLedger, foldUsage, ledgerDayKeys, localDateKey, pruneLedger, summarizeDays, totalTokens } from '../core/ledger.ts'
21
22
  import type { BalanceView, CredentialKind, ObservedSpendView, PlanView, ProviderSnapshotState, ProviderSnapshotView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
22
- import { emptyTotals } from '../core/types.ts'
23
-
24
- /** Source tag the plugin stamps onto pet announcements. */
25
- export const USAGE_ANNOUNCE_SOURCE = 'dsh-usage'
23
+ import { addTotals, emptyTotals } from '../core/types.ts'
26
24
 
27
25
  /**
28
26
  * Persisted accrual state for the official DeepSeek family's real spend:
@@ -49,16 +47,17 @@ interface ResolvedCredential {
49
47
  accountId?: string
50
48
  }
51
49
 
52
- /** One live LLM provider route the service knows about. */
50
+ /** One LLM provider route the service knows about. */
53
51
  interface ProviderRoute {
54
52
  id: string
55
53
  displayName: string
54
+ /** Whether the LLM runtime serves requests on this route; a catalog-only entry is dormant. */
55
+ live: boolean
56
56
  }
57
57
 
58
- /** Poll-loop and announce options; re-applied live on settings change. */
58
+ /** Poll-loop options; re-applied live on settings change. */
59
59
  export interface UsageServiceOptions {
60
60
  pollIntervalSec: number
61
- bubbleMode: 'always' | 'change' | 'off'
62
61
  retainDays: number
63
62
  }
64
63
 
@@ -71,120 +70,6 @@ const FLUSH_DEBOUNCE_MS = 3_000
71
70
  /** How many trend days the overview serves. */
72
71
  const TREND_DAYS = 30
73
72
 
74
- /** Currency symbols the bubble and section render inline; other codes render as `12.00 EUR`. */
75
- const CURRENCY_SYMBOLS: Readonly<Record<string, string>> = { CNY: '¥', USD: '$', EUR: '€', GBP: '£' }
76
-
77
- /** Format a balance for display: symbol prefix when known, code suffix otherwise. */
78
- export function formatMoney(currency: string, totalBalance: string): string {
79
- const symbol = CURRENCY_SYMBOLS[currency.toUpperCase()]
80
- if (symbol !== undefined) return symbol + totalBalance
81
- return `${totalBalance} ${currency.toUpperCase()}`
82
- }
83
-
84
- /** Map a used percent to the announcement tone. */
85
- export function planTone(percent: number): 'ok' | 'warn' | 'low' {
86
- if (percent >= 90) return 'low'
87
- if (percent >= 70) return 'warn'
88
- return 'ok'
89
- }
90
-
91
- /** Announcement context the service computes per poll: today's family spend and the DeepSeek period. */
92
- export interface AnnounceContext {
93
- /** Today's ledger spend for the announced provider's family (CNY; 0 = unpriced). */
94
- todayCost?: number
95
- /** Whether DeepSeek peak pricing is in effect right now. */
96
- peak?: boolean
97
- }
98
-
99
- /**
100
- * Build the raw pet announce payload for one provider snapshot, or
101
- * undefined when nothing worth announcing exists. Pure: every payload this
102
- * returns satisfies the pet's `parseAnnouncement` contract — plan
103
- * announcements require a numeric percent, so percent-less windows never
104
- * announce (the pet validator would silently drop them). A priced family
105
- * (DeepSeek) with spend today announces a cost bubble first; balance-only
106
- * families announce the balance; plan families announce their tightest
107
- * percent window.
108
- */
109
- export function buildAnnouncement(
110
- snapshot: Pick<ProviderSnapshotView, 'displayName' | 'balance' | 'plan'>,
111
- context?: AnnounceContext,
112
- ): Record<string, unknown> | undefined {
113
- const todayCost = context?.todayCost ?? 0
114
- if (todayCost > 0) {
115
- const peak = context?.peak ?? false
116
- const noteParts = [
117
- peak ? '高峰时段 计价×2' : '空闲时段 计价减半',
118
- ...(snapshot.balance !== undefined ? [`余额 ${formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance)}`] : []),
119
- ]
120
- return {
121
- kind: 'cost',
122
- title: snapshot.displayName,
123
- amount: `今日 ${formatMoney('CNY', todayCost.toFixed(2))}`,
124
- ...(noteParts.length > 0 ? { note: noteParts.join(' · ') } : {}),
125
- tone: peak ? 'warn' : 'ok',
126
- }
127
- }
128
- if (snapshot.balance !== undefined) {
129
- return {
130
- kind: 'balance',
131
- title: snapshot.displayName,
132
- amount: formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance),
133
- tone: 'ok',
134
- }
135
- }
136
- if (snapshot.plan === undefined) return undefined
137
- const window = snapshot.plan.windows
138
- .filter((entry) => typeof entry.percent === 'number')
139
- .sort((a, b) => (b.percent ?? 0) - (a.percent ?? 0))[0]
140
- if (window === undefined || window.percent === undefined) return undefined
141
- return {
142
- kind: 'plan',
143
- title: snapshot.displayName,
144
- percent: window.percent,
145
- ...(window.resetsAt !== undefined ? { resetAt: window.resetsAt } : {}),
146
- ...(snapshot.plan.planName !== undefined ? { note: snapshot.plan.planName } : {}),
147
- tone: planTone(window.percent),
148
- }
149
- }
150
-
151
- /**
152
- * Compact token-count display for the usage fallback bubble: `9805`,
153
- * `8.7万`, `1.2亿`. The host-authored bubble copy is zh (like every other
154
- * line this service speaks), so the magnitudes follow the zh convention.
155
- */
156
- export function formatTokens(count: number): string {
157
- const compact = (value: number): string => {
158
- const rounded = Math.round(value * 10) / 10
159
- return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
160
- }
161
- if (count >= 1e8) return compact(count / 1e8) + '亿'
162
- if (count >= 1e4) return compact(count / 1e4) + '万'
163
- return String(count)
164
- }
165
-
166
- /**
167
- * The usage fallback for the session's provider: today's token consumption
168
- * and call count, the one fact the ledger owns for every provider. Providers
169
- * without a readable balance/plan endpoint (relay stations, local runtimes,
170
- * token-plan vendors — and probeable providers whose probes are failing)
171
- * would otherwise leave the pet bubble permanently silent even while their
172
- * sessions run. Undefined when the provider has no usage today: a bubble
173
- * about nothing is noise, not information. Pure; the payload satisfies the
174
- * pet's `parseAnnouncement` contract.
175
- */
176
- export function buildLedgerAnnouncement(input: { displayName: string; totals: UsageTokenTotals }): Record<string, unknown> | undefined {
177
- const total = totalTokens(input.totals)
178
- if (input.totals.calls <= 0 || total <= 0) return undefined
179
- return {
180
- kind: 'cost',
181
- title: input.displayName,
182
- amount: `今日 ${formatTokens(total)} tokens`,
183
- note: `${input.totals.calls} 次调用`,
184
- tone: 'ok',
185
- }
186
- }
187
-
188
73
  /** Best-effort typed service read: absent services resolve to undefined at runtime. */
189
74
  function service<T>(ctx: Context, name: string): T | undefined {
190
75
  try {
@@ -245,7 +130,7 @@ export class UsageService {
245
130
  private spendWatch: SpendWatch | undefined
246
131
  /** Per-live-session route attribution (WeakMap: disposed sessions age out). */
247
132
  private readonly sessionRoutes = new WeakMap<Session, { provider: string; model: string }>()
248
- /** The most recent route seen this boot; the pet bubble follows it. */
133
+ /** The most recent route seen this boot; the sidebar strip and the header highlight follow it. */
249
134
  private current: { provider?: string; model?: string; source: 'live' | 'default' } = { source: 'default' }
250
135
 
251
136
  private sessionListenerDisposer: (() => void) | undefined
@@ -259,7 +144,6 @@ export class UsageService {
259
144
  /** True once loadPersisted finished; nothing may overwrite the files before that. */
260
145
  private loaded = false
261
146
  private disposed = false
262
- private lastSignature: string | undefined
263
147
  /** Last prune guard: once per local day, and whenever retention shrinks. */
264
148
  private lastPrune: { dayKey: string; retainDays: number } | undefined
265
149
 
@@ -332,7 +216,7 @@ export class UsageService {
332
216
  return {
333
217
  updatedAt: Date.now(),
334
218
  providers,
335
- current: { ...this.current },
219
+ current: this.currentView(),
336
220
  usage: {
337
221
  today: this.daySummary(todayKey),
338
222
  days: days.map((date) => {
@@ -358,6 +242,36 @@ export class UsageService {
358
242
  }
359
243
  }
360
244
 
245
+ /**
246
+ * The strip-ready current-provider view: the resolved display name plus
247
+ * today's ledger totals for the provider's adapter family (the route
248
+ * itself when adapter-less). `today` is absent on a day without usage —
249
+ * a strip about nothing is noise, not information.
250
+ */
251
+ private currentView(): UsageOverviewView['current'] {
252
+ const provider = this.current.provider
253
+ if (provider === undefined) return { ...this.current }
254
+ let snapshot = this.snapshots.get(provider)
255
+ if (snapshot === undefined) {
256
+ const family = adapterFor(provider)
257
+ if (family !== undefined) {
258
+ for (const [id, candidate] of this.snapshots) {
259
+ if (adapterFor(id) === family) {
260
+ snapshot = candidate
261
+ break
262
+ }
263
+ }
264
+ }
265
+ }
266
+ const displayName = snapshot?.displayName ?? this.routeDisplayName(provider)
267
+ const today = this.familyUsageToday(provider)
268
+ return {
269
+ ...this.current,
270
+ displayName,
271
+ ...(today.calls > 0 && totalTokens(today) > 0 ? { today } : {}),
272
+ }
273
+ }
274
+
361
275
  /** One local day aggregated per provider. */
362
276
  private daySummary(dateKey: string): UsageOverviewView['usage']['today'] {
363
277
  const day = this.ledger.days[dateKey] ?? {}
@@ -365,21 +279,20 @@ export class UsageService {
365
279
  return { date: dateKey, totals, providers }
366
280
  }
367
281
 
368
- /** Today's ledger spend for one provider's adapter family (0 when unpriced). */
369
- private familyCostToday(provider: string): number {
282
+ /**
283
+ * Today's ledger totals for one provider's adapter family — the exact
284
+ * route when adapter-less, the merged family otherwise, so aliased routes
285
+ * of one account (the deepseek catalog id and its runtime alias) fold
286
+ * together.
287
+ */
288
+ private familyUsageToday(provider: string): UsageTokenTotals {
370
289
  const family = adapterFor(provider)
371
- if (family === undefined) return 0
372
- let cost = 0
290
+ let merged = emptyTotals()
373
291
  for (const row of this.daySummary(localDateKey(Date.now())).providers) {
374
- if (adapterFor(row.provider) === family) cost += row.totals.cost
292
+ const same = family === undefined ? row.provider === provider : adapterFor(row.provider) === family
293
+ if (same) merged = addTotals(merged, row.totals)
375
294
  }
376
- return cost
377
- }
378
-
379
- /** Today's ledger totals for one provider route (the exact id, not the family). */
380
- private providerUsageToday(provider: string): UsageTokenTotals {
381
- const row = this.daySummary(localDateKey(Date.now())).providers.find((entry) => entry.provider === provider)
382
- return row?.totals ?? emptyTotals()
295
+ return merged
383
296
  }
384
297
 
385
298
  /**
@@ -412,7 +325,7 @@ export class UsageService {
412
325
  /**
413
326
  * The display name for a route key: the LLM runtime's first, then the
414
327
  * adapter's, then the id itself — the snapshot may not exist for
415
- * adapter-less routes, yet the bubble still needs a legible title.
328
+ * adapter-less routes, yet the sidebar strip still needs a legible title.
416
329
  */
417
330
  private routeDisplayName(provider: string): string {
418
331
  const route = this.listProviderRoutes().find((entry) => entry.id === provider)
@@ -594,7 +507,7 @@ export class UsageService {
594
507
  }
595
508
 
596
509
  /**
597
- * One poll cycle: enumerate routes, resolve credentials, probe, announce.
510
+ * One poll cycle: enumerate routes, resolve credentials, probe.
598
511
  * A call while a cycle is already running joins that cycle instead of
599
512
  * returning immediately, so a manual refresh always waits for real probes.
600
513
  */
@@ -622,7 +535,6 @@ export class UsageService {
622
535
  if (this.disposed) return
623
536
  this.accrueDeepSeekSpend()
624
537
  await this.persistSnapshots()
625
- this.announceCurrent()
626
538
  } finally {
627
539
  this.pollInFlight = false
628
540
  this.pollPromise = undefined
@@ -632,22 +544,28 @@ export class UsageService {
632
544
  return cycle
633
545
  }
634
546
 
547
+ /**
548
+ * Every route the service probes and renders: the runtime's live providers
549
+ * plus the configurable directory's catalog entries, then alias-folded so a
550
+ * dormant catalog entry cannot shadow the live route of the same adapter
551
+ * family (see foldAliasRoutes).
552
+ */
635
553
  private listProviderRoutes(): ProviderRoute[] {
636
- const routes = new Map<string, string>()
554
+ const routes = new Map<string, ProviderRoute>()
637
555
  const runtime = service<{ listProviders(): Array<{ id: string; name: string }>; listConfigurableProviders(): Array<{ provider: string; displayName: string }> }>(this.ctx, 'llm')
638
556
  if (runtime !== undefined) {
639
557
  try {
640
558
  for (const provider of runtime.listProviders()) {
641
- if (provider.id !== '') routes.set(provider.id, provider.name)
559
+ if (provider.id !== '') routes.set(provider.id, { id: provider.id, displayName: provider.name, live: true })
642
560
  }
643
561
  for (const provider of runtime.listConfigurableProviders()) {
644
- if (!routes.has(provider.provider)) routes.set(provider.provider, provider.displayName)
562
+ if (!routes.has(provider.provider)) routes.set(provider.provider, { id: provider.provider, displayName: provider.displayName, live: false })
645
563
  }
646
564
  } catch {
647
565
  // Registry hiccups degrade to an empty list; the next poll retries.
648
566
  }
649
567
  }
650
- return [...routes].map(([id, displayName]) => ({ id, displayName }))
568
+ return foldAliasRoutes([...routes.values()])
651
569
  }
652
570
 
653
571
  private async probeRoute(route: ProviderRoute, adapter: NonNullable<ReturnType<typeof adapterFor>>): Promise<void> {
@@ -765,71 +683,4 @@ export class UsageService {
765
683
  return section?.apiKeyEnv ?? 'DEEPSEEK_API_KEY'
766
684
  }
767
685
 
768
- // ------------------------------------------------------------------
769
- // Pet bubble
770
- // ------------------------------------------------------------------
771
-
772
- /**
773
- * Announce the current provider's spend, balance, or plan usage to the pet.
774
- * In `change` mode only meaningful value changes re-announce; `off` skips.
775
- * The TTL rides the poll interval (bubble_mode `always` re-announces every
776
- * cycle, so a TTL of two cycles + margin keeps the bubble continuous
777
- * across polls; the pet contract caps the ceiling). A route id the
778
- * catalogs spell differently than the snapshot keys falls back to its
779
- * adapter family's snapshot. When the provider has no announceable probe
780
- * fact (no adapter, failing probes, percent-less windows) the bubble falls
781
- * back to the provider's today ledger usage, and with neither fact nor
782
- * usage it stays silent. Fully guarded: a malformed snapshot or a failing
783
- * pet service must never break the poll loop, and a disposed service never
784
- * announces.
785
- */
786
- private announceCurrent(): void {
787
- if (this.disposed || this.options.bubbleMode === 'off') return
788
- try {
789
- let provider = this.current.provider
790
- if (provider === undefined) {
791
- const fallback = readNamespace(this.ctx, 'agent-default-model') as { provider?: string; model?: string } | undefined
792
- if (fallback?.provider !== undefined) {
793
- provider = fallback.provider
794
- this.current = { provider: fallback.provider, model: fallback.model, source: 'default' }
795
- } else {
796
- return
797
- }
798
- }
799
- let snapshot = this.snapshots.get(provider)
800
- if (snapshot === undefined) {
801
- const family = adapterFor(provider)
802
- if (family !== undefined) {
803
- for (const [id, candidate] of this.snapshots) {
804
- if (adapterFor(id) === family) {
805
- snapshot = candidate
806
- break
807
- }
808
- }
809
- }
810
- }
811
- const displayName = snapshot?.displayName ?? this.routeDisplayName(provider)
812
- let announcement = snapshot !== undefined
813
- ? buildAnnouncement(snapshot, {
814
- todayCost: this.familyCostToday(provider),
815
- peak: deepseekPeriodAt(Date.now()).peak,
816
- })
817
- : undefined
818
- // No probeable fact for the session's provider (no adapter at all,
819
- // failing probes, or percent-less windows): the bubble still follows
820
- // the provider with the one fact the ledger owns — today's usage.
821
- if (announcement === undefined) {
822
- announcement = buildLedgerAnnouncement({ displayName, totals: this.providerUsageToday(provider) })
823
- }
824
- if (announcement === undefined) return
825
- const signature = JSON.stringify(announcement)
826
- if (this.options.bubbleMode === 'change' && signature === this.lastSignature) return
827
- this.lastSignature = signature
828
- const ttlMs = Math.min(7_200_000, this.options.pollIntervalSec * 2_000 + 30_000)
829
- const pet = service<{ announce(input: Record<string, unknown>): void }>(this.ctx, 'pet')
830
- pet?.announce({ source: USAGE_ANNOUNCE_SOURCE, ttlMs, ...announcement })
831
- } catch {
832
- // A missing or failing pet service must never break the poll loop.
833
- }
834
- }
835
686
  }
package/src/index.ts CHANGED
@@ -14,8 +14,6 @@ export interface Config {
14
14
  enabled?: boolean
15
15
  /** Provider probe cycle in seconds; 30-3600. */
16
16
  pollIntervalSec?: number
17
- /** Pet bubble mode: always (refreshes each poll), change (only on value change), off. */
18
- bubbleMode?: string
19
17
  /** Ledger retention in local days. */
20
18
  retainDays?: number
21
19
  }
@@ -23,7 +21,6 @@ export interface Config {
23
21
  export const Config: z<Config> = z.object({
24
22
  enabled: z.boolean().default(true),
25
23
  pollIntervalSec: z.number().min(30).max(3600).default(60),
26
- bubbleMode: z.string().default('always'),
27
24
  retainDays: z.number().min(7).max(730).default(180),
28
25
  })
29
26
 
@@ -32,11 +29,9 @@ export interface ResolvedConfig extends UsageServiceOptions {
32
29
  }
33
30
 
34
31
  export function resolveConfig(config?: Config): ResolvedConfig {
35
- const bubbleMode = config?.bubbleMode === 'change' || config?.bubbleMode === 'off' ? config.bubbleMode : 'always'
36
32
  return {
37
33
  enabled: config?.enabled ?? true,
38
34
  pollIntervalSec: typeof config?.pollIntervalSec === 'number' ? config.pollIntervalSec : 60,
39
- bubbleMode,
40
35
  retainDays: typeof config?.retainDays === 'number' ? config.retainDays : 180,
41
36
  }
42
37
  }
@@ -64,7 +59,7 @@ export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?: C
64
59
  // still pending; only start if this instance is still the live one.
65
60
  if (service !== next) return
66
61
  next.start()
67
- const disposers = [makeUsageOverviewRoute(next), makeUsageRefreshRoute(next)]
62
+ const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)]
68
63
  .map((route) => ctx.webServer.register(route))
69
64
  disposeRoutes = () => {
70
65
  for (const dispose of disposers) {