@ponydev/dsh-model-usage 0.0.0-stage → 1.0.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/lib/index.js ADDED
@@ -0,0 +1,308 @@
1
+ // dsh-model-usage · Host 半区
2
+ // - session/event 跟踪每个会话当前 provider;模型调用后尽快刷新对应额度
3
+ // - 自挂 /model-usage 前缀路由(webServer.register + connection.admit 认证围栏),
4
+ // 纯 JSON 协议:POST { endpoint, payload } → { ok, value } | { ok, error }
5
+ // (不走 connection.rpc.handle:其内部对 connection fiber 的 webServer 访问
6
+ // 在 web 组合下会抛 without inject —— 参见 dsh-api-gateway 的同类做法)
7
+ // - 凭据只在 Host 进程内存中出现;TTL 缓存 + 单飞 + 指数退避
8
+ import { adapterFor, safeMsg } from './adapters.js'
9
+ import { createLedger } from './ledger.js'
10
+ import { dirname, join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { readFile } from 'node:fs/promises'
13
+
14
+ const ASSETS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'logos')
15
+
16
+ const DEFAULTS = {
17
+ refreshSeconds: 60,
18
+ footer: true,
19
+ credentials: {},
20
+ glm: {},
21
+ customQuota: {},
22
+ }
23
+
24
+ export const name = 'dsh-model-usage'
25
+
26
+ const prettyNames = {
27
+ 'deepseek-official': 'DeepSeek',
28
+ 'deepseek-account': 'DeepSeek 账号',
29
+ 'zai-coding-cn': 'GLM Coding Plan',
30
+ zai: 'GLM Coding (国际)',
31
+ }
32
+
33
+ function prettyName(p) {
34
+ return prettyNames[p.id] || p.name || p.id
35
+ }
36
+
37
+ export function apply(ctx, rawConfig) {
38
+ const raw = rawConfig || {}
39
+ const config = { ...DEFAULTS, ...raw }
40
+ config.credentials = { ...DEFAULTS.credentials, ...(raw.credentials || {}) }
41
+ config.glm = { ...DEFAULTS.glm, ...(raw.glm || {}) }
42
+ config.customQuota = { ...(raw.customQuota || {}) }
43
+ config.refreshSeconds = Math.max(15, Number(config.refreshSeconds) || 60)
44
+
45
+ const ok = (value) => ({ ok: true, value })
46
+ const fail = (code, message) => ({ ok: false, error: { code, message, details: {} } })
47
+
48
+ const state = {
49
+ pendingRequest: new Map(), // sessionId -> { t, provider, model } (request/context 时间)
50
+ sessionProviders: new Map(), // sessionId -> provider route
51
+ lastActive: null, // { route, ts } 最近一次真实模型调用的 provider(侧边栏「当前供应商」用)
52
+ cache: new Map(), // route -> { inflight, lastAttempt, backoffUntil, lastBackoff, data }
53
+ }
54
+ let listRoutes = async () => [] // 在 llm/credentials 注入后替换
55
+ let quotaScope = null // llm/credentials 注入作用域,供适配器访问凭据
56
+ const ledger = createLedger()
57
+
58
+ function staleView(c) {
59
+ return { ...c.data, status: 'stale', stale: true }
60
+ }
61
+
62
+ // 可选服务:用 ctx.get 宽松读取(服务缺失时返回 null,绝不能进 inject —— 否则老宿主上整个插件不启动)
63
+ function getAccount() {
64
+ try {
65
+ return typeof ctx.get === 'function' ? ctx.get('deepseekAccount') : null
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ async function getUsage(ad, routeId, force) {
72
+ let c = state.cache.get(routeId)
73
+ if (!c) {
74
+ c = { inflight: null, lastAttempt: 0, backoffUntil: 0, lastBackoff: 0, data: null }
75
+ state.cache.set(routeId, c)
76
+ }
77
+ if (c.inflight) return c.inflight
78
+ const ttl = config.refreshSeconds * 1000
79
+ const now = Date.now()
80
+ if (!force && c.data && now - c.lastAttempt < ttl) return c.data
81
+ if (!force && now < c.backoffUntil && c.data) return staleView(c)
82
+
83
+ const refs = ad.refs(config, routeId)
84
+ const env = {
85
+ ctx: quotaScope,
86
+ credentials: quotaScope ? quotaScope.credentials : undefined,
87
+ getAccount,
88
+ config,
89
+ route: routeId,
90
+ refs,
91
+ }
92
+ const p = (async () => {
93
+ try {
94
+ const d = await ad.read(env)
95
+ d.route = routeId
96
+ d.adapter = ad.id
97
+ if (!d.credential) d.credential = { refs, configured: true }
98
+ try { ledger.observe(routeId, d) } catch {}
99
+ c.data = d
100
+ c.lastAttempt = Date.now()
101
+ c.backoffUntil = 0
102
+ } catch (e) {
103
+ c.lastAttempt = Date.now()
104
+ c.lastBackoff = c.lastBackoff ? Math.min(300000, c.lastBackoff * 2) : 60000
105
+ c.backoffUntil = Date.now() + c.lastBackoff
106
+ const error = { code: 'model-usage/fetch-failed', message: safeMsg(e) }
107
+ c.data = c.data
108
+ ? { ...c.data, status: 'stale', stale: true, error }
109
+ : { route: routeId, adapter: ad.id, status: 'error', error }
110
+ } finally {
111
+ c.inflight = null
112
+ }
113
+ return c.data
114
+ })()
115
+ c.inflight = p
116
+ return p
117
+ }
118
+
119
+ function triggerFetch(routeId) {
120
+ const ad = adapterFor(routeId, config)
121
+ if (!ad) return
122
+ getUsage(ad, routeId, true).catch(() => {})
123
+ }
124
+
125
+ async function buildSnapshot(force) {
126
+ const routes = await listRoutes()
127
+ const providers = []
128
+ for (const r of routes) {
129
+ const base = { route: r.id, displayName: prettyName(r) }
130
+ const ad = adapterFor(r.id, config)
131
+ if (!ad) {
132
+ providers.push({ ...base, adapter: 'none', status: 'idle' })
133
+ continue
134
+ }
135
+ const usage = await getUsage(ad, r.id, force)
136
+ providers.push({ ...base, ...usage })
137
+ }
138
+ return { generatedAt: Date.now(), refreshSeconds: config.refreshSeconds, providers }
139
+ }
140
+
141
+ function publicConfig() {
142
+ return {
143
+ refreshSeconds: config.refreshSeconds,
144
+ entries: {
145
+ footer: !!config.footer,
146
+ },
147
+ }
148
+ }
149
+
150
+ async function handleRpc(endpoint, payload) {
151
+ switch (endpoint) {
152
+ case 'snapshot':
153
+ return ok(await buildSnapshot(!!(payload && payload.refresh)))
154
+ case 'session-provider': {
155
+ // 带 sessionId → 该会话当前 provider;不带 → 全局最近活跃的 provider(侧边栏「当前供应商」)
156
+ const sid = payload && payload.sessionId
157
+ if (sid) return ok({ route: state.sessionProviders.get(sid) || null })
158
+ return ok({ route: (state.lastActive && state.lastActive.route) || null })
159
+ }
160
+ case 'config':
161
+ return ok(publicConfig())
162
+ case 'stats':
163
+ return ok(ledger.stats({ days: payload && payload.days }))
164
+ default:
165
+ return fail('model-usage/unknown-endpoint', 'unknown endpoint: ' + String(endpoint))
166
+ }
167
+ }
168
+
169
+ // ---- 账本:加载 + 退出前落盘
170
+ ctx.effect(() => {
171
+ Promise.resolve(ledger.ready())
172
+ .then(() => ledger.backfill())
173
+ .catch(() => {})
174
+ return () => { ledger.flush().catch(() => {}) }
175
+ }, 'dmu: ledger lifecycle')
176
+
177
+ // ---- 会话事件:跟踪每个会话当前 provider + 模型调用后尽快刷新对应额度
178
+ ctx.effect(() => ctx.on('session/event', (session, event) => {
179
+ try {
180
+ const sid = session && session.id
181
+ const type = event && event.type
182
+ const d = event && event.data
183
+ if (!sid || !d) return
184
+ if (type === 'step/start') {
185
+ state.pendingRequest.set(sid, { t: (event && event.time) || Date.now() })
186
+ return
187
+ }
188
+ if (type === 'request/context' && d.provider) {
189
+ state.sessionProviders.set(sid, d.provider)
190
+ state.lastActive = { route: d.provider, ts: Date.now() }
191
+ return
192
+ }
193
+ if (type === 'assistant/message' && d.message && d.message.source && d.message.source.provider) {
194
+ const prov = d.message.source.provider
195
+ const model = d.message.source.model || ''
196
+ state.sessionProviders.set(sid, prov)
197
+ state.lastActive = { route: prov, ts: Date.now() }
198
+ const u = d.usage
199
+ if (u && typeof u === 'object') {
200
+ try {
201
+ const pending = state.pendingRequest.get(sid)
202
+ state.pendingRequest.delete(sid)
203
+ const tMsg = (event && event.time) || Date.now()
204
+ const stream = Array.isArray(d.stream) ? d.stream : []
205
+ const firstTime = stream.length && Number.isFinite(stream[0].time) ? stream[0].time : null
206
+ const ttftMs = pending && firstTime && firstTime >= pending.t ? firstTime - pending.t : null
207
+ const durationMs = pending ? Math.max(0, tMsg - pending.t) : null
208
+ ledger.record(prov, model, {
209
+ input: u.inputTokens,
210
+ cacheRead: u.cacheReadTokens,
211
+ cacheWrite: u.cacheWriteTokens,
212
+ output: u.outputTokens,
213
+ }, { time: tMsg, durationMs, ttftMs })
214
+ } catch {}
215
+ }
216
+ if (state.cache.has(prov)) {
217
+ const c = state.cache.get(prov)
218
+ if (Date.now() - c.lastAttempt > 15000) triggerFetch(prov)
219
+ }
220
+ }
221
+ } catch {}
222
+ }), 'dmu: session events')
223
+
224
+ // ---- 额度面(需要 llm + credentials)
225
+ ctx.inject(['llm', 'credentials'], (scope) => {
226
+ quotaScope = scope
227
+ listRoutes = async () => {
228
+ try {
229
+ const llm = scope.llm
230
+ return (llm && typeof llm.listProviders === 'function' ? llm.listProviders() : [])
231
+ .map((p) => ({ id: p.id, name: p.name }))
232
+ } catch {
233
+ return []
234
+ }
235
+ }
236
+ scope.effect(() => scope.on('llm/adapters-updated', () => {
237
+ for (const c of state.cache.values()) c.lastAttempt = 0
238
+ }), 'dmu: adapters-updated')
239
+ })
240
+
241
+ // ---- 前端路由(connection.admit 认证围栏 + webServer 注册)
242
+ ctx.inject(['connection', 'webServer'], (scope) => {
243
+ scope.effect(() => scope.webServer.register({
244
+ kind: 'prefix',
245
+ path: '/model-usage',
246
+ handler: async (req, res) => {
247
+ const admission = scope.connection.admit(req)
248
+ if ('rejection' in admission) {
249
+ res.writeHead(admission.rejection)
250
+ res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
251
+ return
252
+ }
253
+ const json = (code, obj) => {
254
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
255
+ res.end(JSON.stringify(obj))
256
+ }
257
+ try {
258
+ // 官方品牌 Logo 静态资产:GET /model-usage/logo/<slug>.svg
259
+ if (req.method === 'GET') {
260
+ const pn = (req.url || '').split('?')[0]
261
+ const m = /^\/model-usage\/logo\/([a-z0-9_-]+)\.svg$/.exec(pn)
262
+ if (m) {
263
+ const name = m[1]
264
+ if (!/^[a-z0-9_-]+$/.test(name)) { res.writeHead(404); res.end(); return }
265
+ try {
266
+ const svg = await readFile(join(ASSETS_DIR, name + '.svg'), 'utf8')
267
+ res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=86400' })
268
+ res.end(svg)
269
+ } catch {
270
+ res.writeHead(404); res.end()
271
+ }
272
+ return
273
+ }
274
+ }
275
+ if (req.method !== 'POST') {
276
+ json(405, fail('model-usage/method', 'POST only'))
277
+ return
278
+ }
279
+ const body = await readBody(req, 1 << 20)
280
+ let parsed = {}
281
+ try { parsed = JSON.parse(body || '{}') } catch { json(400, fail('model-usage/bad-request', 'body is not JSON')); return }
282
+ const result = await handleRpc(String(parsed.endpoint || ''), parsed.payload)
283
+ json(200, result)
284
+ } catch (e) {
285
+ json(200, fail('model-usage/internal', safeMsg(e)))
286
+ }
287
+ },
288
+ }), 'dmu: route')
289
+ })
290
+ }
291
+
292
+ function readBody(req, cap) {
293
+ return new Promise((resolve, reject) => {
294
+ const chunks = []
295
+ let size = 0
296
+ req.on('data', (chunk) => {
297
+ size += chunk.length
298
+ if (size > cap) {
299
+ reject(new Error('body too large'))
300
+ req.destroy()
301
+ return
302
+ }
303
+ chunks.push(chunk)
304
+ })
305
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
306
+ req.on('error', reject)
307
+ })
308
+ }