@linxin666/dsh-usage 0.3.7
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/LICENSE +202 -0
- package/README.i18n.yaml +5 -0
- package/README.md +50 -0
- package/README.zh.md +50 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +755 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1443 -0
- package/lib/types/client/UsageSectionCard.d.ts +36 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +62 -0
- package/lib/types/client/usage-store.d.ts +27 -0
- package/lib/types/core/adapters.d.ts +58 -0
- package/lib/types/core/ledger.d.ts +31 -0
- package/lib/types/core/types.d.ts +112 -0
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host/http.d.ts +42 -0
- package/lib/types/host/loopback.d.ts +24 -0
- package/lib/types/host/routes.d.ts +14 -0
- package/lib/types/host/usage-service.d.ts +82 -0
- package/lib/types/index.d.ts +21 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/package.json +92 -0
- package/src/client/UsageSectionCard.tsx +368 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.ts +131 -0
- package/src/client/locales.ts +116 -0
- package/src/client/usage-store.ts +52 -0
- package/src/client/usage.module.css +311 -0
- package/src/core/adapters.ts +370 -0
- package/src/core/ledger.ts +108 -0
- package/src/core/types.ts +127 -0
- package/src/dsh-home.ts +42 -0
- package/src/host/http.ts +105 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/routes.ts +54 -0
- package/src/host/usage-service.ts +530 -0
- package/src/index.ts +90 -0
- package/src/mount-once.ts +48 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dsh-usage host service: folds live session usage into the persistent
|
|
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.
|
|
7
|
+
* @module @linxin666/dsh-usage/host/usage-service
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
13
|
+
import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
14
|
+
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
15
|
+
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
|
16
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
17
|
+
import { dshHome } from '../dsh-home.ts'
|
|
18
|
+
import { adapterFor, providerErrorMessage } from '../core/adapters.ts'
|
|
19
|
+
import type { BalanceParse, PlanParse } from '../core/adapters.ts'
|
|
20
|
+
import { createLedgerDocument, deserializeLedger, foldUsage, ledgerDayKeys, localDateKey, pruneLedger, totalTokens } from '../core/ledger.ts'
|
|
21
|
+
import type { BalanceView, CredentialKind, PlanView, 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'
|
|
26
|
+
|
|
27
|
+
/** Per-route credential fact the probes run against. */
|
|
28
|
+
interface ResolvedCredential {
|
|
29
|
+
kind: CredentialKind
|
|
30
|
+
key?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One live LLM provider route the service knows about. */
|
|
34
|
+
interface ProviderRoute {
|
|
35
|
+
id: string
|
|
36
|
+
displayName: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Poll-loop and announce options; re-applied live on settings change. */
|
|
40
|
+
export interface UsageServiceOptions {
|
|
41
|
+
pollIntervalSec: number
|
|
42
|
+
bubbleMode: 'always' | 'change' | 'off'
|
|
43
|
+
retainDays: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Probe timeout per HTTP call. */
|
|
47
|
+
const PROBE_TIMEOUT_MS = 10_000
|
|
48
|
+
|
|
49
|
+
/** Ledger flush debounce. */
|
|
50
|
+
const FLUSH_DEBOUNCE_MS = 3_000
|
|
51
|
+
|
|
52
|
+
/** How many trend days the overview serves. */
|
|
53
|
+
const TREND_DAYS = 30
|
|
54
|
+
|
|
55
|
+
/** Currency symbols the bubble and section render inline; other codes render as `12.00 EUR`. */
|
|
56
|
+
const CURRENCY_SYMBOLS: Readonly<Record<string, string>> = { CNY: '¥', USD: '$', EUR: '€', GBP: '£' }
|
|
57
|
+
|
|
58
|
+
/** Format a balance for display: symbol prefix when known, code suffix otherwise. */
|
|
59
|
+
export function formatMoney(currency: string, totalBalance: string): string {
|
|
60
|
+
const symbol = CURRENCY_SYMBOLS[currency.toUpperCase()]
|
|
61
|
+
if (symbol !== undefined) return symbol + totalBalance
|
|
62
|
+
return `${totalBalance} ${currency.toUpperCase()}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Map a used percent to the announcement tone. */
|
|
66
|
+
export function planTone(percent: number): 'ok' | 'warn' | 'low' {
|
|
67
|
+
if (percent >= 90) return 'low'
|
|
68
|
+
if (percent >= 70) return 'warn'
|
|
69
|
+
return 'ok'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Best-effort typed service read: absent services resolve to undefined at runtime. */
|
|
73
|
+
function service<T>(ctx: Context, name: string): T | undefined {
|
|
74
|
+
try {
|
|
75
|
+
return (ctx as unknown as { get(name: string): unknown }).get(name) as T | undefined
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Atomic JSON write through a temp file + rename. */
|
|
82
|
+
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
|
83
|
+
const temp = path + '.tmp'
|
|
84
|
+
await writeFile(temp, JSON.stringify(value, null, 1), 'utf8')
|
|
85
|
+
await rename(temp, path)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read a foreign settings namespace's resolved value (the llm adapter
|
|
90
|
+
* profiles, the agent default model). Unregistered namespaces read as
|
|
91
|
+
* undefined; nothing here throws into the poll loop.
|
|
92
|
+
*/
|
|
93
|
+
function readNamespace(ctx: Context, ns: string): unknown {
|
|
94
|
+
try {
|
|
95
|
+
const settings = service<{ get(ns: unknown): unknown }>(ctx, 'settings')
|
|
96
|
+
if (settings === undefined) return undefined
|
|
97
|
+
return settings.get(settingsNamespace(ns))
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class UsageService {
|
|
104
|
+
private readonly ctx: Context
|
|
105
|
+
private options: UsageServiceOptions
|
|
106
|
+
private readonly persistDir: string
|
|
107
|
+
private readonly ledgerPath: string
|
|
108
|
+
private readonly snapshotsPath: string
|
|
109
|
+
|
|
110
|
+
private ledger: UsageLedgerDocument = createLedgerDocument()
|
|
111
|
+
private readonly snapshots = new Map<string, ProviderSnapshotView>()
|
|
112
|
+
/** Per-live-session route attribution (WeakMap: disposed sessions age out). */
|
|
113
|
+
private readonly sessionRoutes = new WeakMap<Session, { provider: string; model: string }>()
|
|
114
|
+
/** The most recent route seen this boot; the pet bubble follows it. */
|
|
115
|
+
private current: { provider?: string; model?: string; source: 'live' | 'default' } = { source: 'default' }
|
|
116
|
+
|
|
117
|
+
private sessionListenerDisposer: (() => void) | undefined
|
|
118
|
+
private pollTimer: ReturnType<typeof setTimeout> | undefined
|
|
119
|
+
private flushTimer: ReturnType<typeof setTimeout> | undefined
|
|
120
|
+
private pollInFlight = false
|
|
121
|
+
private disposed = false
|
|
122
|
+
private lastSignature: string | undefined
|
|
123
|
+
|
|
124
|
+
constructor(ctx: Context, options: UsageServiceOptions) {
|
|
125
|
+
this.ctx = ctx
|
|
126
|
+
this.options = options
|
|
127
|
+
this.persistDir = join(dshHome(), 'dsh-usage')
|
|
128
|
+
this.ledgerPath = join(this.persistDir, 'usage-ledger.json')
|
|
129
|
+
this.snapshotsPath = join(this.persistDir, 'provider-snapshots.json')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Start the listeners, load persisted state, and arm the first poll. */
|
|
133
|
+
start(): void {
|
|
134
|
+
this.sessionListenerDisposer = this.ctx.on('session/event', (session, event) => this.onSessionEvent(session, event))
|
|
135
|
+
void this.loadPersisted()
|
|
136
|
+
this.rearmPoll(2_000)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Stop timers and flush pending ledger writes. */
|
|
140
|
+
stop(): void {
|
|
141
|
+
this.disposed = true
|
|
142
|
+
if (this.pollTimer !== undefined) clearTimeout(this.pollTimer)
|
|
143
|
+
if (this.flushTimer !== undefined) clearTimeout(this.flushTimer)
|
|
144
|
+
this.sessionListenerDisposer?.()
|
|
145
|
+
void this.flushLedger()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Re-apply options live (settings change); the poll cycle picks them up. */
|
|
149
|
+
applyOptions(options: UsageServiceOptions): void {
|
|
150
|
+
this.options = options
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Force one poll now (manual refresh route). */
|
|
154
|
+
async refresh(): Promise<void> {
|
|
155
|
+
await this.pollNow()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Assemble the overview document the browser section renders. */
|
|
159
|
+
overview(): UsageOverviewView {
|
|
160
|
+
const todayKey = localDateKey(Date.now())
|
|
161
|
+
const routes = this.listProviderRoutes()
|
|
162
|
+
const providers: ProviderSnapshotView[] = []
|
|
163
|
+
for (const route of routes) {
|
|
164
|
+
const adapter = adapterFor(route.id)
|
|
165
|
+
const snapshot = this.snapshots.get(route.id)
|
|
166
|
+
providers.push({
|
|
167
|
+
provider: route.id,
|
|
168
|
+
displayName: route.displayName || adapter?.displayName || route.id,
|
|
169
|
+
credential: snapshot?.credential ?? 'none',
|
|
170
|
+
supported: adapter !== undefined && (adapter.balance !== undefined || adapter.plan !== undefined),
|
|
171
|
+
...(snapshot?.balance !== undefined ? { balance: snapshot.balance } : {}),
|
|
172
|
+
...(snapshot?.plan !== undefined ? { plan: snapshot.plan } : {}),
|
|
173
|
+
...(snapshot?.error !== undefined ? { error: snapshot.error } : {}),
|
|
174
|
+
...(snapshot?.updatedAt !== undefined ? { updatedAt: snapshot.updatedAt } : {}),
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
providers.sort((a, b) => Number(b.supported) - Number(a.supported) || a.displayName.localeCompare(b.displayName))
|
|
178
|
+
const days = ledgerDayKeys(this.ledger).slice(-TREND_DAYS)
|
|
179
|
+
return {
|
|
180
|
+
updatedAt: Date.now(),
|
|
181
|
+
providers,
|
|
182
|
+
current: { ...this.current },
|
|
183
|
+
usage: {
|
|
184
|
+
today: this.daySummary(todayKey),
|
|
185
|
+
days: days.map((date) => {
|
|
186
|
+
const summary = this.daySummary(date)
|
|
187
|
+
return { date, totals: summary.totals }
|
|
188
|
+
}),
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One local day aggregated per provider. */
|
|
194
|
+
private daySummary(dateKey: string): UsageOverviewView['usage']['today'] {
|
|
195
|
+
const day = this.ledger.days[dateKey] ?? {}
|
|
196
|
+
const providers = Object.entries(day).map(([provider, models]) => {
|
|
197
|
+
const totals = emptyTotals()
|
|
198
|
+
const modelRows = Object.entries(models).map(([model, modelTotals]) => ({ model, totals: modelTotals }))
|
|
199
|
+
for (const row of modelRows) {
|
|
200
|
+
totals.inputTokens += row.totals.inputTokens
|
|
201
|
+
totals.outputTokens += row.totals.outputTokens
|
|
202
|
+
totals.cacheReadTokens += row.totals.cacheReadTokens
|
|
203
|
+
totals.cacheWriteTokens += row.totals.cacheWriteTokens
|
|
204
|
+
totals.reasoningTokens += row.totals.reasoningTokens
|
|
205
|
+
totals.calls += row.totals.calls
|
|
206
|
+
}
|
|
207
|
+
modelRows.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals))
|
|
208
|
+
return { provider, totals, models: modelRows.slice(0, 12) }
|
|
209
|
+
})
|
|
210
|
+
providers.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals))
|
|
211
|
+
const totals = emptyTotals()
|
|
212
|
+
for (const row of providers) {
|
|
213
|
+
totals.inputTokens += row.totals.inputTokens
|
|
214
|
+
totals.outputTokens += row.totals.outputTokens
|
|
215
|
+
totals.cacheReadTokens += row.totals.cacheReadTokens
|
|
216
|
+
totals.cacheWriteTokens += row.totals.cacheWriteTokens
|
|
217
|
+
totals.reasoningTokens += row.totals.reasoningTokens
|
|
218
|
+
totals.calls += row.totals.calls
|
|
219
|
+
}
|
|
220
|
+
return { date: dateKey, totals, providers }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ------------------------------------------------------------------
|
|
224
|
+
// Session usage fold
|
|
225
|
+
// ------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
private onSessionEvent(session: Session, event: SessionEvent): void {
|
|
228
|
+
try {
|
|
229
|
+
if (event.type === 'request/header') {
|
|
230
|
+
const config = (event.data as { header?: { config?: { provider?: string; model?: string } } }).header?.config
|
|
231
|
+
if (config?.provider !== undefined) {
|
|
232
|
+
this.sessionRoutes.set(session, { provider: config.provider, model: config.model ?? '' })
|
|
233
|
+
this.current = { provider: config.provider, model: config.model, source: 'live' }
|
|
234
|
+
}
|
|
235
|
+
} else if (event.type === 'request/context') {
|
|
236
|
+
const data = event.data as { provider?: string; model?: string }
|
|
237
|
+
if (data.provider !== undefined) {
|
|
238
|
+
this.sessionRoutes.set(session, { provider: data.provider, model: data.model ?? '' })
|
|
239
|
+
this.current = { provider: data.provider, model: data.model, source: 'live' }
|
|
240
|
+
}
|
|
241
|
+
} else if (event.type === 'assistant/message') {
|
|
242
|
+
const usage = (event.data as { usage?: TokenUsage }).usage
|
|
243
|
+
if (usage === undefined) return
|
|
244
|
+
const route = this.sessionRoutes.get(session)
|
|
245
|
+
if (route === undefined || route.provider === '') return
|
|
246
|
+
foldUsage(this.ledger, Date.now(), route.provider, route.model || 'unknown', this.totalsFrom(usage))
|
|
247
|
+
this.scheduleFlush()
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// A malformed event must never break the session loop.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Normalize a provider TokenUsage into the ledger bucket (one call). */
|
|
255
|
+
private totalsFrom(usage: TokenUsage): UsageTokenTotals {
|
|
256
|
+
const totals = emptyTotals()
|
|
257
|
+
totals.inputTokens = usage.inputTokens
|
|
258
|
+
totals.outputTokens = usage.outputTokens
|
|
259
|
+
totals.cacheReadTokens = usage.cacheReadTokens ?? 0
|
|
260
|
+
totals.cacheWriteTokens = usage.cacheWriteTokens ?? 0
|
|
261
|
+
totals.reasoningTokens = usage.reasoningTokens ?? 0
|
|
262
|
+
totals.calls = 1
|
|
263
|
+
return totals
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ------------------------------------------------------------------
|
|
267
|
+
// Persistence
|
|
268
|
+
// ------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
private async loadPersisted(): Promise<void> {
|
|
271
|
+
try {
|
|
272
|
+
const [rawLedger, rawSnapshots] = await Promise.all([
|
|
273
|
+
readFile(this.ledgerPath, 'utf8').catch(() => undefined),
|
|
274
|
+
readFile(this.snapshotsPath, 'utf8').catch(() => undefined),
|
|
275
|
+
])
|
|
276
|
+
if (rawLedger !== undefined) {
|
|
277
|
+
this.ledger = deserializeLedger(JSON.parse(rawLedger))
|
|
278
|
+
pruneLedger(this.ledger, localDateKey(Date.now()), this.options.retainDays)
|
|
279
|
+
}
|
|
280
|
+
if (rawSnapshots !== undefined) {
|
|
281
|
+
const parsed = JSON.parse(rawSnapshots) as { providers?: Record<string, ProviderSnapshotView> }
|
|
282
|
+
if (typeof parsed === 'object' && parsed !== null && typeof parsed.providers === 'object' && parsed.providers !== null) {
|
|
283
|
+
for (const [provider, snapshot] of Object.entries(parsed.providers)) {
|
|
284
|
+
if (typeof snapshot === 'object' && snapshot !== null && typeof snapshot.provider === 'string') {
|
|
285
|
+
this.snapshots.set(provider, snapshot)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
// Corrupt or unreadable state starts fresh; the next flush rewrites it.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private scheduleFlush(): void {
|
|
296
|
+
if (this.flushTimer !== undefined || this.disposed) return
|
|
297
|
+
this.flushTimer = setTimeout(() => {
|
|
298
|
+
this.flushTimer = undefined
|
|
299
|
+
void this.flushLedger()
|
|
300
|
+
}, FLUSH_DEBOUNCE_MS)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async flushLedger(): Promise<void> {
|
|
304
|
+
try {
|
|
305
|
+
await mkdir(dirname(this.ledgerPath), { recursive: true })
|
|
306
|
+
await writeJsonAtomic(this.ledgerPath, this.ledger)
|
|
307
|
+
} catch {
|
|
308
|
+
// Persistence failures degrade silently; the in-memory ledger keeps counting.
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async persistSnapshots(): Promise<void> {
|
|
313
|
+
try {
|
|
314
|
+
await mkdir(dirname(this.snapshotsPath), { recursive: true })
|
|
315
|
+
await writeJsonAtomic(this.snapshotsPath, { version: 1, providers: Object.fromEntries(this.snapshots) })
|
|
316
|
+
} catch {
|
|
317
|
+
// Same silent degradation as the ledger.
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ------------------------------------------------------------------
|
|
322
|
+
// Poll loop
|
|
323
|
+
// ------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
private rearmPoll(delayMs: number): void {
|
|
326
|
+
if (this.disposed) return
|
|
327
|
+
if (this.pollTimer !== undefined) clearTimeout(this.pollTimer)
|
|
328
|
+
this.pollTimer = setTimeout(() => {
|
|
329
|
+
this.pollTimer = undefined
|
|
330
|
+
void this.pollNow().finally(() => this.rearmPoll(Math.max(30, this.options.pollIntervalSec) * 1000))
|
|
331
|
+
}, delayMs)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** One poll cycle: enumerate routes, resolve credentials, probe, announce. */
|
|
335
|
+
async pollNow(): Promise<void> {
|
|
336
|
+
if (this.pollInFlight || this.disposed) return
|
|
337
|
+
this.pollInFlight = true
|
|
338
|
+
try {
|
|
339
|
+
const routes = this.listProviderRoutes()
|
|
340
|
+
const seen = new Set<string>()
|
|
341
|
+
for (const route of routes) {
|
|
342
|
+
seen.add(route.id)
|
|
343
|
+
const adapter = adapterFor(route.id)
|
|
344
|
+
if (adapter === undefined || (adapter.balance === undefined && adapter.plan === undefined)) continue
|
|
345
|
+
await this.probeRoute(route, adapter)
|
|
346
|
+
}
|
|
347
|
+
for (const id of [...this.snapshots.keys()]) {
|
|
348
|
+
if (!seen.has(id)) this.snapshots.delete(id)
|
|
349
|
+
}
|
|
350
|
+
await this.persistSnapshots()
|
|
351
|
+
this.announceCurrent()
|
|
352
|
+
} finally {
|
|
353
|
+
this.pollInFlight = false
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private listProviderRoutes(): ProviderRoute[] {
|
|
358
|
+
const routes = new Map<string, string>()
|
|
359
|
+
const runtime = service<{ listProviders(): Array<{ id: string; name: string }>; listConfigurableProviders(): Array<{ provider: string; displayName: string }> }>(this.ctx, 'llm')
|
|
360
|
+
if (runtime !== undefined) {
|
|
361
|
+
try {
|
|
362
|
+
for (const provider of runtime.listProviders()) {
|
|
363
|
+
if (provider.id !== '') routes.set(provider.id, provider.name)
|
|
364
|
+
}
|
|
365
|
+
for (const provider of runtime.listConfigurableProviders()) {
|
|
366
|
+
if (!routes.has(provider.provider)) routes.set(provider.provider, provider.displayName)
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
// Registry hiccups degrade to an empty list; the next poll retries.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return [...routes].map(([id, displayName]) => ({ id, displayName }))
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private async probeRoute(route: ProviderRoute, adapter: NonNullable<ReturnType<typeof adapterFor>>): Promise<void> {
|
|
376
|
+
const credential = await this.resolveCredential(route.id)
|
|
377
|
+
const previous = this.snapshots.get(route.id)
|
|
378
|
+
const snapshot: ProviderSnapshotView = {
|
|
379
|
+
provider: route.id,
|
|
380
|
+
displayName: route.displayName || adapter.displayName,
|
|
381
|
+
credential: credential.kind,
|
|
382
|
+
supported: true,
|
|
383
|
+
...(previous?.balance !== undefined ? { balance: previous.balance } : {}),
|
|
384
|
+
...(previous?.plan !== undefined ? { plan: previous.plan } : {}),
|
|
385
|
+
updatedAt: Date.now(),
|
|
386
|
+
}
|
|
387
|
+
if (credential.key === undefined) {
|
|
388
|
+
// Nothing to probe with (oauth grant, no credential): keep any previous
|
|
389
|
+
// facts visible and stay quiet about it.
|
|
390
|
+
snapshot.error = undefined
|
|
391
|
+
this.snapshots.set(route.id, snapshot)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
let probed = false
|
|
395
|
+
const runProbe = async (kind: 'balance' | 'plan'): Promise<BalanceView | PlanView | undefined> => {
|
|
396
|
+
const half = adapter[kind]
|
|
397
|
+
if (half === undefined) return undefined
|
|
398
|
+
try {
|
|
399
|
+
const spec = half.build({ apiKey: credential.key as string })
|
|
400
|
+
const response = await fetch(spec.url, { headers: spec.headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
|
|
401
|
+
const body: unknown = await response.json().catch(() => undefined)
|
|
402
|
+
if (!response.ok) throw new Error(providerErrorMessage(response.status, body))
|
|
403
|
+
const parsed = half.parse(response.status, body)
|
|
404
|
+
if (parsed === undefined) throw new Error('unrecognized response shape')
|
|
405
|
+
probed = true
|
|
406
|
+
const updatedAt = Date.now()
|
|
407
|
+
if (kind === 'balance') {
|
|
408
|
+
const fact = parsed as BalanceParse
|
|
409
|
+
return { currency: fact.currency, totalBalance: fact.totalBalance, updatedAt }
|
|
410
|
+
}
|
|
411
|
+
const plan = parsed as PlanParse
|
|
412
|
+
return { planName: plan.planName, windows: plan.windows, updatedAt }
|
|
413
|
+
} catch (error) {
|
|
414
|
+
snapshot.error = error instanceof Error ? error.message.slice(0, 200) : String(error)
|
|
415
|
+
return undefined
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const balance = await runProbe('balance')
|
|
419
|
+
// A failed probe keeps the previous fact visible (stale but useful), with
|
|
420
|
+
// the error line explaining why it is stale.
|
|
421
|
+
if (balance !== undefined) snapshot.balance = balance as BalanceView
|
|
422
|
+
const plan = await runProbe('plan')
|
|
423
|
+
if (plan !== undefined) snapshot.plan = plan as PlanView
|
|
424
|
+
if (probed) snapshot.error = undefined
|
|
425
|
+
if (snapshot.balance === undefined && snapshot.plan === undefined && snapshot.error === undefined) {
|
|
426
|
+
snapshot.error = 'no credential configured'
|
|
427
|
+
}
|
|
428
|
+
this.snapshots.set(route.id, snapshot)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Resolve the credential backing one route: pi-ai credential records
|
|
433
|
+
* first, then the profile's apiKeyEnv reference, then the DeepSeek
|
|
434
|
+
* official adapter's env reference. OAuth grants resolve to a kind with
|
|
435
|
+
* no key (this plugin does not spend third-party OAuth budgets).
|
|
436
|
+
*/
|
|
437
|
+
private async resolveCredential(provider: string): Promise<ResolvedCredential> {
|
|
438
|
+
const credentials = service<{
|
|
439
|
+
readRecord(key: unknown): Promise<{ kind: string; key?: string; env?: Record<string, string> } | undefined>
|
|
440
|
+
resolve(ref: unknown): Promise<{ value: string } | undefined>
|
|
441
|
+
}>(this.ctx, 'credentials')
|
|
442
|
+
if (credentials === undefined) return { kind: 'none' }
|
|
443
|
+
try {
|
|
444
|
+
const record = await credentials.readRecord(credentialKey('llm-pi-ai', provider))
|
|
445
|
+
if (record?.kind === 'api-key' && typeof record.key === 'string' && record.key !== '') {
|
|
446
|
+
return { kind: 'api-key', key: record.key }
|
|
447
|
+
}
|
|
448
|
+
if (record?.kind === 'grant') return { kind: 'oauth' }
|
|
449
|
+
} catch {
|
|
450
|
+
// Invalid record ids read as absent.
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const profile = this.piAiProfile(provider)
|
|
454
|
+
const envName = profile?.apiKeyEnv ?? (provider === 'deepseek' ? this.deepseekApiKeyEnv() : undefined)
|
|
455
|
+
if (typeof envName === 'string' && envName !== '') {
|
|
456
|
+
const resolved = await credentials.resolve(credentialRef(envName))
|
|
457
|
+
if (resolved?.value !== undefined && resolved.value !== '') return { kind: 'env', key: resolved.value }
|
|
458
|
+
}
|
|
459
|
+
} catch {
|
|
460
|
+
// Malformed references read as absent.
|
|
461
|
+
}
|
|
462
|
+
return { kind: 'none' }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The llm-pi-ai profile object for one provider route, when configured. */
|
|
466
|
+
private piAiProfile(provider: string): { apiKeyEnv?: string; baseURL?: string } | undefined {
|
|
467
|
+
const section = readNamespace(this.ctx, 'llm-pi-ai') as { providers?: Record<string, { apiKeyEnv?: string; baseURL?: string }> } | undefined
|
|
468
|
+
return section?.providers?.[provider]
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The DeepSeek official adapter's credential reference name. */
|
|
472
|
+
private deepseekApiKeyEnv(): string {
|
|
473
|
+
const section = readNamespace(this.ctx, 'llm-deepseek') as { apiKeyEnv?: string } | undefined
|
|
474
|
+
return section?.apiKeyEnv ?? 'DEEPSEEK_API_KEY'
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ------------------------------------------------------------------
|
|
478
|
+
// Pet bubble
|
|
479
|
+
// ------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Announce the current provider's balance or plan usage to the pet. In
|
|
483
|
+
* `change` mode only meaningful value changes re-announce; `off` skips.
|
|
484
|
+
*/
|
|
485
|
+
private announceCurrent(): void {
|
|
486
|
+
if (this.options.bubbleMode === 'off') return
|
|
487
|
+
let provider = this.current.provider
|
|
488
|
+
if (provider === undefined) {
|
|
489
|
+
const fallback = readNamespace(this.ctx, 'agent-default-model') as { provider?: string; model?: string } | undefined
|
|
490
|
+
if (fallback?.provider !== undefined) {
|
|
491
|
+
provider = fallback.provider
|
|
492
|
+
this.current = { provider: fallback.provider, model: fallback.model, source: 'default' }
|
|
493
|
+
} else {
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const snapshot = this.snapshots.get(provider)
|
|
498
|
+
if (snapshot === undefined) return
|
|
499
|
+
let announcement: Record<string, unknown> | undefined
|
|
500
|
+
if (snapshot.balance !== undefined) {
|
|
501
|
+
announcement = {
|
|
502
|
+
kind: 'balance',
|
|
503
|
+
title: snapshot.displayName,
|
|
504
|
+
amount: formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance),
|
|
505
|
+
tone: 'ok',
|
|
506
|
+
}
|
|
507
|
+
} else if (snapshot.plan !== undefined && snapshot.plan.windows.length > 0) {
|
|
508
|
+
const window = [...snapshot.plan.windows].sort((a, b) => (b.percent ?? 0) - (a.percent ?? 0))[0]
|
|
509
|
+
announcement = {
|
|
510
|
+
kind: 'plan',
|
|
511
|
+
title: snapshot.displayName,
|
|
512
|
+
...(window.percent !== undefined ? { percent: window.percent } : {}),
|
|
513
|
+
...(window.resetsAt !== undefined ? { resetAt: window.resetsAt } : {}),
|
|
514
|
+
...(snapshot.plan.planName !== undefined ? { note: snapshot.plan.planName } : {}),
|
|
515
|
+
tone: window.percent !== undefined ? planTone(window.percent) : 'ok',
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
const signature = JSON.stringify(announcement)
|
|
521
|
+
if (this.options.bubbleMode === 'change' && signature === this.lastSignature) return
|
|
522
|
+
this.lastSignature = signature
|
|
523
|
+
const pet = service<{ announce(input: Record<string, unknown>): void }>(this.ctx, 'pet')
|
|
524
|
+
try {
|
|
525
|
+
pet?.announce({ source: USAGE_ANNOUNCE_SOURCE, ttlMs: undefined, ...announcement })
|
|
526
|
+
} catch {
|
|
527
|
+
// A missing or failing pet service must never break the poll loop.
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
3
|
+
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
4
|
+
import z from 'schemastery'
|
|
5
|
+
import { mountOnce } from './mount-once.ts'
|
|
6
|
+
import { UsageService, type UsageServiceOptions } from './host/usage-service.ts'
|
|
7
|
+
import { makeUsageOverviewRoute, makeUsageRefreshRoute } from './host/routes.ts'
|
|
8
|
+
|
|
9
|
+
export const name = 'dsh-usage'
|
|
10
|
+
export const inject = ['webServer']
|
|
11
|
+
export const USAGE_SETTINGS_NAMESPACE = settingsNamespace('dsh-usage')
|
|
12
|
+
|
|
13
|
+
export interface Config {
|
|
14
|
+
enabled?: boolean
|
|
15
|
+
/** Provider probe cycle in seconds; 30-3600. */
|
|
16
|
+
pollIntervalSec?: number
|
|
17
|
+
/** Pet bubble mode: always (refreshes each poll), change (only on value change), off. */
|
|
18
|
+
bubbleMode?: string
|
|
19
|
+
/** Ledger retention in local days. */
|
|
20
|
+
retainDays?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Config: z<Config> = z.object({
|
|
24
|
+
enabled: z.boolean().default(true),
|
|
25
|
+
pollIntervalSec: z.number().min(30).max(3600).default(60),
|
|
26
|
+
bubbleMode: z.string().default('always'),
|
|
27
|
+
retainDays: z.number().min(7).max(730).default(180),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export interface ResolvedConfig extends UsageServiceOptions {
|
|
31
|
+
enabled: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveConfig(config?: Config): ResolvedConfig {
|
|
35
|
+
const bubbleMode = config?.bubbleMode === 'change' || config?.bubbleMode === 'off' ? config.bubbleMode : 'always'
|
|
36
|
+
return {
|
|
37
|
+
enabled: config?.enabled ?? true,
|
|
38
|
+
pollIntervalSec: typeof config?.pollIntervalSec === 'number' ? config.pollIntervalSec : 60,
|
|
39
|
+
bubbleMode,
|
|
40
|
+
retainDays: typeof config?.retainDays === 'number' ? config.retainDays : 180,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?: Config): void => {
|
|
45
|
+
let source: () => Config = () => config ?? {}
|
|
46
|
+
let service: UsageService | undefined
|
|
47
|
+
let disposeRoutes: (() => void) | undefined
|
|
48
|
+
|
|
49
|
+
const rearm = (): void => {
|
|
50
|
+
const value = resolveConfig(source())
|
|
51
|
+
if (!value.enabled) {
|
|
52
|
+
service?.stop()
|
|
53
|
+
service = undefined
|
|
54
|
+
disposeRoutes?.()
|
|
55
|
+
disposeRoutes = undefined
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (service === undefined) {
|
|
59
|
+
service = new UsageService(ctx, value)
|
|
60
|
+
service.start()
|
|
61
|
+
const disposers = [makeUsageOverviewRoute(service), makeUsageRefreshRoute(service)]
|
|
62
|
+
.map((route) => ctx.webServer.register(route))
|
|
63
|
+
disposeRoutes = () => {
|
|
64
|
+
for (const dispose of disposers) {
|
|
65
|
+
try {
|
|
66
|
+
dispose()
|
|
67
|
+
} catch {
|
|
68
|
+
// Route fiber already gone during shutdown.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
service.applyOptions(value)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
installSettingsSection(ctx, USAGE_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
78
|
+
setSource: (next) => { source = next; rearm() },
|
|
79
|
+
onChange: rearm,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
ctx.effect(() => {
|
|
83
|
+
rearm()
|
|
84
|
+
return () => {
|
|
85
|
+
disposeRoutes?.()
|
|
86
|
+
service?.stop()
|
|
87
|
+
service = undefined
|
|
88
|
+
}
|
|
89
|
+
}, 'dsh-usage: runtime')
|
|
90
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
+
* (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
6
|
+
* without this guard the second instance would still re-register the same
|
|
7
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
9
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
10
|
+
* package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* The registry rides a global symbol so two module instances of the same
|
|
13
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
14
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
|
|
19
|
+
|
|
20
|
+
interface MountRegistry {
|
|
21
|
+
[MOUNTED]?: Set<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mountedSet(): Set<string> {
|
|
25
|
+
const registry = globalThis as MountRegistry
|
|
26
|
+
return (registry[MOUNTED] ??= new Set())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
32
|
+
* any later mount of the same package name is a no-op.
|
|
33
|
+
* @param packageName - npm package identity shared by every install source.
|
|
34
|
+
* @param fn - the original plugin apply.
|
|
35
|
+
* @returns an apply of the same shape.
|
|
36
|
+
*/
|
|
37
|
+
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
+
return ((...args: unknown[]) => {
|
|
39
|
+
const mounted = mountedSet()
|
|
40
|
+
if (mounted.has(packageName)) return
|
|
41
|
+
mounted.add(packageName)
|
|
42
|
+
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
|
+
ctx?.effect?.(() => () => {
|
|
44
|
+
mounted.delete(packageName)
|
|
45
|
+
})
|
|
46
|
+
return fn(...args)
|
|
47
|
+
}) as T
|
|
48
|
+
}
|