@linxin666/dsh-usage 0.3.22 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +6 -8
- package/README.zh.md +6 -8
- package/lib/client.js +749 -78
- package/lib/client.js.map +1 -1
- package/lib/index.js +171 -201
- package/lib/types/client/UsageSectionCard.d.ts +0 -1
- package/lib/types/client/UsageSidebarPanel.d.ts +34 -0
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/locales.d.ts +9 -4
- package/lib/types/client/sidebar-entry-core.d.ts +87 -0
- package/lib/types/client/sidebar-entry.d.ts +35 -0
- package/lib/types/client/sidebar-panel-mount.d.ts +40 -0
- package/lib/types/client/voucher.d.ts +2 -2
- package/lib/types/core/provider-routes.d.ts +26 -0
- package/lib/types/core/types.d.ts +14 -1
- package/lib/types/host/access.d.ts +17 -0
- package/lib/types/host/pair-access.d.ts +34 -0
- package/lib/types/host/routes.d.ts +16 -7
- package/lib/types/host/usage-service.d.ts +28 -73
- package/lib/types/index.d.ts +0 -2
- package/package.json +4 -3
- package/src/client/UsageSectionCard.tsx +1 -15
- package/src/client/UsageSidebarPanel.tsx +196 -0
- package/src/client/body-mutations.ts +133 -0
- package/src/client/index.ts +22 -1
- package/src/client/locales.ts +20 -10
- package/src/client/sidebar-entry-core.ts +321 -0
- package/src/client/sidebar-entry.ts +101 -0
- package/src/client/sidebar-panel-mount.tsx +113 -0
- package/src/client/usage.module.css +252 -0
- package/src/client/voucher.ts +2 -2
- package/src/core/adapters.ts +40 -6
- package/src/core/provider-routes.ts +37 -0
- package/src/core/types.ts +14 -1
- package/src/host/access.ts +21 -0
- package/src/host/pair-access.ts +49 -0
- package/src/host/routes.ts +19 -10
- package/src/host/usage-service.ts +65 -214
- 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
|
|
5
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
let cost = 0
|
|
290
|
+
let merged = emptyTotals()
|
|
373
291
|
for (const row of this.daySummary(localDateKey(Date.now())).providers) {
|
|
374
|
-
|
|
292
|
+
const same = family === undefined ? row.provider === provider : adapterFor(row.provider) === family
|
|
293
|
+
if (same) merged = addTotals(merged, row.totals)
|
|
375
294
|
}
|
|
376
|
-
return
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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) {
|