@linxin666/dsh-usage 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +4 -3
- package/README.md +10 -4
- package/README.zh.md +10 -4
- package/lib/client.js +652 -48
- package/lib/client.js.map +1 -1
- package/lib/index.js +560 -368
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/core/adapters.d.ts +10 -1
- package/lib/types/core/ledger.d.ts +15 -2
- package/lib/types/core/pricing.d.ts +30 -0
- package/lib/types/core/types.d.ts +34 -1
- package/lib/types/host/usage-service.d.ts +72 -11
- package/lib/types/index.d.ts +2 -1
- package/package.json +15 -15
- package/src/client/UsageSectionCard.tsx +138 -55
- package/src/client/index.ts +11 -2
- package/src/client/locales.ts +8 -0
- package/src/client/usage.module.css +71 -12
- package/src/core/adapters.ts +84 -3
- package/src/core/ledger.ts +65 -3
- package/src/core/pricing.ts +109 -0
- package/src/core/types.ts +37 -2
- package/src/host/usage-service.ts +300 -131
- package/src/index.ts +30 -17
|
@@ -7,18 +7,18 @@
|
|
|
7
7
|
* @module @linxin666/dsh-usage/host/usage-service
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { mkdir, readFile, rename,
|
|
10
|
+
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
|
|
11
11
|
import { dirname, join } from 'node:path'
|
|
12
12
|
import type { Context } from '@deepseek-ai/cordis'
|
|
13
13
|
import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
14
14
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
15
15
|
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
|
16
|
-
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
17
16
|
import { dshHome } from '../dsh-home.ts'
|
|
18
|
-
import { adapterFor, providerErrorMessage } from '../core/adapters.ts'
|
|
17
|
+
import { adapterFor, isDeepSeekProviderRoute, providerErrorMessage } from '../core/adapters.ts'
|
|
19
18
|
import type { BalanceParse, PlanParse } from '../core/adapters.ts'
|
|
20
|
-
import {
|
|
21
|
-
import
|
|
19
|
+
import { deepseekModelSpend, deepseekPeriodAt } from '../core/pricing.ts'
|
|
20
|
+
import { createLedgerDocument, deserializeLedger, foldUsage, ledgerDayKeys, localDateKey, pruneLedger, summarizeDays } from '../core/ledger.ts'
|
|
21
|
+
import type { BalanceView, CredentialKind, PlanView, ProviderSnapshotState, ProviderSnapshotView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
|
|
22
22
|
import { emptyTotals } from '../core/types.ts'
|
|
23
23
|
|
|
24
24
|
/** Source tag the plugin stamps onto pet announcements. */
|
|
@@ -27,7 +27,10 @@ export const USAGE_ANNOUNCE_SOURCE = 'dsh-usage'
|
|
|
27
27
|
/** Per-route credential fact the probes run against. */
|
|
28
28
|
interface ResolvedCredential {
|
|
29
29
|
kind: CredentialKind
|
|
30
|
+
/** The probe secret: an API key, or an OAuth access token for oauth-aware adapters. */
|
|
30
31
|
key?: string
|
|
32
|
+
/** Provider account id riding a dedicated header, when the credential carries one. */
|
|
33
|
+
accountId?: string
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
/** One live LLM provider route the service knows about. */
|
|
@@ -69,6 +72,66 @@ export function planTone(percent: number): 'ok' | 'warn' | 'low' {
|
|
|
69
72
|
return 'ok'
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
/** Announcement context the service computes per poll: today's family spend and the DeepSeek period. */
|
|
76
|
+
export interface AnnounceContext {
|
|
77
|
+
/** Today's ledger spend for the announced provider's family (CNY; 0 = unpriced). */
|
|
78
|
+
todayCost?: number
|
|
79
|
+
/** Whether DeepSeek peak pricing is in effect right now. */
|
|
80
|
+
peak?: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the raw pet announce payload for one provider snapshot, or
|
|
85
|
+
* undefined when nothing worth announcing exists. Pure: every payload this
|
|
86
|
+
* returns satisfies the pet's `parseAnnouncement` contract — plan
|
|
87
|
+
* announcements require a numeric percent, so percent-less windows never
|
|
88
|
+
* announce (the pet validator would silently drop them). A priced family
|
|
89
|
+
* (DeepSeek) with spend today announces a cost bubble first; balance-only
|
|
90
|
+
* families announce the balance; plan families announce their tightest
|
|
91
|
+
* percent window.
|
|
92
|
+
*/
|
|
93
|
+
export function buildAnnouncement(
|
|
94
|
+
snapshot: Pick<ProviderSnapshotView, 'displayName' | 'balance' | 'plan'>,
|
|
95
|
+
context?: AnnounceContext,
|
|
96
|
+
): Record<string, unknown> | undefined {
|
|
97
|
+
const todayCost = context?.todayCost ?? 0
|
|
98
|
+
if (todayCost > 0) {
|
|
99
|
+
const peak = context?.peak ?? false
|
|
100
|
+
const noteParts = [
|
|
101
|
+
peak ? '高峰时段 计价×2' : '空闲时段 计价减半',
|
|
102
|
+
...(snapshot.balance !== undefined ? [`余额 ${formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance)}`] : []),
|
|
103
|
+
]
|
|
104
|
+
return {
|
|
105
|
+
kind: 'cost',
|
|
106
|
+
title: snapshot.displayName,
|
|
107
|
+
amount: `今日 ${formatMoney('CNY', todayCost.toFixed(2))}`,
|
|
108
|
+
...(noteParts.length > 0 ? { note: noteParts.join(' · ') } : {}),
|
|
109
|
+
tone: peak ? 'warn' : 'ok',
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (snapshot.balance !== undefined) {
|
|
113
|
+
return {
|
|
114
|
+
kind: 'balance',
|
|
115
|
+
title: snapshot.displayName,
|
|
116
|
+
amount: formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance),
|
|
117
|
+
tone: 'ok',
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (snapshot.plan === undefined) return undefined
|
|
121
|
+
const window = snapshot.plan.windows
|
|
122
|
+
.filter((entry) => typeof entry.percent === 'number')
|
|
123
|
+
.sort((a, b) => (b.percent ?? 0) - (a.percent ?? 0))[0]
|
|
124
|
+
if (window === undefined || window.percent === undefined) return undefined
|
|
125
|
+
return {
|
|
126
|
+
kind: 'plan',
|
|
127
|
+
title: snapshot.displayName,
|
|
128
|
+
percent: window.percent,
|
|
129
|
+
...(window.resetsAt !== undefined ? { resetAt: window.resetsAt } : {}),
|
|
130
|
+
...(snapshot.plan.planName !== undefined ? { note: snapshot.plan.planName } : {}),
|
|
131
|
+
tone: planTone(window.percent),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
72
135
|
/** Best-effort typed service read: absent services resolve to undefined at runtime. */
|
|
73
136
|
function service<T>(ctx: Context, name: string): T | undefined {
|
|
74
137
|
try {
|
|
@@ -78,11 +141,27 @@ function service<T>(ctx: Context, name: string): T | undefined {
|
|
|
78
141
|
}
|
|
79
142
|
}
|
|
80
143
|
|
|
81
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* Atomic JSON write through a unique temp file + fsync + rename. The temp
|
|
146
|
+
* name is per-call so two overlapping flushes can never interleave into one
|
|
147
|
+
* temp file, and the fsync closes the rename-lands-but-bytes-are-not-durable
|
|
148
|
+
* window.
|
|
149
|
+
*/
|
|
82
150
|
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
|
83
|
-
const temp = path
|
|
84
|
-
|
|
85
|
-
|
|
151
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
152
|
+
try {
|
|
153
|
+
const handle = await open(temp, 'w')
|
|
154
|
+
try {
|
|
155
|
+
await handle.writeFile(JSON.stringify(value, null, 1), 'utf8')
|
|
156
|
+
await handle.sync()
|
|
157
|
+
} finally {
|
|
158
|
+
await handle.close()
|
|
159
|
+
}
|
|
160
|
+
await rename(temp, path)
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await unlink(temp).catch(() => {})
|
|
163
|
+
throw error
|
|
164
|
+
}
|
|
86
165
|
}
|
|
87
166
|
|
|
88
167
|
/**
|
|
@@ -94,7 +173,7 @@ function readNamespace(ctx: Context, ns: string): unknown {
|
|
|
94
173
|
try {
|
|
95
174
|
const settings = service<{ get(ns: unknown): unknown }>(ctx, 'settings')
|
|
96
175
|
if (settings === undefined) return undefined
|
|
97
|
-
return settings.get(
|
|
176
|
+
return settings.get(ns)
|
|
98
177
|
} catch {
|
|
99
178
|
return undefined
|
|
100
179
|
}
|
|
@@ -108,7 +187,7 @@ export class UsageService {
|
|
|
108
187
|
private readonly snapshotsPath: string
|
|
109
188
|
|
|
110
189
|
private ledger: UsageLedgerDocument = createLedgerDocument()
|
|
111
|
-
private readonly snapshots = new Map<string,
|
|
190
|
+
private readonly snapshots = new Map<string, ProviderSnapshotState>()
|
|
112
191
|
/** Per-live-session route attribution (WeakMap: disposed sessions age out). */
|
|
113
192
|
private readonly sessionRoutes = new WeakMap<Session, { provider: string; model: string }>()
|
|
114
193
|
/** The most recent route seen this boot; the pet bubble follows it. */
|
|
@@ -118,8 +197,16 @@ export class UsageService {
|
|
|
118
197
|
private pollTimer: ReturnType<typeof setTimeout> | undefined
|
|
119
198
|
private flushTimer: ReturnType<typeof setTimeout> | undefined
|
|
120
199
|
private pollInFlight = false
|
|
200
|
+
/** The running poll cycle; manual refresh joins it instead of no-oping. */
|
|
201
|
+
private pollPromise: Promise<void> | undefined
|
|
202
|
+
/** Serialized ledger flushes: overlapping debounce/stop flushes queue, never interleave. */
|
|
203
|
+
private flushChain: Promise<void> = Promise.resolve()
|
|
204
|
+
/** True once loadPersisted finished; nothing may overwrite the files before that. */
|
|
205
|
+
private loaded = false
|
|
121
206
|
private disposed = false
|
|
122
207
|
private lastSignature: string | undefined
|
|
208
|
+
/** Last prune guard: once per local day, and whenever retention shrinks. */
|
|
209
|
+
private lastPrune: { dayKey: string; retainDays: number } | undefined
|
|
123
210
|
|
|
124
211
|
constructor(ctx: Context, options: UsageServiceOptions) {
|
|
125
212
|
this.ctx = ctx
|
|
@@ -136,21 +223,26 @@ export class UsageService {
|
|
|
136
223
|
this.rearmPoll(2_000)
|
|
137
224
|
}
|
|
138
225
|
|
|
139
|
-
/**
|
|
140
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Stop timers and flush pending ledger writes. The returned promise
|
|
228
|
+
* resolves after the final flush lands, so a successor instance (quick
|
|
229
|
+
* disable → enable) can serialize its first load behind it.
|
|
230
|
+
*/
|
|
231
|
+
stop(): Promise<void> {
|
|
141
232
|
this.disposed = true
|
|
142
233
|
if (this.pollTimer !== undefined) clearTimeout(this.pollTimer)
|
|
143
234
|
if (this.flushTimer !== undefined) clearTimeout(this.flushTimer)
|
|
144
235
|
this.sessionListenerDisposer?.()
|
|
145
|
-
|
|
236
|
+
return this.flushLedger()
|
|
146
237
|
}
|
|
147
238
|
|
|
148
|
-
/** Re-apply options live (settings change);
|
|
239
|
+
/** Re-apply options live (settings change); retention shrink prunes now. */
|
|
149
240
|
applyOptions(options: UsageServiceOptions): void {
|
|
150
241
|
this.options = options
|
|
242
|
+
this.pruneIfNeeded()
|
|
151
243
|
}
|
|
152
244
|
|
|
153
|
-
/** Force one poll now (manual refresh route). */
|
|
245
|
+
/** Force one poll now (manual refresh route); joins an in-flight cycle. */
|
|
154
246
|
async refresh(): Promise<void> {
|
|
155
247
|
await this.pollNow()
|
|
156
248
|
}
|
|
@@ -163,19 +255,23 @@ export class UsageService {
|
|
|
163
255
|
for (const route of routes) {
|
|
164
256
|
const adapter = adapterFor(route.id)
|
|
165
257
|
const snapshot = this.snapshots.get(route.id)
|
|
258
|
+
const error = snapshot?.balanceError ?? snapshot?.planError
|
|
166
259
|
providers.push({
|
|
167
260
|
provider: route.id,
|
|
168
261
|
displayName: route.displayName || adapter?.displayName || route.id,
|
|
169
262
|
credential: snapshot?.credential ?? 'none',
|
|
170
263
|
supported: adapter !== undefined && (adapter.balance !== undefined || adapter.plan !== undefined),
|
|
264
|
+
...(adapter?.balance !== undefined ? { balanceSupported: true } : {}),
|
|
265
|
+
...(adapter?.plan !== undefined ? { planSupported: true } : {}),
|
|
171
266
|
...(snapshot?.balance !== undefined ? { balance: snapshot.balance } : {}),
|
|
172
267
|
...(snapshot?.plan !== undefined ? { plan: snapshot.plan } : {}),
|
|
173
|
-
...(
|
|
268
|
+
...(error !== undefined ? { error } : {}),
|
|
174
269
|
...(snapshot?.updatedAt !== undefined ? { updatedAt: snapshot.updatedAt } : {}),
|
|
175
270
|
})
|
|
176
271
|
}
|
|
177
272
|
providers.sort((a, b) => Number(b.supported) - Number(a.supported) || a.displayName.localeCompare(b.displayName))
|
|
178
273
|
const days = ledgerDayKeys(this.ledger).slice(-TREND_DAYS)
|
|
274
|
+
const range = summarizeDays(days.map((key) => this.ledger.days[key] ?? {}))
|
|
179
275
|
return {
|
|
180
276
|
updatedAt: Date.now(),
|
|
181
277
|
providers,
|
|
@@ -186,6 +282,12 @@ export class UsageService {
|
|
|
186
282
|
const summary = this.daySummary(date)
|
|
187
283
|
return { date, totals: summary.totals }
|
|
188
284
|
}),
|
|
285
|
+
range: {
|
|
286
|
+
from: days[0] ?? todayKey,
|
|
287
|
+
to: days[days.length - 1] ?? todayKey,
|
|
288
|
+
totals: range.totals,
|
|
289
|
+
providers: range.providers,
|
|
290
|
+
},
|
|
189
291
|
},
|
|
190
292
|
}
|
|
191
293
|
}
|
|
@@ -193,33 +295,21 @@ export class UsageService {
|
|
|
193
295
|
/** One local day aggregated per provider. */
|
|
194
296
|
private daySummary(dateKey: string): UsageOverviewView['usage']['today'] {
|
|
195
297
|
const day = this.ledger.days[dateKey] ?? {}
|
|
196
|
-
const providers =
|
|
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
|
-
}
|
|
298
|
+
const { totals, providers } = summarizeDays([day])
|
|
220
299
|
return { date: dateKey, totals, providers }
|
|
221
300
|
}
|
|
222
301
|
|
|
302
|
+
/** Today's ledger spend for one provider's adapter family (0 when unpriced). */
|
|
303
|
+
private familyCostToday(provider: string): number {
|
|
304
|
+
const family = adapterFor(provider)
|
|
305
|
+
if (family === undefined) return 0
|
|
306
|
+
let cost = 0
|
|
307
|
+
for (const row of this.daySummary(localDateKey(Date.now())).providers) {
|
|
308
|
+
if (adapterFor(row.provider) === family) cost += row.totals.cost
|
|
309
|
+
}
|
|
310
|
+
return cost
|
|
311
|
+
}
|
|
312
|
+
|
|
223
313
|
// ------------------------------------------------------------------
|
|
224
314
|
// Session usage fold
|
|
225
315
|
// ------------------------------------------------------------------
|
|
@@ -243,7 +333,7 @@ export class UsageService {
|
|
|
243
333
|
if (usage === undefined) return
|
|
244
334
|
const route = this.sessionRoutes.get(session)
|
|
245
335
|
if (route === undefined || route.provider === '') return
|
|
246
|
-
foldUsage(this.ledger, Date.now(), route.provider, route.model || 'unknown', this.totalsFrom(usage))
|
|
336
|
+
foldUsage(this.ledger, Date.now(), route.provider, route.model || 'unknown', this.totalsFrom(usage, route.provider, route.model || 'unknown', Date.now()))
|
|
247
337
|
this.scheduleFlush()
|
|
248
338
|
}
|
|
249
339
|
} catch {
|
|
@@ -251,8 +341,12 @@ export class UsageService {
|
|
|
251
341
|
}
|
|
252
342
|
}
|
|
253
343
|
|
|
254
|
-
/**
|
|
255
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Normalize a provider TokenUsage into the ledger bucket (one call). The
|
|
346
|
+
* DeepSeek official family is priced at the fold instant (its billing
|
|
347
|
+
* period is time-of-day); other families stay unpriced (cost 0).
|
|
348
|
+
*/
|
|
349
|
+
private totalsFrom(usage: TokenUsage, provider: string, model: string, atMs: number): UsageTokenTotals {
|
|
256
350
|
const totals = emptyTotals()
|
|
257
351
|
totals.inputTokens = usage.inputTokens
|
|
258
352
|
totals.outputTokens = usage.outputTokens
|
|
@@ -260,6 +354,7 @@ export class UsageService {
|
|
|
260
354
|
totals.cacheWriteTokens = usage.cacheWriteTokens ?? 0
|
|
261
355
|
totals.reasoningTokens = usage.reasoningTokens ?? 0
|
|
262
356
|
totals.calls = 1
|
|
357
|
+
if (isDeepSeekProviderRoute(provider)) totals.cost = deepseekModelSpend(model, totals, atMs)
|
|
263
358
|
return totals
|
|
264
359
|
}
|
|
265
360
|
|
|
@@ -274,11 +369,22 @@ export class UsageService {
|
|
|
274
369
|
readFile(this.snapshotsPath, 'utf8').catch(() => undefined),
|
|
275
370
|
])
|
|
276
371
|
if (rawLedger !== undefined) {
|
|
277
|
-
|
|
372
|
+
const loaded = deserializeLedger(JSON.parse(rawLedger))
|
|
373
|
+
// Merge into (never replace) the live document: folds that landed
|
|
374
|
+
// during the read window must survive the load.
|
|
375
|
+
for (const [dayKey, providers] of Object.entries(loaded.days)) {
|
|
376
|
+
const atMs = new Date(dayKey + 'T12:00:00').getTime()
|
|
377
|
+
for (const [provider, models] of Object.entries(providers)) {
|
|
378
|
+
for (const [model, totals] of Object.entries(models)) {
|
|
379
|
+
foldUsage(this.ledger, atMs, provider, model, totals)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
278
383
|
pruneLedger(this.ledger, localDateKey(Date.now()), this.options.retainDays)
|
|
384
|
+
this.lastPrune = { dayKey: localDateKey(Date.now()), retainDays: this.options.retainDays }
|
|
279
385
|
}
|
|
280
386
|
if (rawSnapshots !== undefined) {
|
|
281
|
-
const parsed = JSON.parse(rawSnapshots) as { providers?: Record<string,
|
|
387
|
+
const parsed = JSON.parse(rawSnapshots) as { providers?: Record<string, ProviderSnapshotState> }
|
|
282
388
|
if (typeof parsed === 'object' && parsed !== null && typeof parsed.providers === 'object' && parsed.providers !== null) {
|
|
283
389
|
for (const [provider, snapshot] of Object.entries(parsed.providers)) {
|
|
284
390
|
if (typeof snapshot === 'object' && snapshot !== null && typeof snapshot.provider === 'string') {
|
|
@@ -289,6 +395,9 @@ export class UsageService {
|
|
|
289
395
|
}
|
|
290
396
|
} catch {
|
|
291
397
|
// Corrupt or unreadable state starts fresh; the next flush rewrites it.
|
|
398
|
+
} finally {
|
|
399
|
+
// Only now may flushes overwrite the persisted files.
|
|
400
|
+
this.loaded = true
|
|
292
401
|
}
|
|
293
402
|
}
|
|
294
403
|
|
|
@@ -300,7 +409,22 @@ export class UsageService {
|
|
|
300
409
|
}, FLUSH_DEBOUNCE_MS)
|
|
301
410
|
}
|
|
302
411
|
|
|
303
|
-
|
|
412
|
+
/**
|
|
413
|
+
* Queue one ledger flush behind the previous one. Debounce and stop-time
|
|
414
|
+
* flushes serialize here, so two `writeJsonAtomic` calls can never run
|
|
415
|
+
* concurrently on the same path.
|
|
416
|
+
*/
|
|
417
|
+
private flushLedger(): Promise<void> {
|
|
418
|
+
const next = this.flushChain.then(() => this.writeLedgerOnce())
|
|
419
|
+
this.flushChain = next.catch(() => {})
|
|
420
|
+
return next
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async writeLedgerOnce(): Promise<void> {
|
|
424
|
+
// Never overwrite the persisted file before it has been loaded: a flush
|
|
425
|
+
// racing the load window would otherwise clobber history with a partial
|
|
426
|
+
// document.
|
|
427
|
+
if (!this.loaded) return
|
|
304
428
|
try {
|
|
305
429
|
await mkdir(dirname(this.ledgerPath), { recursive: true })
|
|
306
430
|
await writeJsonAtomic(this.ledgerPath, this.ledger)
|
|
@@ -309,7 +433,21 @@ export class UsageService {
|
|
|
309
433
|
}
|
|
310
434
|
}
|
|
311
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Prune days past retention. Runs at most once per local day and whenever
|
|
438
|
+
* `retainDays` shrinks — not only at startup, so long-lived processes and
|
|
439
|
+
* live settings changes both honor retention.
|
|
440
|
+
*/
|
|
441
|
+
private pruneIfNeeded(): void {
|
|
442
|
+
const todayKey = localDateKey(Date.now())
|
|
443
|
+
if (this.lastPrune?.dayKey === todayKey && this.lastPrune.retainDays === this.options.retainDays) return
|
|
444
|
+
const pruned = pruneLedger(this.ledger, todayKey, this.options.retainDays)
|
|
445
|
+
this.lastPrune = { dayKey: todayKey, retainDays: this.options.retainDays }
|
|
446
|
+
if (pruned > 0) this.scheduleFlush()
|
|
447
|
+
}
|
|
448
|
+
|
|
312
449
|
private async persistSnapshots(): Promise<void> {
|
|
450
|
+
if (!this.loaded) return
|
|
313
451
|
try {
|
|
314
452
|
await mkdir(dirname(this.snapshotsPath), { recursive: true })
|
|
315
453
|
await writeJsonAtomic(this.snapshotsPath, { version: 1, providers: Object.fromEntries(this.snapshots) })
|
|
@@ -331,27 +469,42 @@ export class UsageService {
|
|
|
331
469
|
}, delayMs)
|
|
332
470
|
}
|
|
333
471
|
|
|
334
|
-
/**
|
|
335
|
-
|
|
336
|
-
|
|
472
|
+
/**
|
|
473
|
+
* One poll cycle: enumerate routes, resolve credentials, probe, announce.
|
|
474
|
+
* A call while a cycle is already running joins that cycle instead of
|
|
475
|
+
* returning immediately, so a manual refresh always waits for real probes.
|
|
476
|
+
*/
|
|
477
|
+
pollNow(): Promise<void> {
|
|
478
|
+
if (this.pollInFlight) return this.pollPromise ?? Promise.resolve()
|
|
479
|
+
if (this.disposed) return Promise.resolve()
|
|
337
480
|
this.pollInFlight = true
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
seen
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
481
|
+
const cycle = (async () => {
|
|
482
|
+
try {
|
|
483
|
+
this.pruneIfNeeded()
|
|
484
|
+
const routes = this.listProviderRoutes()
|
|
485
|
+
const seen = new Set<string>()
|
|
486
|
+
for (const route of routes) {
|
|
487
|
+
// Probes serialize at up to 2 × PROBE_TIMEOUT_MS per route; bail
|
|
488
|
+
// out of a cycle that outlived the service.
|
|
489
|
+
if (this.disposed) return
|
|
490
|
+
seen.add(route.id)
|
|
491
|
+
const adapter = adapterFor(route.id)
|
|
492
|
+
if (adapter === undefined || (adapter.balance === undefined && adapter.plan === undefined)) continue
|
|
493
|
+
await this.probeRoute(route, adapter)
|
|
494
|
+
}
|
|
495
|
+
for (const id of [...this.snapshots.keys()]) {
|
|
496
|
+
if (!seen.has(id)) this.snapshots.delete(id)
|
|
497
|
+
}
|
|
498
|
+
if (this.disposed) return
|
|
499
|
+
await this.persistSnapshots()
|
|
500
|
+
this.announceCurrent()
|
|
501
|
+
} finally {
|
|
502
|
+
this.pollInFlight = false
|
|
503
|
+
this.pollPromise = undefined
|
|
349
504
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
this.pollInFlight = false
|
|
354
|
-
}
|
|
505
|
+
})()
|
|
506
|
+
this.pollPromise = cycle
|
|
507
|
+
return cycle
|
|
355
508
|
}
|
|
356
509
|
|
|
357
510
|
private listProviderRoutes(): ProviderRoute[] {
|
|
@@ -375,34 +528,36 @@ export class UsageService {
|
|
|
375
528
|
private async probeRoute(route: ProviderRoute, adapter: NonNullable<ReturnType<typeof adapterFor>>): Promise<void> {
|
|
376
529
|
const credential = await this.resolveCredential(route.id)
|
|
377
530
|
const previous = this.snapshots.get(route.id)
|
|
378
|
-
|
|
531
|
+
if (credential.key === undefined) {
|
|
532
|
+
// Nothing to probe with (oauth grant, no credential): retained facts
|
|
533
|
+
// would never refresh again, so the row resets to its credential
|
|
534
|
+
// state instead of presenting stale data as current.
|
|
535
|
+
this.snapshots.set(route.id, {
|
|
536
|
+
provider: route.id,
|
|
537
|
+
displayName: route.displayName || adapter.displayName,
|
|
538
|
+
credential: credential.kind,
|
|
539
|
+
supported: true,
|
|
540
|
+
updatedAt: Date.now(),
|
|
541
|
+
})
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
const snapshot: ProviderSnapshotState = {
|
|
379
545
|
provider: route.id,
|
|
380
546
|
displayName: route.displayName || adapter.displayName,
|
|
381
547
|
credential: credential.kind,
|
|
382
548
|
supported: true,
|
|
383
|
-
...(previous?.balance !== undefined ? { balance: previous.balance } : {}),
|
|
384
|
-
...(previous?.plan !== undefined ? { plan: previous.plan } : {}),
|
|
385
549
|
updatedAt: Date.now(),
|
|
386
550
|
}
|
|
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
551
|
const runProbe = async (kind: 'balance' | 'plan'): Promise<BalanceView | PlanView | undefined> => {
|
|
396
552
|
const half = adapter[kind]
|
|
397
553
|
if (half === undefined) return undefined
|
|
398
554
|
try {
|
|
399
|
-
const spec = half.build({ apiKey: credential.key as string })
|
|
555
|
+
const spec = half.build({ apiKey: credential.key as string, ...(credential.accountId !== undefined ? { accountId: credential.accountId } : {}) })
|
|
400
556
|
const response = await fetch(spec.url, { headers: spec.headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
|
|
401
557
|
const body: unknown = await response.json().catch(() => undefined)
|
|
402
558
|
if (!response.ok) throw new Error(providerErrorMessage(response.status, body))
|
|
403
559
|
const parsed = half.parse(response.status, body)
|
|
404
560
|
if (parsed === undefined) throw new Error('unrecognized response shape')
|
|
405
|
-
probed = true
|
|
406
561
|
const updatedAt = Date.now()
|
|
407
562
|
if (kind === 'balance') {
|
|
408
563
|
const fact = parsed as BalanceParse
|
|
@@ -411,32 +566,36 @@ export class UsageService {
|
|
|
411
566
|
const plan = parsed as PlanParse
|
|
412
567
|
return { planName: plan.planName, windows: plan.windows, updatedAt }
|
|
413
568
|
} catch (error) {
|
|
414
|
-
|
|
569
|
+
// Per-fact error slot: a success in the other probe half can no
|
|
570
|
+
// longer clear this failure (and a stale fact always carries the
|
|
571
|
+
// error line explaining why it is stale).
|
|
572
|
+
const message = error instanceof Error ? error.message.slice(0, 200) : String(error)
|
|
573
|
+
if (kind === 'balance') snapshot.balanceError = message
|
|
574
|
+
else snapshot.planError = message
|
|
415
575
|
return undefined
|
|
416
576
|
}
|
|
417
577
|
}
|
|
418
578
|
const balance = await runProbe('balance')
|
|
419
|
-
// A failed probe keeps the previous fact visible (stale but useful)
|
|
420
|
-
// the error line explaining why it is stale.
|
|
579
|
+
// A failed probe keeps the previous fact visible (stale but useful).
|
|
421
580
|
if (balance !== undefined) snapshot.balance = balance as BalanceView
|
|
581
|
+
else if (previous?.balance !== undefined) snapshot.balance = previous.balance
|
|
422
582
|
const plan = await runProbe('plan')
|
|
423
583
|
if (plan !== undefined) snapshot.plan = plan as PlanView
|
|
424
|
-
if (
|
|
425
|
-
if (snapshot.balance === undefined && snapshot.plan === undefined && snapshot.error === undefined) {
|
|
426
|
-
snapshot.error = 'no credential configured'
|
|
427
|
-
}
|
|
584
|
+
else if (previous?.plan !== undefined) snapshot.plan = previous.plan
|
|
428
585
|
this.snapshots.set(route.id, snapshot)
|
|
429
586
|
}
|
|
430
587
|
|
|
431
588
|
/**
|
|
432
589
|
* Resolve the credential backing one route: pi-ai credential records
|
|
433
590
|
* first, then the profile's apiKeyEnv reference, then the DeepSeek
|
|
434
|
-
* official adapter's env reference. OAuth grants
|
|
435
|
-
*
|
|
591
|
+
* official adapter's env reference. OAuth grants hand their stored access
|
|
592
|
+
* token to the oauth-aware adapters (Codex plan quota); a stale token
|
|
593
|
+
* fails its probe with a 401 and refreshes the next time the harness
|
|
594
|
+
* itself runs that provider.
|
|
436
595
|
*/
|
|
437
596
|
private async resolveCredential(provider: string): Promise<ResolvedCredential> {
|
|
438
597
|
const credentials = service<{
|
|
439
|
-
readRecord(key: unknown): Promise<{ kind: string; key?: string; env?: Record<string, string
|
|
598
|
+
readRecord(key: unknown): Promise<{ kind: string; key?: string; env?: Record<string, string>; payload?: unknown } | undefined>
|
|
440
599
|
resolve(ref: unknown): Promise<{ value: string } | undefined>
|
|
441
600
|
}>(this.ctx, 'credentials')
|
|
442
601
|
if (credentials === undefined) return { kind: 'none' }
|
|
@@ -445,13 +604,20 @@ export class UsageService {
|
|
|
445
604
|
if (record?.kind === 'api-key' && typeof record.key === 'string' && record.key !== '') {
|
|
446
605
|
return { kind: 'api-key', key: record.key }
|
|
447
606
|
}
|
|
448
|
-
if (record?.kind === 'grant'
|
|
607
|
+
if (record?.kind === 'grant' && typeof record.payload === 'object' && record.payload !== null) {
|
|
608
|
+
// pi-ai stores its OAuth credential verbatim: { type: 'oauth', access, refresh, expires, ... }.
|
|
609
|
+
const payload = record.payload as Record<string, unknown>
|
|
610
|
+
const access = typeof payload.access === 'string' && payload.access !== '' ? payload.access : undefined
|
|
611
|
+
const accountId = typeof payload.chatgpt_account_id === 'string' && payload.chatgpt_account_id !== '' ? payload.chatgpt_account_id : undefined
|
|
612
|
+
if (access !== undefined) return { kind: 'oauth', key: access, ...(accountId !== undefined ? { accountId } : {}) }
|
|
613
|
+
return { kind: 'oauth' }
|
|
614
|
+
}
|
|
449
615
|
} catch {
|
|
450
616
|
// Invalid record ids read as absent.
|
|
451
617
|
}
|
|
452
618
|
try {
|
|
453
619
|
const profile = this.piAiProfile(provider)
|
|
454
|
-
const envName = profile?.apiKeyEnv ?? (provider
|
|
620
|
+
const envName = profile?.apiKeyEnv ?? (isDeepSeekProviderRoute(provider) ? this.deepseekApiKeyEnv() : undefined)
|
|
455
621
|
if (typeof envName === 'string' && envName !== '') {
|
|
456
622
|
const resolved = await credentials.resolve(credentialRef(envName))
|
|
457
623
|
if (resolved?.value !== undefined && resolved.value !== '') return { kind: 'env', key: resolved.value }
|
|
@@ -479,50 +645,53 @@ export class UsageService {
|
|
|
479
645
|
// ------------------------------------------------------------------
|
|
480
646
|
|
|
481
647
|
/**
|
|
482
|
-
* Announce the current provider's balance or plan usage to the pet.
|
|
483
|
-
* `change` mode only meaningful value changes re-announce; `off` skips.
|
|
648
|
+
* Announce the current provider's spend, balance, or plan usage to the pet.
|
|
649
|
+
* In `change` mode only meaningful value changes re-announce; `off` skips.
|
|
650
|
+
* The TTL rides the poll interval (bubble_mode `always` re-announces every
|
|
651
|
+
* cycle, so a TTL of two cycles + margin keeps the bubble continuous
|
|
652
|
+
* across polls; the pet contract caps the ceiling). A route id the
|
|
653
|
+
* catalogs spell differently than the snapshot keys falls back to its
|
|
654
|
+
* adapter family's snapshot. Fully guarded: a malformed snapshot or a
|
|
655
|
+
* failing pet service must never break the poll loop, and a disposed
|
|
656
|
+
* service never announces.
|
|
484
657
|
*/
|
|
485
658
|
private announceCurrent(): void {
|
|
486
|
-
if (this.options.bubbleMode === 'off') return
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
provider
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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',
|
|
659
|
+
if (this.disposed || this.options.bubbleMode === 'off') return
|
|
660
|
+
try {
|
|
661
|
+
let provider = this.current.provider
|
|
662
|
+
if (provider === undefined) {
|
|
663
|
+
const fallback = readNamespace(this.ctx, 'agent-default-model') as { provider?: string; model?: string } | undefined
|
|
664
|
+
if (fallback?.provider !== undefined) {
|
|
665
|
+
provider = fallback.provider
|
|
666
|
+
this.current = { provider: fallback.provider, model: fallback.model, source: 'default' }
|
|
667
|
+
} else {
|
|
668
|
+
return
|
|
669
|
+
}
|
|
506
670
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
671
|
+
let snapshot = this.snapshots.get(provider)
|
|
672
|
+
if (snapshot === undefined) {
|
|
673
|
+
const family = adapterFor(provider)
|
|
674
|
+
if (family !== undefined) {
|
|
675
|
+
for (const [id, candidate] of this.snapshots) {
|
|
676
|
+
if (adapterFor(id) === family) {
|
|
677
|
+
snapshot = candidate
|
|
678
|
+
break
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
516
682
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
683
|
+
if (snapshot === undefined) return
|
|
684
|
+
const announcement = buildAnnouncement(snapshot, {
|
|
685
|
+
todayCost: this.familyCostToday(provider),
|
|
686
|
+
peak: deepseekPeriodAt(Date.now()).peak,
|
|
687
|
+
})
|
|
688
|
+
if (announcement === undefined) return
|
|
689
|
+
const signature = JSON.stringify(announcement)
|
|
690
|
+
if (this.options.bubbleMode === 'change' && signature === this.lastSignature) return
|
|
691
|
+
this.lastSignature = signature
|
|
692
|
+
const ttlMs = Math.min(7_200_000, this.options.pollIntervalSec * 2_000 + 30_000)
|
|
693
|
+
const pet = service<{ announce(input: Record<string, unknown>): void }>(this.ctx, 'pet')
|
|
694
|
+
pet?.announce({ source: USAGE_ANNOUNCE_SOURCE, ttlMs, ...announcement })
|
|
526
695
|
} catch {
|
|
527
696
|
// A missing or failing pet service must never break the poll loop.
|
|
528
697
|
}
|