dsh-opencode-usage-dashboard 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +254 -2
- package/THIRD_PARTY_NOTICES.md +10 -0
- package/client.js +3397 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +17 -0
- package/index.js +810 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -4
- package/start.mjs +234 -0
package/index.js
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode 额度面板的 Host 半边。
|
|
3
|
+
*
|
|
4
|
+
* DSH 里的 `opencode-go` / `opencode` 只是两个连接预设,不能拿 provider id 判断
|
|
5
|
+
* 账户有没有 Go 订阅:同一个组织的任意服务账号 Key 都能读 `/inference/go/v1/usage`,
|
|
6
|
+
* 额度本身也是组织级共享的。这里用这个接口的 200/404 做订阅探测(404 = 没有订阅),
|
|
7
|
+
* provider id 只决定优先用哪把 Key、以及没有 Key 时怎么回落。
|
|
8
|
+
*
|
|
9
|
+
* 供应商列表来自 `llm-pi-ai` 设置里的 providers,凭据引用取各自的 `apiKeyEnv`,
|
|
10
|
+
* 缺失时按预设回落到 `OPENCODE_GO_API_KEY` / `OPENCODE_API_KEY`。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { appendFile, mkdir } from 'node:fs/promises'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
|
|
17
|
+
/** @typedef {{ percent: number | null, status: 'ok' | 'limited', resetsAt: string | null }} QuotaWindow */
|
|
18
|
+
/** @typedef {{ id: string, kind: 'go' | 'console', ref: string, name: string }} Account 一个 provider 连接;`kind` 只是连接预设,不代表计费方式。 */
|
|
19
|
+
|
|
20
|
+
const GO_BASE = 'https://opencode.ai/inference/go/v1'
|
|
21
|
+
const CONSOLE_BASE = 'https://opencode.ai/console/api'
|
|
22
|
+
const DEFAULT_KEY_REF = 'OPENCODE_GO_API_KEY'
|
|
23
|
+
const CONSOLE_KEY_REF = 'OPENCODE_API_KEY'
|
|
24
|
+
const QUOTA_WINDOWS = ['rolling', 'weekly', 'monthly']
|
|
25
|
+
const CACHE_MS = 10_000
|
|
26
|
+
const TIMEOUT_MS = 12_000
|
|
27
|
+
const MAX_RESPONSE_BYTES = 256 * 1024
|
|
28
|
+
const MAX_BODY_BYTES = 8 * 1024
|
|
29
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
30
|
+
const GO_PROBE_MS = 60 * 1000
|
|
31
|
+
|
|
32
|
+
/** 需要的 Host 服务:HTTP 路由、凭据库、只读设置。 */
|
|
33
|
+
export const inject = ['webServer', 'credentials', 'settings']
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 挂载路由。
|
|
37
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx 插件上下文。
|
|
38
|
+
* @param {{ baseURL?: string }} [config] 该行可选的 `config`。
|
|
39
|
+
*/
|
|
40
|
+
/** 激活失败也不能拖垮 DSH:包一层,出错只记日志。 */
|
|
41
|
+
export function apply(ctx, config) {
|
|
42
|
+
try {
|
|
43
|
+
applyInner(ctx, config)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('[opencode-quota] 激活失败:', error)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function applyInner(ctx, config) {
|
|
50
|
+
const goBase = String((config && config.baseURL) || GO_BASE).replace(/\/+$/, '')
|
|
51
|
+
/** @type {Map<string, { at: number, view: any }>} */
|
|
52
|
+
const cache = new Map()
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 从 `llm-pi-ai` 设置里找出 opencode 供应商;读不到时回落到 Go。
|
|
56
|
+
* @returns {Account[]} provider 列表(未合并)。
|
|
57
|
+
*/
|
|
58
|
+
const providerEntries = () => {
|
|
59
|
+
/** @type {Account[]} */
|
|
60
|
+
const accounts = []
|
|
61
|
+
try {
|
|
62
|
+
const doc = ctx.settings.describe({ redactSecrets: true })
|
|
63
|
+
const descriptor = Array.isArray(doc) ? doc.find((row) => row && row.ns === 'llm-pi-ai') : null
|
|
64
|
+
const providers = (descriptor && descriptor.value && descriptor.value.providers) || {}
|
|
65
|
+
for (const [route, entry] of Object.entries(providers)) {
|
|
66
|
+
if (!entry || typeof entry !== 'object') continue
|
|
67
|
+
const id = String(route)
|
|
68
|
+
const baseURL = typeof entry.baseURL === 'string' ? entry.baseURL : ''
|
|
69
|
+
/** provider 名以 opencode 开头(opencode、opencode-go、opencode-work…)或指向 opencode.ai 都算。 */
|
|
70
|
+
const opencode = /^opencode(-|$)/i.test(id) || /opencode\.ai/i.test(baseURL)
|
|
71
|
+
if (!opencode) continue
|
|
72
|
+
/** kind 只是 DSH 的连接预设(决定优先用哪把 Key),订阅与否由 probeGo 探测。 */
|
|
73
|
+
const kind = id === 'opencode-go' || /\/inference\/go(\/|$)/.test(baseURL) ? 'go' : 'console'
|
|
74
|
+
const ref =
|
|
75
|
+
typeof entry.apiKeyEnv === 'string' && entry.apiKeyEnv.length > 0
|
|
76
|
+
? entry.apiKeyEnv
|
|
77
|
+
: kind === 'go'
|
|
78
|
+
? DEFAULT_KEY_REF
|
|
79
|
+
: CONSOLE_KEY_REF
|
|
80
|
+
const name = typeof entry.displayName === 'string' && entry.displayName ? entry.displayName : id
|
|
81
|
+
accounts.push({ id, kind, ref, name })
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
/* 设置不可读时走回落 */
|
|
85
|
+
}
|
|
86
|
+
if (accounts.length === 0) accounts.push({ id: 'opencode-go', kind: 'go', ref: DEFAULT_KEY_REF, name: 'OpenCode Go' })
|
|
87
|
+
return accounts
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 凭证所属身份,用于把同一个账户下的多个 provider 合并。 */
|
|
91
|
+
const identities = new Map()
|
|
92
|
+
/** 服务账号列表按 Key 缓存:返回组织下所有 SA 及其 Key 前缀(tokenHint)。 */
|
|
93
|
+
const serviceAccounts = new Map()
|
|
94
|
+
/** 组织成员(owner 的邮箱最适合当 org 名字),按 Key 缓存。 */
|
|
95
|
+
const orgOwners = new Map()
|
|
96
|
+
/** 账户是否有 Go 订阅;额度是组织级,所以按账户(org id)缓存 60 秒。 */
|
|
97
|
+
const goProbes = new Map()
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 读组织成员,挑 owner;同一个 Key 只请求一次。
|
|
101
|
+
* @param {string} key 明文 Key。
|
|
102
|
+
* @returns {Promise<{ email: string | null, name: string | null } | null>} 组织主账号。
|
|
103
|
+
*/
|
|
104
|
+
const readOwner = async (key) => {
|
|
105
|
+
if (orgOwners.has(key)) return orgOwners.get(key)
|
|
106
|
+
let owner = null
|
|
107
|
+
try {
|
|
108
|
+
const body = await requestJson(`${CONSOLE_BASE}/members`, key)
|
|
109
|
+
const items = body && Array.isArray(body.items) ? body.items : []
|
|
110
|
+
const hit = items.find((row) => row && row.role === 'owner') || items[0] || null
|
|
111
|
+
if (hit) owner = { email: hit.email || null, name: hit.displayName || null }
|
|
112
|
+
} catch {
|
|
113
|
+
/* 读不到成员就退回服务账号名 */
|
|
114
|
+
}
|
|
115
|
+
orgOwners.set(key, owner)
|
|
116
|
+
return owner
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 读组织下的服务账号;同一个 Key 只请求一次。
|
|
121
|
+
* @param {string} key 明文 Key。
|
|
122
|
+
* @returns {Promise<Array<any>>} `{ account, keys }` 列表。
|
|
123
|
+
*/
|
|
124
|
+
const listServiceAccounts = async (key) => {
|
|
125
|
+
const hit = serviceAccounts.get(key)
|
|
126
|
+
if (hit) return hit
|
|
127
|
+
const body = await requestJson(`${CONSOLE_BASE}/service-accounts`, key)
|
|
128
|
+
const items = body && Array.isArray(body.items) ? body.items : []
|
|
129
|
+
serviceAccounts.set(key, items)
|
|
130
|
+
return items
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 用 Key 的前缀匹配 tokenHint,找出这把 Key 属于哪个服务账号。
|
|
135
|
+
* @param {Array<any>} items 服务账号列表。
|
|
136
|
+
* @param {string} key 明文 Key。
|
|
137
|
+
* @returns {{ id: string, name: string } | null} 命中的服务账号。
|
|
138
|
+
*/
|
|
139
|
+
const matchServiceAccount = (items, key) => {
|
|
140
|
+
for (const item of items) {
|
|
141
|
+
const account = item && item.account
|
|
142
|
+
if (!account || typeof account.id !== 'string') continue
|
|
143
|
+
const rows = item && Array.isArray(item.keys) ? item.keys : []
|
|
144
|
+
for (const row of rows) {
|
|
145
|
+
const hint = row && typeof row.tokenHint === 'string' ? row.tokenHint.replace(/\.+$/, '') : ''
|
|
146
|
+
if (hint && key.startsWith(hint)) return { id: account.id, name: account.name || account.id }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 用 provider 的 Key 读账户身份;读不到时退回 provider 自身,保证不误合并。
|
|
154
|
+
* 账户 = 组织(org);服务账号只作为 Key 的归属展示。
|
|
155
|
+
* @param {Account} provider provider。
|
|
156
|
+
* @returns {Promise<{ id: string, name: string, serviceAccount?: string | null, hasKey?: boolean }>} 身份。
|
|
157
|
+
*/
|
|
158
|
+
const resolveIdentity = async (provider) => {
|
|
159
|
+
const cached = identities.get(provider.ref)
|
|
160
|
+
if (cached) return cached
|
|
161
|
+
let identity = { id: `ref:${provider.ref}`, name: provider.name, serviceAccount: null, hasKey: false }
|
|
162
|
+
try {
|
|
163
|
+
const hit = await ctx.credentials.resolve(provider.ref)
|
|
164
|
+
if (hit && hit.value) {
|
|
165
|
+
identity.hasKey = true
|
|
166
|
+
/** 服务账号:用 Key 前缀匹配 tokenHint,拿不准也不影响分组。 */
|
|
167
|
+
let serviceAccount = null
|
|
168
|
+
try {
|
|
169
|
+
serviceAccount = matchServiceAccount(await listServiceAccounts(hit.value), hit.value)
|
|
170
|
+
} catch {
|
|
171
|
+
/* 不是服务账号 Key 时这个接口可能 403 */
|
|
172
|
+
}
|
|
173
|
+
let name = serviceAccount ? serviceAccount.name : provider.name
|
|
174
|
+
let user = null
|
|
175
|
+
if (!serviceAccount) {
|
|
176
|
+
try {
|
|
177
|
+
const body = await requestJson(`${CONSOLE_BASE}/usage/users`, hit.value)
|
|
178
|
+
const item = body && Array.isArray(body.items) ? body.items[0] : null
|
|
179
|
+
if (item) {
|
|
180
|
+
name = item.name || item.email || name
|
|
181
|
+
user = item.serviceUserId || item.userId || null
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
/* 读不到 users 就用 provider 名 */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** 组织 id 在 console config 的 provider 头里。 */
|
|
188
|
+
let org = null
|
|
189
|
+
try {
|
|
190
|
+
const config = await requestJson(`${CONSOLE_BASE}/config`, hit.value)
|
|
191
|
+
const providers = (config && config.config && config.config.provider) || {}
|
|
192
|
+
for (const entry of Object.values(providers)) {
|
|
193
|
+
const headers = entry && entry.options && entry.options.headers
|
|
194
|
+
const value = headers && headers['x-opencode-org-id']
|
|
195
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
196
|
+
org = value
|
|
197
|
+
break
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
/* 读不到 config 就退回服务账号 */
|
|
202
|
+
}
|
|
203
|
+
const id = org || (serviceAccount && serviceAccount.id) || user
|
|
204
|
+
/** 组织名优先用 owner 的邮箱,比服务账号名更像“账户”。 */
|
|
205
|
+
const owner = await readOwner(hit.value)
|
|
206
|
+
if (owner && owner.email) name = owner.email
|
|
207
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
208
|
+
identity = {
|
|
209
|
+
id,
|
|
210
|
+
name,
|
|
211
|
+
email: owner ? owner.email : null,
|
|
212
|
+
serviceAccount: serviceAccount ? serviceAccount.name : null,
|
|
213
|
+
serviceAccountId: serviceAccount ? serviceAccount.id : null,
|
|
214
|
+
hasKey: true,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
/* 身份读不到就按 ref 区分 */
|
|
220
|
+
}
|
|
221
|
+
identities.set(provider.ref, identity)
|
|
222
|
+
return identity
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 把 provider 按账户身份合并;同一账户的 go 与 console 合成一张卡。
|
|
227
|
+
* @returns {Promise<Array<{ id: string, name: string, refs: Array<{ kind: string, ref: string, name: string }> }>>} 账户。
|
|
228
|
+
*/
|
|
229
|
+
const discoverAccounts = async () => {
|
|
230
|
+
const groups = new Map()
|
|
231
|
+
for (const provider of providerEntries()) {
|
|
232
|
+
const identity = await resolveIdentity(provider)
|
|
233
|
+
const group = groups.get(identity.id) || { id: identity.id, name: identity.name, refs: [], hasKey: false }
|
|
234
|
+
group.hasKey = group.hasKey || Boolean(identity.hasKey)
|
|
235
|
+
if (!group.refs.some((row) => row.ref === provider.ref)) {
|
|
236
|
+
group.refs.push({ kind: provider.kind, ref: provider.ref, name: provider.name })
|
|
237
|
+
}
|
|
238
|
+
groups.set(identity.id, group)
|
|
239
|
+
}
|
|
240
|
+
const list = [...groups.values()]
|
|
241
|
+
/** 只配了一个 provider 时,别为没配 Key 的另一个 provider 再画一张空账户。 */
|
|
242
|
+
const hasIdentified = list.some((group) => !group.id.startsWith('ref:'))
|
|
243
|
+
return hasIdentified ? list.filter((group) => !group.id.startsWith('ref:') || group.hasKey) : list
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 用一把 Key 探测账户有没有 Go 订阅。
|
|
248
|
+
* @param {string} key 明文 Key。
|
|
249
|
+
* @returns {Promise<boolean | null>} true=有额度,false=404 没订阅,null=这次判断不了。
|
|
250
|
+
*/
|
|
251
|
+
const probeGoWithKey = async (key) => {
|
|
252
|
+
try {
|
|
253
|
+
await fetchQuota(key)
|
|
254
|
+
return true
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error && error.code === 'NOT_SUBSCRIBED') return false
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 探测账户是否有 Go 订阅:优先 Go 预设的 Key,读不到就换下一把。
|
|
263
|
+
* 订阅是组织级的,所以只按账户探测一次,60 秒内复用。
|
|
264
|
+
* @param {Account} account 账户。
|
|
265
|
+
* @returns {Promise<boolean | null>} 探测结果。
|
|
266
|
+
*/
|
|
267
|
+
const probeGo = async (account) => {
|
|
268
|
+
const cached = goProbes.get(account.id)
|
|
269
|
+
if (cached && Date.now() - cached.at < GO_PROBE_MS) return cached.hasGo
|
|
270
|
+
const candidates = []
|
|
271
|
+
for (const entry of account.refs) {
|
|
272
|
+
try {
|
|
273
|
+
const credential = await ctx.credentials.resolve(entry.ref)
|
|
274
|
+
if (credential && credential.value) candidates.push({ kind: entry.kind, key: credential.value })
|
|
275
|
+
} catch {
|
|
276
|
+
/* 单把 Key 读不到不影响其他 Key */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
candidates.sort((a, b) => (a.kind === 'go' ? 0 : 1) - (b.kind === 'go' ? 0 : 1))
|
|
280
|
+
let hasGo = null
|
|
281
|
+
for (const candidate of candidates) {
|
|
282
|
+
const result = await probeGoWithKey(candidate.key)
|
|
283
|
+
if (result !== null) {
|
|
284
|
+
hasGo = result
|
|
285
|
+
break
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
goProbes.set(account.id, { at: Date.now(), hasGo })
|
|
289
|
+
return hasGo
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 单个账户的配置与凭据状态。
|
|
294
|
+
* @param {Account} account 账户。
|
|
295
|
+
* @returns {Promise<any>} 前端用的状态。
|
|
296
|
+
*/
|
|
297
|
+
const stateOf = async (account) => {
|
|
298
|
+
const keys = []
|
|
299
|
+
for (const entry of account.refs) {
|
|
300
|
+
const info = await ctx.credentials.describe(entry.ref)
|
|
301
|
+
/** 每个 provider 的 Key 属于哪个服务账号,仅作展示。 */
|
|
302
|
+
const identity = await resolveIdentity({ ...entry, id: entry.ref }).catch(() => null)
|
|
303
|
+
keys.push({
|
|
304
|
+
kind: entry.kind,
|
|
305
|
+
ref: entry.ref,
|
|
306
|
+
name: entry.name,
|
|
307
|
+
configured: Boolean(info && info.configured),
|
|
308
|
+
source: (info && info.source) || null,
|
|
309
|
+
serviceAccount: (identity && identity.serviceAccount) || null,
|
|
310
|
+
serviceAccountId: (identity && identity.serviceAccountId) || null,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
/** 订阅与否看接口,不看 provider 名;探测不出来时才退回预设。 */
|
|
314
|
+
const hasGo = await probeGo(account)
|
|
315
|
+
const go = keys.find((row) => row.kind === 'go') || null
|
|
316
|
+
const configured = keys.find((row) => row.configured) || go || keys[0] || null
|
|
317
|
+
/** 组织里出现过的服务账号名,用于给 org 起一个能看懂的名字。 */
|
|
318
|
+
const members = [...new Set(keys.map((row) => row.serviceAccount).filter(Boolean))]
|
|
319
|
+
return {
|
|
320
|
+
id: account.id,
|
|
321
|
+
name: account.name,
|
|
322
|
+
members,
|
|
323
|
+
hasGo,
|
|
324
|
+
kind: hasGo === true ? 'go' : hasGo === false ? 'console' : go ? 'go' : 'console',
|
|
325
|
+
keys,
|
|
326
|
+
keyRef: configured ? configured.ref : null,
|
|
327
|
+
configured: keys.some((row) => row.configured),
|
|
328
|
+
source: configured ? configured.source : null,
|
|
329
|
+
writable: true,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 所有账户的状态列表。
|
|
335
|
+
* @returns {Promise<any>} 含 `accounts` 的状态。
|
|
336
|
+
*/
|
|
337
|
+
const state = async () => {
|
|
338
|
+
const accounts = await discoverAccounts()
|
|
339
|
+
const rows = await Promise.all(accounts.map((account) => stateOf(account)))
|
|
340
|
+
return { accounts: rows, baseURL: goBase }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 找到账户;未知 id 返回 undefined。
|
|
345
|
+
* @param {string | null} id 账户 id。
|
|
346
|
+
* @returns {Account | undefined} 账户。
|
|
347
|
+
*/
|
|
348
|
+
const pickAccount = async (id) => {
|
|
349
|
+
const accounts = await discoverAccounts()
|
|
350
|
+
return accounts.find((account) => account.id === id) || accounts[0]
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 读某个账户的额度视图(带 10 秒缓存)。
|
|
355
|
+
* @param {Account} account 账户。
|
|
356
|
+
* @param {boolean} fresh 是否跳过缓存。
|
|
357
|
+
* @param {string} [sa] 服务账号过滤串;空串表示整个 org。
|
|
358
|
+
* @returns {Promise<any>} 视图。
|
|
359
|
+
*/
|
|
360
|
+
const readView = async (account, fresh, sa = '') => {
|
|
361
|
+
const cacheKey = `${account.id}:${sa}`
|
|
362
|
+
const hit = cache.get(cacheKey)
|
|
363
|
+
if (!fresh && hit && Date.now() - hit.at < CACHE_MS) return hit.view
|
|
364
|
+
const keys = []
|
|
365
|
+
for (const entry of account.refs) {
|
|
366
|
+
const credential = await ctx.credentials.resolve(entry.ref)
|
|
367
|
+
if (credential && credential.value) keys.push({ kind: entry.kind, value: credential.value })
|
|
368
|
+
}
|
|
369
|
+
if (keys.length === 0) {
|
|
370
|
+
const error = new Error(`${account.name} 未配置 API Key`)
|
|
371
|
+
error.code = 'NO_KEY'
|
|
372
|
+
throw error
|
|
373
|
+
}
|
|
374
|
+
/** 额度接口两把 Key 都能读;优先 Go 预设的 Key。 */
|
|
375
|
+
const goKey = keys.find((row) => row.kind === 'go')
|
|
376
|
+
const consoleKey = keys.find((row) => row.kind === 'console')
|
|
377
|
+
const picked = goKey || consoleKey
|
|
378
|
+
const key = picked.value
|
|
379
|
+
/** 选了某个服务账号就按它过滤花费;选 org 时是整个组织。 */
|
|
380
|
+
const filter = sa ? `serviceAccountId=${encodeURIComponent(sa)}` : ''
|
|
381
|
+
const summary = await fetchSummary(key, null, filter).catch(() => null)
|
|
382
|
+
/** 一次取 7 天的小时桶:7 天图按本地日聚合,24 小时图取最后 24 格。 */
|
|
383
|
+
let hourly = await fetchDaily(key, '7d', 'hour', filter).catch(() => null)
|
|
384
|
+
let daily = null
|
|
385
|
+
if (!hourly || hourly.length === 0) {
|
|
386
|
+
daily = await fetchDaily(key, '7d', '', filter).catch(() => null)
|
|
387
|
+
hourly = await fetchDaily(key, '24h', 'hour', filter).catch(() => null)
|
|
388
|
+
}
|
|
389
|
+
const compare = await fetchCompare(key, filter).catch(() => null)
|
|
390
|
+
const models = await fetchModels(key, filter).catch(() => null)
|
|
391
|
+
/** 已知没有 Go 订阅时跳过额度请求;手动刷新则重新探测。 */
|
|
392
|
+
let quota = null
|
|
393
|
+
const known = goProbes.get(account.id)
|
|
394
|
+
if (fresh || !known || known.hasGo !== false) {
|
|
395
|
+
try {
|
|
396
|
+
quota = await fetchQuota(key)
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (!error || error.code !== 'NOT_SUBSCRIBED') throw error
|
|
399
|
+
}
|
|
400
|
+
goProbes.set(account.id, { at: Date.now(), hasGo: Boolean(quota) })
|
|
401
|
+
}
|
|
402
|
+
const period = await fetchPeriod(key, quota ? quota.monthly : null, filter).catch(() => null)
|
|
403
|
+
const view = { kind: quota ? 'go' : 'console', quota, windows: quota, summary, daily, hourly, compare, models, period }
|
|
404
|
+
cache.set(cacheKey, { at: Date.now(), view })
|
|
405
|
+
return view
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** 客户端激活/报错日志:写到 $DSH_HOME/opencode-quota/client.log,便于排查“没组件”。 */
|
|
409
|
+
const appendClientLog = async (text) => {
|
|
410
|
+
try {
|
|
411
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
412
|
+
const dir = join(home, 'opencode-quota')
|
|
413
|
+
await mkdir(dir, { recursive: true })
|
|
414
|
+
await appendFile(join(dir, 'client.log'), new Date().toISOString() + ' ' + text + '\n')
|
|
415
|
+
} catch {
|
|
416
|
+
/* 日志失败不影响主流程 */
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
ctx.effect(
|
|
421
|
+
() =>
|
|
422
|
+
ctx.webServer.register({
|
|
423
|
+
kind: 'exact',
|
|
424
|
+
path: '/api/opencode-quota/log',
|
|
425
|
+
handler: async (req, res) => {
|
|
426
|
+
if (!allowed(req)) return deny(res)
|
|
427
|
+
if (!method(req, res, 'POST')) return
|
|
428
|
+
try {
|
|
429
|
+
const body = await readJson(req)
|
|
430
|
+
await appendClientLog(String((body && body.text) || '').slice(0, 4000))
|
|
431
|
+
send(res, 200, { ok: true })
|
|
432
|
+
} catch (error) {
|
|
433
|
+
fail(res, error)
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
}),
|
|
437
|
+
'opencode-quota: /log route',
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
ctx.effect(
|
|
441
|
+
() =>
|
|
442
|
+
ctx.webServer.register({
|
|
443
|
+
kind: 'exact',
|
|
444
|
+
path: '/api/opencode-quota/state',
|
|
445
|
+
handler: async (req, res) => {
|
|
446
|
+
if (!allowed(req)) return deny(res)
|
|
447
|
+
if (!method(req, res, 'GET')) return
|
|
448
|
+
try {
|
|
449
|
+
send(res, 200, await state())
|
|
450
|
+
} catch (error) {
|
|
451
|
+
fail(res, error)
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
}),
|
|
455
|
+
'opencode-quota: /state route',
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
ctx.effect(
|
|
459
|
+
() =>
|
|
460
|
+
ctx.webServer.register({
|
|
461
|
+
kind: 'exact',
|
|
462
|
+
path: '/api/opencode-quota/key',
|
|
463
|
+
handler: async (req, res) => {
|
|
464
|
+
if (!allowed(req)) return deny(res)
|
|
465
|
+
if (!method(req, res, 'POST')) return
|
|
466
|
+
try {
|
|
467
|
+
const body = await readJson(req)
|
|
468
|
+
/** 可写引用来自全部 provider,不能用过滤后的账户列表,否则没配 Key 的 provider 会被漏掉。 */
|
|
469
|
+
const refs = providerEntries().map((row) => row.ref)
|
|
470
|
+
const ref = typeof body.ref === 'string' && refs.includes(body.ref) ? body.ref : refs[0]
|
|
471
|
+
if (!ref) throw new Error('没有可写的 Key 引用')
|
|
472
|
+
const value = typeof body.key === 'string' ? body.key.trim() : ''
|
|
473
|
+
if (value.length === 0) await ctx.credentials.unset(ref)
|
|
474
|
+
else await ctx.credentials.set(ref, value)
|
|
475
|
+
identities.delete(ref)
|
|
476
|
+
serviceAccounts.clear()
|
|
477
|
+
cache.clear()
|
|
478
|
+
goProbes.clear()
|
|
479
|
+
const accounts = await discoverAccounts()
|
|
480
|
+
const account = accounts.find((row) => row.refs.some((entry) => entry.ref === ref)) || accounts[0]
|
|
481
|
+
send(res, 200, await stateOf(account))
|
|
482
|
+
} catch (error) {
|
|
483
|
+
fail(res, error)
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
}),
|
|
487
|
+
'opencode-quota: /key route',
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
ctx.effect(
|
|
491
|
+
() =>
|
|
492
|
+
ctx.webServer.register({
|
|
493
|
+
kind: 'exact',
|
|
494
|
+
path: '/api/opencode-quota/usage',
|
|
495
|
+
handler: async (req, res) => {
|
|
496
|
+
if (!allowed(req)) return deny(res)
|
|
497
|
+
if (!method(req, res, 'GET')) return
|
|
498
|
+
const params = new URL(req.url || '/', 'http://127.0.0.1').searchParams
|
|
499
|
+
const account = await pickAccount(params.get('account'))
|
|
500
|
+
if (!account) return send(res, 400, { error: { code: 'no-account', message: '没有可用的 opencode 账户' } })
|
|
501
|
+
try {
|
|
502
|
+
const view = await readView(account, params.get('fresh') === '1', params.get('sa') || '')
|
|
503
|
+
send(res, 200, { ...(await stateOf(account)), ...view, fetchedAt: Date.now() })
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (error && error.code === 'NO_KEY') return send(res, 200, { ...(await stateOf(account)), quota: null, summary: null })
|
|
506
|
+
fail(res, error)
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
}),
|
|
510
|
+
'opencode-quota: /usage route',
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* 用 Go 账户的 Key 读三个额度窗口。
|
|
515
|
+
* @param {string} key 明文 Key。
|
|
516
|
+
* @returns {Promise<Record<string, QuotaWindow>>} 三个窗口。
|
|
517
|
+
*/
|
|
518
|
+
async function fetchQuota(key) {
|
|
519
|
+
const body = await requestJson(`${goBase}/usage`, key)
|
|
520
|
+
return normalizeQuota(body)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* 本周与上周的花费对比(周一为界,按本地时区)。
|
|
525
|
+
* @param {string} key 明文 Key。
|
|
526
|
+
* @param {string} saFilter 服务账号过滤串(空串表示整个组织)。
|
|
527
|
+
* @returns {Promise<{ current: number, previous: number }>} 两周花费。
|
|
528
|
+
*/
|
|
529
|
+
async function fetchCompare(key, saFilter) {
|
|
530
|
+
const day = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
531
|
+
const now = new Date()
|
|
532
|
+
const mondayOffset = (now.getDay() + 6) % 7
|
|
533
|
+
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - mondayOffset)
|
|
534
|
+
const lastMonday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() - 7)
|
|
535
|
+
const lastSunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() - 1)
|
|
536
|
+
const [current, previous] = await Promise.all([
|
|
537
|
+
fetchSummary(key, `from=${day(monday)}&to=${day(now)}`, saFilter),
|
|
538
|
+
fetchSummary(key, `from=${day(lastMonday)}&to=${day(lastSunday)}`, saFilter),
|
|
539
|
+
])
|
|
540
|
+
return {
|
|
541
|
+
current: current.costUsd,
|
|
542
|
+
previous: previous.costUsd,
|
|
543
|
+
requests: current.requests,
|
|
544
|
+
tokens: current.inputTokens + current.outputTokens + current.cacheReadTokens,
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* 读最近 7 天每天的花费。
|
|
550
|
+
* @param {string} key 明文 Key。
|
|
551
|
+
* @returns {Promise<Array<{ date: string, costUsd: number, tokens: number, requests: number }>>} 每日序列。
|
|
552
|
+
*/
|
|
553
|
+
/**
|
|
554
|
+
* 当前计费周期(从 monthly 重置时间的前一个月到今天)的每日花费。
|
|
555
|
+
* @param {string} key 明文 Key。
|
|
556
|
+
* @param {any} monthly monthly 窗口。
|
|
557
|
+
* @param {string} saFilter 服务账号过滤串。
|
|
558
|
+
* @returns {Promise<any>} 周期与每日花费。
|
|
559
|
+
*/
|
|
560
|
+
async function fetchPeriod(key, monthly, saFilter) {
|
|
561
|
+
if (!monthly || typeof monthly.resetsAt !== 'string') return null
|
|
562
|
+
const end = new Date(monthly.resetsAt)
|
|
563
|
+
const start = new Date(end.getTime())
|
|
564
|
+
start.setMonth(start.getMonth() - 1)
|
|
565
|
+
const day = (date) =>
|
|
566
|
+
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
567
|
+
const filter = saFilter ? `&${saFilter}` : ''
|
|
568
|
+
const body = await requestJson(`${CONSOLE_BASE}/usage/cost-by-day?from=${day(start)}&to=${day(new Date())}${filter}`, key)
|
|
569
|
+
const rows = Array.isArray(body) ? body : []
|
|
570
|
+
return {
|
|
571
|
+
start: start.toISOString(),
|
|
572
|
+
end: end.toISOString(),
|
|
573
|
+
daily: rows.map((row) => ({
|
|
574
|
+
date: String(row.date || ''),
|
|
575
|
+
costUsd: Number(row.totalCostMicroCents) / 1e8,
|
|
576
|
+
tokens: Number(row.totalTokens) || 0,
|
|
577
|
+
requests: Number(row.totalRequests) || 0,
|
|
578
|
+
})),
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function fetchDaily(key, range, bucket, saFilter) {
|
|
583
|
+
const withBucket = bucket ? `&bucket=${bucket}` : ''
|
|
584
|
+
const filter = saFilter ? `&${saFilter}` : ''
|
|
585
|
+
const body = await requestJson(`${CONSOLE_BASE}/usage/cost-by-day?range=${range}${withBucket}${filter}`, key)
|
|
586
|
+
const rows = Array.isArray(body) ? body : []
|
|
587
|
+
return rows.map((row) => ({
|
|
588
|
+
date: String(row.date || ''),
|
|
589
|
+
costUsd: Number(row.totalCostMicroCents) / 1e8,
|
|
590
|
+
tokens: Number(row.totalTokens) || 0,
|
|
591
|
+
requests: Number(row.totalRequests) || 0,
|
|
592
|
+
}))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* 读按模型拆分的前几名。
|
|
597
|
+
* @param {string} key 明文 Key。
|
|
598
|
+
* @returns {Promise<Array<{ model: string, costUsd: number, requests: number, tokens: number }>>} 模型用量。
|
|
599
|
+
*/
|
|
600
|
+
async function fetchModels(key, saFilter) {
|
|
601
|
+
const filter = saFilter ? `&${saFilter}` : ''
|
|
602
|
+
const body = await requestJson(`${CONSOLE_BASE}/usage/models?pageSize=6${filter}`, key)
|
|
603
|
+
const items = body && Array.isArray(body.items) ? body.items : []
|
|
604
|
+
return items.slice(0, 6).map((row) => ({
|
|
605
|
+
model: String(row.model || ''),
|
|
606
|
+
costUsd: Number(row.totalCostMicroCents) / 1e8,
|
|
607
|
+
requests: Number(row.totalRequests) || 0,
|
|
608
|
+
tokens:
|
|
609
|
+
(Number(row.totalInputTokens) || 0) +
|
|
610
|
+
(Number(row.totalOutputTokens) || 0) +
|
|
611
|
+
(Number(row.totalCacheReadTokens) || 0) +
|
|
612
|
+
(Number(row.totalCacheWrite5mTokens) || 0) +
|
|
613
|
+
(Number(row.totalCacheWrite1hTokens) || 0),
|
|
614
|
+
}))
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* 用 Console 账户的 Key 读花费汇总。
|
|
619
|
+
* @param {string} key 明文 Key。
|
|
620
|
+
* @returns {Promise<any>} 汇总(美元金额已换算)。
|
|
621
|
+
*/
|
|
622
|
+
async function fetchSummary(key, query, saFilter) {
|
|
623
|
+
/** Console 的「Today」= 本地日界,对应 `since` 参数;不带参数默认是 7 天。 */
|
|
624
|
+
const today = new Date()
|
|
625
|
+
const since = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString()
|
|
626
|
+
const filter = saFilter ? `&${saFilter}` : ''
|
|
627
|
+
const search = (query || `since=${encodeURIComponent(since)}`) + filter
|
|
628
|
+
const body = await requestJson(`${CONSOLE_BASE}/usage/summary?${search}`, key)
|
|
629
|
+
const micro = Number(body && body.totalCostMicroCents)
|
|
630
|
+
return {
|
|
631
|
+
requests: Number(body && body.totalRequests) || 0,
|
|
632
|
+
inputTokens: Number(body && body.totalInputTokens) || 0,
|
|
633
|
+
outputTokens: Number(body && body.totalOutputTokens) || 0,
|
|
634
|
+
cacheReadTokens: Number(body && body.totalCacheReadTokens) || 0,
|
|
635
|
+
costUsd: Number.isFinite(micro) ? micro / 1e8 : null,
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* 请求上游 JSON;401/403/404 转成可读原因。
|
|
642
|
+
* @param {string} url 地址。
|
|
643
|
+
* @param {string} key 明文 Key。
|
|
644
|
+
* @returns {Promise<any>} 解析后的 JSON。
|
|
645
|
+
*/
|
|
646
|
+
async function requestJson(url, key) {
|
|
647
|
+
/** @type {Response} */
|
|
648
|
+
let response
|
|
649
|
+
try {
|
|
650
|
+
response = await fetch(url, {
|
|
651
|
+
method: 'GET',
|
|
652
|
+
redirect: 'error',
|
|
653
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
654
|
+
headers: {
|
|
655
|
+
authorization: `Bearer ${key}`,
|
|
656
|
+
accept: 'application/json',
|
|
657
|
+
'cache-control': 'no-cache',
|
|
658
|
+
pragma: 'no-cache',
|
|
659
|
+
},
|
|
660
|
+
})
|
|
661
|
+
} catch (error) {
|
|
662
|
+
throw new Error(`连不上 ${new URL(url).host}:${errorText(error)}`)
|
|
663
|
+
}
|
|
664
|
+
let raw
|
|
665
|
+
try {
|
|
666
|
+
raw = await response.text()
|
|
667
|
+
} catch (error) {
|
|
668
|
+
throw new Error(`读取上游响应失败:${errorText(error)}`)
|
|
669
|
+
}
|
|
670
|
+
if (raw.length > MAX_RESPONSE_BYTES) throw new Error('上游响应过大')
|
|
671
|
+
if (response.status === 404) {
|
|
672
|
+
const error = new Error('这个账户没有 Go 订阅额度')
|
|
673
|
+
error.code = 'NOT_SUBSCRIBED'
|
|
674
|
+
throw error
|
|
675
|
+
}
|
|
676
|
+
if (!response.ok) {
|
|
677
|
+
const hint = response.status === 401 || response.status === 403 ? ',请检查 Key 是否正确' : ''
|
|
678
|
+
throw new Error(`上游返回 HTTP ${response.status}${hint}`)
|
|
679
|
+
}
|
|
680
|
+
try {
|
|
681
|
+
return JSON.parse(raw)
|
|
682
|
+
} catch {
|
|
683
|
+
throw new Error('上游返回的不是 JSON')
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* 把 Go 的 usage 文档归一化为三个窗口。
|
|
689
|
+
* @param {unknown} payload 上游 JSON。
|
|
690
|
+
* @returns {Record<string, QuotaWindow>} 归一化窗口。
|
|
691
|
+
*/
|
|
692
|
+
function normalizeQuota(payload) {
|
|
693
|
+
const body = payload && typeof payload === 'object' ? payload : null
|
|
694
|
+
if (!body) throw new Error('上游返回格式无法识别')
|
|
695
|
+
const source = body.usage && typeof body.usage === 'object' ? body.usage : body
|
|
696
|
+
/** @type {Record<string, QuotaWindow | null>} */
|
|
697
|
+
const quota = {}
|
|
698
|
+
for (const name of QUOTA_WINDOWS) {
|
|
699
|
+
const row = source[name]
|
|
700
|
+
if (!row || typeof row !== 'object') {
|
|
701
|
+
quota[name] = null
|
|
702
|
+
continue
|
|
703
|
+
}
|
|
704
|
+
const percent = Number(row.percent)
|
|
705
|
+
quota[name] = {
|
|
706
|
+
percent: Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : null,
|
|
707
|
+
status: row.status === 'rate-limited' ? 'limited' : 'ok',
|
|
708
|
+
resetsAt: typeof row.resetsAt === 'string' ? row.resetsAt : null,
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (QUOTA_WINDOWS.every((name) => quota[name] === null)) throw new Error('上游返回里没有额度窗口')
|
|
712
|
+
return quota
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* 是否放行:仅本机回环,且拒绝跨站来源。
|
|
717
|
+
* @param {import('node:http').IncomingMessage} req 请求。
|
|
718
|
+
* @returns {boolean} 放行时为 true。
|
|
719
|
+
*/
|
|
720
|
+
function allowed(req) {
|
|
721
|
+
if (!LOOPBACK.has(String((req.socket && req.socket.remoteAddress) || ''))) return false
|
|
722
|
+
const site = req.headers['sec-fetch-site']
|
|
723
|
+
if (site !== undefined && site !== 'same-origin' && site !== 'none') return false
|
|
724
|
+
const origin = req.headers.origin
|
|
725
|
+
if (origin !== undefined) {
|
|
726
|
+
try {
|
|
727
|
+
if (new URL(origin).host !== req.headers.host) return false
|
|
728
|
+
} catch {
|
|
729
|
+
return false
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return true
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* 读 JSON 请求体。
|
|
737
|
+
* @param {import('node:http').IncomingMessage} req 请求。
|
|
738
|
+
* @returns {Promise<Record<string, unknown>>} 解析后的对象。
|
|
739
|
+
*/
|
|
740
|
+
async function readJson(req) {
|
|
741
|
+
/** @type {Buffer[]} */
|
|
742
|
+
const chunks = []
|
|
743
|
+
let size = 0
|
|
744
|
+
for await (const chunk of req) {
|
|
745
|
+
size += chunk.length
|
|
746
|
+
if (size > MAX_BODY_BYTES) throw new Error('请求体过大')
|
|
747
|
+
chunks.push(chunk)
|
|
748
|
+
}
|
|
749
|
+
if (size === 0) return {}
|
|
750
|
+
try {
|
|
751
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
752
|
+
return value && typeof value === 'object' ? value : {}
|
|
753
|
+
} catch {
|
|
754
|
+
throw new Error('请求体不是合法 JSON')
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* 校验请求方法。
|
|
760
|
+
* @param {import('node:http').IncomingMessage} req 请求。
|
|
761
|
+
* @param {import('node:http').ServerResponse} res 响应。
|
|
762
|
+
* @param {string} expected 期望的方法。
|
|
763
|
+
* @returns {boolean} 方法匹配时为 true。
|
|
764
|
+
*/
|
|
765
|
+
function method(req, res, expected) {
|
|
766
|
+
if (req.method === expected) return true
|
|
767
|
+
send(res, 405, { error: { code: 'method-not-allowed', message: `只接受 ${expected}` } })
|
|
768
|
+
return false
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* 写 JSON 响应。
|
|
773
|
+
* @param {import('node:http').ServerResponse} res 响应。
|
|
774
|
+
* @param {number} status 状态码。
|
|
775
|
+
* @param {unknown} payload 响应体。
|
|
776
|
+
*/
|
|
777
|
+
function send(res, status, payload) {
|
|
778
|
+
res.writeHead(status, {
|
|
779
|
+
'content-type': 'application/json; charset=utf-8',
|
|
780
|
+
'cache-control': 'no-store',
|
|
781
|
+
})
|
|
782
|
+
res.end(JSON.stringify(payload))
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* 拒绝非本机来源。
|
|
787
|
+
* @param {import('node:http').ServerResponse} res 响应。
|
|
788
|
+
*/
|
|
789
|
+
function deny(res) {
|
|
790
|
+
send(res, 403, { error: { code: 'forbidden', message: '只允许本机页面访问' } })
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* 把异常写成 500。
|
|
795
|
+
* @param {import('node:http').ServerResponse} res 响应。
|
|
796
|
+
* @param {unknown} error 异常。
|
|
797
|
+
*/
|
|
798
|
+
function fail(res, error) {
|
|
799
|
+
send(res, 500, { error: { code: 'upstream-failed', message: errorText(error) } })
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* 取异常可读文本。
|
|
804
|
+
* @param {unknown} error 异常。
|
|
805
|
+
* @returns {string} 可展示的原因。
|
|
806
|
+
*/
|
|
807
|
+
function errorText(error) {
|
|
808
|
+
if (error instanceof Error) return error.message
|
|
809
|
+
return String(error)
|
|
810
|
+
}
|