@linxin666/dsh-usage 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Provider adapters: one entry per provider family this plugin can query for
3
+ * a pay-as-you-go balance or a coding-plan quota. Adapters are pure — they
4
+ * build an HTTP probe from a resolved credential and parse the response — so
5
+ * the service stays transport-only and every endpoint detail lives here.
6
+ *
7
+ * All probes run host-side (Node fetch); API keys never reach the browser.
8
+ * @module @linxin666/dsh-usage/core/adapters
9
+ */
10
+
11
+ import type { PlanWindowView } from './types.ts'
12
+
13
+ /** One HTTP probe the service executes verbatim. */
14
+ export interface ProbeSpec {
15
+ url: string
16
+ headers: Record<string, string>
17
+ }
18
+
19
+ /** Inputs an adapter may use to build a probe. */
20
+ export interface AdapterProbeContext {
21
+ /** Resolved credential value (the raw key; env refs are resolved upstream). */
22
+ apiKey: string
23
+ }
24
+
25
+ /** A parsed balance fact. */
26
+ export interface BalanceParse {
27
+ currency: string
28
+ totalBalance: string
29
+ }
30
+
31
+ /** A parsed plan-quota fact. */
32
+ export interface PlanParse {
33
+ planName?: string
34
+ windows: PlanWindowView[]
35
+ }
36
+
37
+ /** One provider family's probing capability. */
38
+ export interface ProviderAdapter {
39
+ /** Provider route keys this adapter serves. */
40
+ ids: readonly string[]
41
+ /** Fallback display name when the LLM runtime has none. */
42
+ displayName: string
43
+ balance?: {
44
+ build(context: AdapterProbeContext): ProbeSpec
45
+ parse(status: number, body: unknown): BalanceParse | undefined
46
+ }
47
+ plan?: {
48
+ build(context: AdapterProbeContext): ProbeSpec
49
+ parse(status: number, body: unknown): PlanParse | undefined
50
+ }
51
+ }
52
+
53
+ /** Parse a string/number into a finite number, else undefined. */
54
+ function toNum(value: unknown): number | undefined {
55
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
56
+ if (typeof value === 'string' && value.trim() !== '') {
57
+ const parsed = Number(value)
58
+ return Number.isFinite(parsed) ? parsed : undefined
59
+ }
60
+ return undefined
61
+ }
62
+
63
+ /** Read a string field that must be a non-empty string. */
64
+ function str(value: unknown): string | undefined {
65
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
66
+ }
67
+
68
+ /** Format a number to a fixed 2-decimal display string. */
69
+ function money(value: number): string {
70
+ return value.toFixed(2)
71
+ }
72
+
73
+ /** Millisecond epoch or ISO string → normalized ISO 8601, else undefined. */
74
+ function toIso(value: unknown): string | undefined {
75
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
76
+ const ms = value < 1e12 ? value * 1000 : value
77
+ const date = new Date(ms)
78
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
79
+ }
80
+ const text = str(value)
81
+ if (text === undefined) return undefined
82
+ const date = new Date(text)
83
+ return Number.isNaN(date.getTime()) ? text : date.toISOString()
84
+ }
85
+
86
+ /** Used-percent helper guarding zero/absent limits. */
87
+ function usedPercent(used: unknown, limit: unknown): number | undefined {
88
+ const usedNum = toNum(used)
89
+ const limitNum = toNum(limit)
90
+ if (usedNum === undefined || limitNum === undefined || limitNum <= 0) return undefined
91
+ return Math.max(0, Math.min(100, (usedNum / limitNum) * 100))
92
+ }
93
+
94
+ function bearer(apiKey: string): Record<string, string> {
95
+ return { authorization: `Bearer ${apiKey}` }
96
+ }
97
+
98
+ const DEEPSEEK: ProviderAdapter = {
99
+ ids: ['deepseek'],
100
+ displayName: 'DeepSeek',
101
+ balance: {
102
+ build: ({ apiKey }) => ({ url: 'https://api.deepseek.com/user/balance', headers: bearer(apiKey) }),
103
+ parse: (status, body) => {
104
+ if (status !== 200 || typeof body !== 'object' || body === null) return undefined
105
+ const infos = (body as Record<string, unknown>).balance_infos
106
+ if (!Array.isArray(infos) || infos.length === 0) return undefined
107
+ const first = infos[0]
108
+ if (typeof first !== 'object' || first === null) return undefined
109
+ const currency = str((first as Record<string, unknown>).currency)
110
+ const total = str((first as Record<string, unknown>).total_balance)
111
+ if (currency === undefined || total === undefined) return undefined
112
+ return { currency, totalBalance: total }
113
+ },
114
+ },
115
+ }
116
+
117
+ /** Moonshot pay-as-you-go balance; CN bills in CNY, international in USD. */
118
+ function moonshotBalance(host: string, currency: string, ids: readonly string[]): ProviderAdapter {
119
+ return {
120
+ ids,
121
+ displayName: 'Moonshot AI',
122
+ balance: {
123
+ build: ({ apiKey }) => ({ url: `https://${host}/v1/users/me/balance`, headers: bearer(apiKey) }),
124
+ parse: (status, body) => {
125
+ if (status !== 200 || typeof body !== 'object' || body === null) return undefined
126
+ const data = (body as Record<string, unknown>).data
127
+ if (typeof data !== 'object' || data === null) return undefined
128
+ const available = toNum((data as Record<string, unknown>).available_balance)
129
+ if (available === undefined) return undefined
130
+ return { currency, totalBalance: money(available) }
131
+ },
132
+ },
133
+ }
134
+ }
135
+
136
+ /** Kimi For Coding quota: top-level `usage` is the weekly summary, `limits[]` the per-window rows. */
137
+ const KIMI_CODING: ProviderAdapter = {
138
+ ids: ['kimi-coding'],
139
+ displayName: 'Kimi For Coding',
140
+ plan: {
141
+ build: ({ apiKey }) => ({ url: 'https://api.kimi.com/coding/v1/usages', headers: bearer(apiKey) }),
142
+ parse: (status, body) => {
143
+ if (status !== 200 || typeof body !== 'object' || body === null) return undefined
144
+ const root = body as Record<string, unknown>
145
+ const windows: PlanWindowView[] = []
146
+ const limits = root.limits
147
+ if (Array.isArray(limits)) {
148
+ for (const entry of limits) {
149
+ if (typeof entry !== 'object' || entry === null) continue
150
+ const detail = (entry as Record<string, unknown>).detail
151
+ const window = (entry as Record<string, unknown>).window
152
+ if (typeof detail !== 'object' || detail === null) continue
153
+ const row = detail as Record<string, unknown>
154
+ const duration = typeof window === 'object' && window !== null ? toNum((window as Record<string, unknown>).duration) : undefined
155
+ const unit = typeof window === 'object' && window !== null ? str((window as Record<string, unknown>).timeUnit) : undefined
156
+ const key = duration === 300 && unit === 'TIME_UNIT_MINUTE' ? '5h' : duration !== undefined ? `w-${duration}` : 'window'
157
+ const percent = usedPercent(row.used, row.limit)
158
+ windows.push({ key, name: str(row.name), percent, resetsAt: toIso(row.resetTime) })
159
+ }
160
+ }
161
+ const usage = root.usage
162
+ if (typeof usage === 'object' && usage !== null) {
163
+ const weekly = usage as Record<string, unknown>
164
+ windows.push({
165
+ key: 'week',
166
+ name: 'Weekly',
167
+ percent: usedPercent(weekly.used, weekly.limit),
168
+ resetsAt: toIso(weekly.resetTime),
169
+ })
170
+ }
171
+ if (windows.length === 0) return undefined
172
+ const user = root.user
173
+ const membership = typeof user === 'object' && user !== null ? (user as Record<string, unknown>).membership : undefined
174
+ const planName = typeof membership === 'object' && membership !== null ? str((membership as Record<string, unknown>).level) : undefined
175
+ return { planName, windows }
176
+ },
177
+ },
178
+ }
179
+
180
+ /** GLM Coding Plan quota; auth is the RAW key without a Bearer prefix. */
181
+ function glmPlan(host: string, ids: readonly string[]): ProviderAdapter {
182
+ return {
183
+ ids,
184
+ displayName: 'GLM Coding Plan',
185
+ plan: {
186
+ build: ({ apiKey }) => ({
187
+ url: `https://${host}/api/monitor/usage/quota/limit`,
188
+ headers: { authorization: apiKey, 'accept-language': 'en-US,en' },
189
+ }),
190
+ parse: (status, body) => {
191
+ if (status !== 200 || typeof body !== 'object' || body === null) return undefined
192
+ const root = body as Record<string, unknown>
193
+ if (root.success !== true) return undefined
194
+ const data = root.data
195
+ if (typeof data !== 'object' || data === null) return undefined
196
+ const limits = (data as Record<string, unknown>).limits
197
+ if (!Array.isArray(limits)) return undefined
198
+ const windows: PlanWindowView[] = []
199
+ for (const entry of limits) {
200
+ if (typeof entry !== 'object' || entry === null) continue
201
+ const row = entry as Record<string, unknown>
202
+ const unit = toNum(row.unit)
203
+ const percent = toNum(row.percentage)
204
+ windows.push({
205
+ key: unit === 3 ? '5h' : unit === 6 ? 'week' : unit !== undefined ? `unit-${unit}` : 'window',
206
+ percent: percent === undefined ? undefined : Math.max(0, Math.min(100, percent)),
207
+ resetsAt: toIso(row.nextResetTime),
208
+ })
209
+ }
210
+ if (windows.length === 0) return undefined
211
+ return { planName: str((data as Record<string, unknown>).level), windows }
212
+ },
213
+ },
214
+ }
215
+ }
216
+
217
+ /** OpenCode Go quota: percent-only rolling/weekly/monthly windows. */
218
+ const OPENCODE_GO: ProviderAdapter = {
219
+ ids: ['opencode-go'],
220
+ displayName: 'OpenCode Go',
221
+ plan: {
222
+ build: ({ apiKey }) => ({ url: 'https://opencode.ai/zen/go/v1/usage', headers: bearer(apiKey) }),
223
+ parse: (status, body) => {
224
+ if (status !== 200 || typeof body !== 'object' || body === null) return undefined
225
+ const usage = (body as Record<string, unknown>).usage
226
+ if (typeof usage !== 'object' || usage === null) return undefined
227
+ const windows: PlanWindowView[] = []
228
+ const keys: Array<[string, string]> = [['rolling', '5h'], ['weekly', 'week'], ['monthly', 'month']]
229
+ for (const [field, key] of keys) {
230
+ const entry = (usage as Record<string, unknown>)[field]
231
+ if (typeof entry !== 'object' || entry === null) continue
232
+ const row = entry as Record<string, unknown>
233
+ const percent = toNum(row.percent)
234
+ windows.push({
235
+ key,
236
+ percent: percent === undefined ? undefined : Math.max(0, Math.min(100, percent)),
237
+ // percent 0 resetsAt is a placeholder (now + window); drop it.
238
+ resetsAt: percent === 0 ? undefined : toIso(row.resetsAt),
239
+ })
240
+ }
241
+ if (windows.length === 0) return undefined
242
+ return { windows }
243
+ },
244
+ },
245
+ }
246
+
247
+ /** MiniMax coding-plan remains: remaining-percent semantics, `general` model entry. */
248
+ function minimaxPlan(host: string, ids: readonly string[]): ProviderAdapter {
249
+ return {
250
+ ids,
251
+ displayName: 'MiniMax Coding Plan',
252
+ plan: {
253
+ build: ({ apiKey }) => ({ url: `https://${host}/v1/api/openplatform/coding_plan/remains`, headers: bearer(apiKey) }),
254
+ parse: (status, body) => {
255
+ if (status !== 200 || typeof body !== 'object' || body === null) return undefined
256
+ const remains = (body as Record<string, unknown>).model_remains
257
+ if (!Array.isArray(remains)) return undefined
258
+ const general = remains.find((entry) => typeof entry === 'object' && entry !== null
259
+ && (entry as Record<string, unknown>).model_name === 'general')
260
+ if (typeof general !== 'object' || general === null) return undefined
261
+ const row = general as Record<string, unknown>
262
+ const windows: PlanWindowView[] = []
263
+ const intervalRemaining = toNum(row.current_interval_remaining_percent)
264
+ if (intervalRemaining !== undefined) {
265
+ windows.push({ key: '5h', percent: Math.max(0, Math.min(100, 100 - intervalRemaining)), resetsAt: toIso(row.end_time) })
266
+ }
267
+ if (row.current_weekly_status === 1) {
268
+ const weeklyRemaining = toNum(row.current_weekly_remaining_percent)
269
+ if (weeklyRemaining !== undefined) {
270
+ windows.push({ key: 'week', percent: Math.max(0, Math.min(100, 100 - weeklyRemaining)), resetsAt: toIso(row.weekly_end_time) })
271
+ }
272
+ }
273
+ if (windows.length === 0) return undefined
274
+ return { windows }
275
+ },
276
+ },
277
+ }
278
+ }
279
+
280
+ const OPENROUTER: ProviderAdapter = {
281
+ ids: ['openrouter'],
282
+ displayName: 'OpenRouter',
283
+ balance: {
284
+ build: ({ apiKey }) => ({ url: 'https://openrouter.ai/api/v1/credits', headers: bearer(apiKey) }),
285
+ parse: (status, body) => {
286
+ if (status !== 200 || typeof body !== 'object' || body === null) return undefined
287
+ const data = (body as Record<string, unknown>).data
288
+ if (typeof data !== 'object' || data === null) return undefined
289
+ const credits = toNum((data as Record<string, unknown>).total_credits)
290
+ const used = toNum((data as Record<string, unknown>).total_usage) ?? 0
291
+ if (credits === undefined) return undefined
292
+ return { currency: 'USD', totalBalance: money(credits - used) }
293
+ },
294
+ },
295
+ }
296
+
297
+ function siliconFlow(host: string, ids: readonly string[], currency: string): ProviderAdapter {
298
+ return {
299
+ ids,
300
+ displayName: 'SiliconFlow',
301
+ balance: {
302
+ build: ({ apiKey }) => ({ url: `https://${host}/v1/user/info`, headers: bearer(apiKey) }),
303
+ parse: (status, body) => {
304
+ if (status !== 200 || typeof body !== 'object' || body === null) return undefined
305
+ const data = (body as Record<string, unknown>).data
306
+ if (typeof data !== 'object' || data === null) return undefined
307
+ const total = str((data as Record<string, unknown>).totalBalance)
308
+ if (total === undefined) return undefined
309
+ return { currency, totalBalance: total }
310
+ },
311
+ },
312
+ }
313
+ }
314
+
315
+ const ZENMUX: ProviderAdapter = {
316
+ ids: ['zenmux'],
317
+ displayName: 'ZenMux',
318
+ balance: {
319
+ build: ({ apiKey }) => ({ url: 'https://zenmux.ai/api/v1/management/payg/balance', headers: bearer(apiKey) }),
320
+ parse: (status, body) => {
321
+ if (status !== 200 || typeof body !== 'object' || body === null) return undefined
322
+ const data = (body as Record<string, unknown>).data
323
+ if (typeof data !== 'object' || data === null) return undefined
324
+ const credits = toNum((data as Record<string, unknown>).total_credits)
325
+ if (credits === undefined) return undefined
326
+ return { currency: 'USD', totalBalance: money(credits) }
327
+ },
328
+ },
329
+ }
330
+
331
+ /**
332
+ * The adapter registry, in no particular order. Route keys come from the
333
+ * pi-ai provider catalog plus the routes this deployment observed in user
334
+ * configuration (`zenmux`).
335
+ */
336
+ export const PROVIDER_ADAPTERS: readonly ProviderAdapter[] = [
337
+ DEEPSEEK,
338
+ moonshotBalance('api.moonshot.cn', 'CNY', ['moonshotai-cn']),
339
+ moonshotBalance('api.moonshot.ai', 'USD', ['moonshotai']),
340
+ KIMI_CODING,
341
+ glmPlan('open.bigmodel.cn', ['zai-coding-cn']),
342
+ glmPlan('api.z.ai', ['zai-coding']),
343
+ OPENCODE_GO,
344
+ minimaxPlan('api.minimaxi.com', ['minimax-cn']),
345
+ minimaxPlan('api.minimax.io', ['minimax']),
346
+ OPENROUTER,
347
+ siliconFlow('api.siliconflow.cn', ['siliconflow', 'siliconflow-cn'], 'CNY'),
348
+ siliconFlow('api.siliconflow.com', ['siliconflow-intl'], 'USD'),
349
+ ZENMUX,
350
+ ]
351
+
352
+ /** Find the adapter serving a provider route key, if any. */
353
+ export function adapterFor(provider: string): ProviderAdapter | undefined {
354
+ return PROVIDER_ADAPTERS.find((adapter) => adapter.ids.includes(provider))
355
+ }
356
+
357
+ /**
358
+ * Best-effort human message from a provider error body, for the per-provider
359
+ * error line. Never throws; truncated to one short sentence.
360
+ */
361
+ export function providerErrorMessage(status: number, body: unknown): string {
362
+ let message: string | undefined
363
+ if (typeof body === 'object' && body !== null) {
364
+ const root = body as Record<string, unknown>
365
+ const nested = typeof root.error === 'object' && root.error !== null ? (root.error as Record<string, unknown>) : undefined
366
+ message = str(root.message) ?? str(root.msg) ?? (nested !== undefined ? str(nested.message) : undefined)
367
+ }
368
+ const detail = message === undefined ? '' : `: ${message.slice(0, 120)}`
369
+ return `HTTP ${status}${detail}`
370
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The usage ledger: a pure fold from session usage facts into a per-day,
3
+ * per-provider, per-model totals document, plus its JSON serialization.
4
+ * Host-side state lives only in the document; the service owns persistence.
5
+ * @module @linxin666/dsh-usage/core/ledger
6
+ */
7
+
8
+ import { addTotals, emptyTotals, type UsageLedgerDocument, type UsageTokenTotals } from './types.ts'
9
+
10
+ /** Local-date key (`YYYY-MM-DD`) for an epoch ms timestamp. */
11
+ export function localDateKey(ms: number): string {
12
+ const date = new Date(ms)
13
+ const month = String(date.getMonth() + 1).padStart(2, '0')
14
+ const day = String(date.getDate()).padStart(2, '0')
15
+ return `${date.getFullYear()}-${month}-${day}`
16
+ }
17
+
18
+ /** An empty ledger document. */
19
+ export function createLedgerDocument(): UsageLedgerDocument {
20
+ return { version: 1, days: {} }
21
+ }
22
+
23
+ /**
24
+ * Fold one usage report into the ledger in place. `provider` is the route key
25
+ * and `model` the provider-owned model id the step ran under.
26
+ */
27
+ export function foldUsage(
28
+ doc: UsageLedgerDocument,
29
+ atMs: number,
30
+ provider: string,
31
+ model: string,
32
+ usage: Readonly<UsageTokenTotals>,
33
+ ): void {
34
+ if (usage.calls <= 0 && usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens <= 0) return
35
+ const dayKey = localDateKey(atMs)
36
+ const day = doc.days[dayKey] ?? {}
37
+ const models = day[provider] ?? {}
38
+ const totals = models[model] ?? emptyTotals()
39
+ addTotals(totals, usage)
40
+ models[model] = totals
41
+ day[provider] = models
42
+ doc.days[dayKey] = day
43
+ }
44
+
45
+ /** Total tokens of a bucket (billed input + output; reasoning is inside output). */
46
+ export function totalTokens(totals: Readonly<UsageTokenTotals>): number {
47
+ return totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens
48
+ }
49
+
50
+ /** All local-date keys in the ledger, ascending. */
51
+ export function ledgerDayKeys(doc: Readonly<UsageLedgerDocument>): string[] {
52
+ return Object.keys(doc.days).sort()
53
+ }
54
+
55
+ /**
56
+ * Drop every day older than `retainDays` local days before `todayKey`, in
57
+ * place. Returns the number of pruned days.
58
+ */
59
+ export function pruneLedger(doc: UsageLedgerDocument, todayKey: string, retainDays: number): number {
60
+ const cutoff = new Date(todayKey + 'T00:00:00')
61
+ cutoff.setDate(cutoff.getDate() - retainDays)
62
+ const cutoffKey = localDateKey(cutoff.getTime())
63
+ let pruned = 0
64
+ for (const key of Object.keys(doc.days)) {
65
+ if (key < cutoffKey) {
66
+ delete doc.days[key]
67
+ pruned += 1
68
+ }
69
+ }
70
+ return pruned
71
+ }
72
+
73
+ function reviveTotals(value: unknown): UsageTokenTotals | undefined {
74
+ if (typeof value !== 'object' || value === null) return undefined
75
+ const source = value as Record<string, unknown>
76
+ const num = (key: string): number => typeof source[key] === 'number' && Number.isFinite(source[key]) ? source[key] : 0
77
+ return {
78
+ inputTokens: num('inputTokens'),
79
+ outputTokens: num('outputTokens'),
80
+ cacheReadTokens: num('cacheReadTokens'),
81
+ cacheWriteTokens: num('cacheWriteTokens'),
82
+ reasoningTokens: num('reasoningTokens'),
83
+ calls: num('calls'),
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Parse a ledger document from untrusted JSON: unknown shapes resolve to an
89
+ * empty document, malformed entries are dropped, numbers are coerced to
90
+ * finite values. Never throws.
91
+ */
92
+ export function deserializeLedger(value: unknown): UsageLedgerDocument {
93
+ const doc = createLedgerDocument()
94
+ if (typeof value !== 'object' || value === null) return doc
95
+ const days = (value as Record<string, unknown>).days
96
+ if (typeof days !== 'object' || days === null) return doc
97
+ for (const [dateKey, providers] of Object.entries(days as Record<string, unknown>)) {
98
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey) || typeof providers !== 'object' || providers === null) continue
99
+ for (const [provider, models] of Object.entries(providers as Record<string, unknown>)) {
100
+ if (typeof models !== 'object' || models === null) continue
101
+ for (const [model, totals] of Object.entries(models as Record<string, unknown>)) {
102
+ const revived = reviveTotals(totals)
103
+ if (revived !== undefined) foldUsage(doc, new Date(dateKey + 'T12:00:00').getTime(), provider, model, revived)
104
+ }
105
+ }
106
+ }
107
+ return doc
108
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Wire-facing view types shared by the dsh-usage host service, its HTTP
3
+ * routes, and the browser section. Everything here crosses the wire as JSON
4
+ * and must stay provider-agnostic: provider quirks are normalized inside the
5
+ * core adapters, never here.
6
+ * @module @linxin666/dsh-usage/core/types
7
+ */
8
+
9
+ /** One disjoint token bucket total, aggregated over calls (billed input = input + cacheRead + cacheWrite). */
10
+ export interface UsageTokenTotals {
11
+ /** Uncached input tokens. */
12
+ inputTokens: number
13
+ /** Output tokens (reasoning included). */
14
+ outputTokens: number
15
+ /** Cache read tokens. */
16
+ cacheReadTokens: number
17
+ /** Cache write tokens. */
18
+ cacheWriteTokens: number
19
+ /** Reasoning tokens, when the provider reported them. */
20
+ reasoningTokens: number
21
+ /** Provider calls that reported usage. */
22
+ calls: number
23
+ }
24
+
25
+ /** A zeroed totals bucket. */
26
+ export function emptyTotals(): UsageTokenTotals {
27
+ return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, calls: 0 }
28
+ }
29
+
30
+ /** Add `right` into `left` in place. */
31
+ export function addTotals(left: UsageTokenTotals, right: Readonly<UsageTokenTotals>): UsageTokenTotals {
32
+ left.inputTokens += right.inputTokens
33
+ left.outputTokens += right.outputTokens
34
+ left.cacheReadTokens += right.cacheReadTokens
35
+ left.cacheWriteTokens += right.cacheWriteTokens
36
+ left.reasoningTokens += right.reasoningTokens
37
+ left.calls += right.calls
38
+ return left
39
+ }
40
+
41
+ /** On-disk usage ledger: per local day, per provider route, per model. */
42
+ export interface UsageLedgerDocument {
43
+ version: 1
44
+ /** Local-date key `YYYY-MM-DD` → per-provider per-model totals. */
45
+ days: Record<string, Record<string, Record<string, UsageTokenTotals>>>
46
+ }
47
+
48
+ /** One provider row of the day summary the overview serves. */
49
+ export interface UsageProviderSummary {
50
+ provider: string
51
+ totals: UsageTokenTotals
52
+ /** Per-model breakdown, heaviest by total tokens first, capped by the route. */
53
+ models: Array<{ model: string; totals: UsageTokenTotals }>
54
+ }
55
+
56
+ /** One day row of the trend the overview serves. */
57
+ export interface UsageDaySummary {
58
+ date: string
59
+ totals: UsageTokenTotals
60
+ }
61
+
62
+ /** A balance fact normalized from a provider probe. */
63
+ export interface BalanceView {
64
+ /** ISO 4217 code the provider bills in (`CNY`, `USD`, ...). */
65
+ currency: string
66
+ /** Spendable total formatted for display (no currency symbol). */
67
+ totalBalance: string
68
+ /** Epoch ms of the successful probe. */
69
+ updatedAt: number
70
+ }
71
+
72
+ /** One quota window of a coding plan. */
73
+ export interface PlanWindowView {
74
+ /** Stable window key the UI localizes (`5h`, `week`, `month`, provider strings otherwise). */
75
+ key: string
76
+ /** Provider-supplied window name, when it sends one. */
77
+ name?: string
78
+ /** Used percent 0-100, when computable. */
79
+ percent?: number
80
+ /** ISO 8601 reset instant, when the provider reports one. */
81
+ resetsAt?: string
82
+ }
83
+
84
+ /** A coding-plan quota fact normalized from a provider probe. */
85
+ export interface PlanView {
86
+ /** Plan tier name, when the provider reports one. */
87
+ planName?: string
88
+ windows: PlanWindowView[]
89
+ updatedAt: number
90
+ }
91
+
92
+ /** How the credential backing one provider route was resolved. */
93
+ export type CredentialKind = 'api-key' | 'env' | 'oauth' | 'none'
94
+
95
+ /** One provider row of the overview snapshot. */
96
+ export interface ProviderSnapshotView {
97
+ /** Provider route key (`deepseek`, `kimi-coding`, custom routes, ...). */
98
+ provider: string
99
+ /** Display name from the LLM runtime, else the adapter's, else the route key. */
100
+ displayName: string
101
+ credential: CredentialKind
102
+ /** Whether a balance/plan adapter exists for this route. */
103
+ supported: boolean
104
+ balance?: BalanceView
105
+ plan?: PlanView
106
+ /** Last probe failure, cleared on the next success. */
107
+ error?: string
108
+ updatedAt?: number
109
+ }
110
+
111
+ /** The overview the browser section renders. */
112
+ export interface UsageOverviewView {
113
+ updatedAt: number
114
+ providers: ProviderSnapshotView[]
115
+ /** The provider the pet bubble and the header highlight; `source` says how it was picked. */
116
+ current: {
117
+ provider?: string
118
+ model?: string
119
+ /** `live` — last request seen this boot; `default` — the agent default model. */
120
+ source: 'live' | 'default'
121
+ }
122
+ usage: {
123
+ today: { date: string; totals: UsageTokenTotals; providers: UsageProviderSummary[] }
124
+ /** Last N local days ascending, including today. */
125
+ days: UsageDaySummary[]
126
+ }
127
+ }
@@ -0,0 +1,42 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/dsh-home.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
4
+ * environment override wins, the platform home fallback follows. Mirrors
5
+ * what dsh-pet and dsh-liangshen each used to implement locally.
6
+ */
7
+
8
+ import { homedir } from 'node:os'
9
+ import { isAbsolute, join } from 'node:path'
10
+ import { isAbsolute as posixIsAbsolute, join as posixJoin } from 'node:path/posix'
11
+
12
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
13
+ export function expandHome(path: string, home: string = homedir()): string {
14
+ const isPosix = home.startsWith('/')
15
+ const j = isPosix ? posixJoin : join
16
+ if (path === '~') return home
17
+ if (path.startsWith('~/') || path.startsWith('~\\')) return j(home, path.slice(2))
18
+ return path
19
+ }
20
+
21
+ /**
22
+ * Resolve the DSH home directory.
23
+ * @param env - process environment to read DSH_HOME from.
24
+ * @param home - platform home directory fallback (test seam).
25
+ * @returns the absolute DSH home path.
26
+ */
27
+ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
28
+ const isPosix = home.startsWith('/')
29
+ const j = isPosix ? posixJoin : join
30
+ const isAbs = isPosix ? posixIsAbsolute : isAbsolute
31
+ const raw = env.DSH_HOME
32
+ if (raw !== undefined && raw.trim() !== '') {
33
+ const expanded = expandHome(raw.trim(), home)
34
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded)
35
+ }
36
+ return j(home, '.dsh')
37
+ }
38
+
39
+ /** Resolve the DSH home directory from the live environment. */
40
+ export function dshHome(): string {
41
+ return resolveDshHome()
42
+ }