@linxin666/dsh-usage 0.3.23 → 0.4.0

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