@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.
@@ -7,18 +7,18 @@
7
7
  * @module @linxin666/dsh-usage/host/usage-service
8
8
  */
9
9
 
10
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
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 { 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'
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
- /** Atomic JSON write through a temp file + rename. */
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 + '.tmp'
84
- await writeFile(temp, JSON.stringify(value, null, 1), 'utf8')
85
- await rename(temp, path)
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(settingsNamespace(ns))
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, ProviderSnapshotView>()
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
- /** Stop timers and flush pending ledger writes. */
140
- stop(): void {
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
- void this.flushLedger()
236
+ return this.flushLedger()
146
237
  }
147
238
 
148
- /** Re-apply options live (settings change); the poll cycle picks them up. */
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
- ...(snapshot?.error !== undefined ? { error: snapshot.error } : {}),
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 = 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
- }
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
- /** Normalize a provider TokenUsage into the ledger bucket (one call). */
255
- private totalsFrom(usage: TokenUsage): UsageTokenTotals {
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
- this.ledger = deserializeLedger(JSON.parse(rawLedger))
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, ProviderSnapshotView> }
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
- private async flushLedger(): Promise<void> {
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
- /** One poll cycle: enumerate routes, resolve credentials, probe, announce. */
335
- async pollNow(): Promise<void> {
336
- if (this.pollInFlight || this.disposed) return
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
- 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)
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
- await this.persistSnapshots()
351
- this.announceCurrent()
352
- } finally {
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
- const snapshot: ProviderSnapshotView = {
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
- snapshot.error = error instanceof Error ? error.message.slice(0, 200) : String(error)
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), with
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 (probed) snapshot.error = undefined
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 resolve to a kind with
435
- * no key (this plugin does not spend third-party OAuth budgets).
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> } | undefined>
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') return { kind: 'oauth' }
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 === 'deepseek' ? this.deepseekApiKeyEnv() : undefined)
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. In
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
- 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',
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
- } 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',
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
- } 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 })
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
  }