@ponydev/dsh-model-usage 0.0.0-stage → 1.0.1
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/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +119 -2
- package/assets/logos/anthropic.svg +1 -0
- package/assets/logos/deepseek-dark.svg +3 -0
- package/assets/logos/deepseek.svg +3 -0
- package/assets/logos/googlegemini.svg +1 -0
- package/assets/logos/openai.svg +1 -0
- package/cordis.patch.yml +6 -0
- package/docs/architecture.md +855 -0
- package/docs/screenshots/01-sidebar.png +0 -0
- package/docs/screenshots/02-modal.png +0 -0
- package/docs/screenshots/03-settings.png +0 -0
- package/lib/adapters.js +465 -0
- package/lib/client.js +1149 -0
- package/lib/index.js +308 -0
- package/lib/ledger.js +420 -0
- package/package.json +65 -4
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
|
+
}
|