@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.
- package/LICENSE +202 -0
- package/README.i18n.yaml +5 -0
- package/README.md +50 -0
- package/README.zh.md +50 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +755 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1443 -0
- package/lib/types/client/UsageSectionCard.d.ts +36 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +62 -0
- package/lib/types/client/usage-store.d.ts +27 -0
- package/lib/types/core/adapters.d.ts +58 -0
- package/lib/types/core/ledger.d.ts +31 -0
- package/lib/types/core/types.d.ts +112 -0
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host/http.d.ts +42 -0
- package/lib/types/host/loopback.d.ts +24 -0
- package/lib/types/host/routes.d.ts +14 -0
- package/lib/types/host/usage-service.d.ts +82 -0
- package/lib/types/index.d.ts +21 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/package.json +92 -0
- package/src/client/UsageSectionCard.tsx +368 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.ts +131 -0
- package/src/client/locales.ts +116 -0
- package/src/client/usage-store.ts +52 -0
- package/src/client/usage.module.css +311 -0
- package/src/core/adapters.ts +370 -0
- package/src/core/ledger.ts +108 -0
- package/src/core/types.ts +127 -0
- package/src/dsh-home.ts +42 -0
- package/src/host/http.ts +105 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/routes.ts +54 -0
- package/src/host/usage-service.ts +530 -0
- package/src/index.ts +90 -0
- package/src/mount-once.ts +48 -0
|
@@ -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
|
+
}
|
package/src/dsh-home.ts
ADDED
|
@@ -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
|
+
}
|