@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.
- package/README.i18n.yaml +2 -2
- package/README.md +7 -9
- package/README.zh.md +7 -9
- package/icon.svg +3 -0
- package/lib/client.js +621 -65
- package/lib/client.js.map +1 -1
- package/lib/index.js +258 -271
- package/lib/types/client/UsageFootCard.d.ts +55 -0
- package/lib/types/client/UsageSectionCard.d.ts +3 -4
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/foot-card-mount.d.ts +34 -0
- package/lib/types/client/index.d.ts +21 -8
- package/lib/types/client/locales.d.ts +6 -4
- package/lib/types/core/provider-routes.d.ts +26 -0
- package/lib/types/core/types.d.ts +14 -1
- package/lib/types/host/access.d.ts +17 -0
- package/lib/types/host/http.d.ts +18 -1
- package/lib/types/host/pair-access.d.ts +34 -0
- package/lib/types/host/routes.d.ts +16 -7
- package/lib/types/host/usage-service.d.ts +32 -76
- package/lib/types/index.d.ts +14 -2
- package/package.json +20 -17
- package/src/client/UsageFootCard.tsx +300 -0
- package/src/client/UsageSectionCard.tsx +41 -23
- package/src/client/body-mutations.ts +133 -0
- package/src/client/foot-card-mount.tsx +112 -0
- package/src/client/index.ts +57 -16
- package/src/client/locales.ts +12 -8
- package/src/client/usage.module.css +188 -0
- package/src/core/adapters.ts +40 -6
- package/src/core/provider-routes.ts +37 -0
- package/src/core/types.ts +14 -1
- package/src/host/access.ts +21 -0
- package/src/host/http.ts +23 -1
- package/src/host/pair-access.ts +49 -0
- package/src/host/routes.ts +19 -10
- package/src/host/usage-service.ts +74 -218
- 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
|
+
}
|
package/src/host/routes.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 (!
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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 (!
|
|
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
|
|
5
|
-
*
|
|
6
|
-
* overview document.
|
|
4
|
+
* a poll cycle, and serves the current provider's strip-ready view inside
|
|
5
|
+
* the overview document. Secrets stay in the host process; the browser only
|
|
6
|
+
* ever sees the overview document.
|
|
7
7
|
* @module @linxin666/dsh-usage/host/usage-service
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
284
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
let cost = 0
|
|
292
|
+
let merged = emptyTotals()
|
|
373
293
|
for (const row of this.daySummary(localDateKey(Date.now())).providers) {
|
|
374
|
-
|
|
294
|
+
const same = family === undefined ? row.provider === provider : adapterFor(row.provider) === family
|
|
295
|
+
if (same) merged = addTotals(merged, row.totals)
|
|
375
296
|
}
|
|
376
|
-
return
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** Today's ledger totals for one provider route (the exact id, not the family). */
|
|
380
|
-
private providerUsageToday(provider: string): UsageTokenTotals {
|
|
381
|
-
const row = this.daySummary(localDateKey(Date.now())).providers.find((entry) => entry.provider === provider)
|
|
382
|
-
return row?.totals ?? emptyTotals()
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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, {
|
|
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
|
}
|