dsh-workbuddy2api-hub 0.0.0-stage → 1.0.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/CHANGELOG.md +128 -0
- package/LICENSE +21 -0
- package/README.md +200 -2
- package/client/client.js +1354 -0
- package/cordis.patch.yml +10 -0
- package/docs/MAINTAINER.md +116 -0
- package/lib/hub.js +827 -0
- package/lib/index.js +1671 -0
- package/package.json +93 -4
- package/scripts/check-secrets.mjs +86 -0
- package/scripts/install-local.mjs +182 -0
- package/scripts/mock-hub.mjs +493 -0
- package/scripts/release-notes.mjs +53 -0
- package/scripts/release.mjs +107 -0
- package/scripts/smoke-live.mjs +280 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 对真实运行的 workbuddy2api-hub 做一次**只读**联调。
|
|
4
|
+
*
|
|
5
|
+
* 用途:装好插件之前先确认「地址对不对、密码对不对、能不能拿到模型」。
|
|
6
|
+
* 这里只发 GET 请求,不改 hub 上任何东西,也不碰本机 DSH 的配置与凭据:
|
|
7
|
+
* - 不写 llm-pi-ai 的 providers(ctx 里没有 settings 服务,写入会自然跳过)
|
|
8
|
+
* - 不读 hub 上的明文 API Key(`autoApiKey: false`)
|
|
9
|
+
* - 状态文件丢在系统临时目录
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* node scripts/smoke-live.mjs
|
|
13
|
+
* node scripts/smoke-live.mjs --url http://127.0.0.1:8788 --pwd admin
|
|
14
|
+
* node scripts/smoke-live.mjs --json
|
|
15
|
+
*
|
|
16
|
+
* 退出码:0 一切正常 / 2 连不上 / 3 密码不对或没有权限 / 4 其它(含超时)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
20
|
+
import { tmpdir } from 'node:os'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
import { apply, constants } from '../lib/index.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 解析命令行参数。
|
|
27
|
+
* @param argv - `process.argv.slice(2)`。
|
|
28
|
+
* @returns 选项对象。
|
|
29
|
+
*/
|
|
30
|
+
function parseArgs(argv) {
|
|
31
|
+
const options = {
|
|
32
|
+
baseUrl: constants.DEFAULT_BASE_URL,
|
|
33
|
+
pwd: constants.DEFAULT_PWD,
|
|
34
|
+
tab: constants.DEFAULT_TAB,
|
|
35
|
+
json: false,
|
|
36
|
+
waitMs: 15000,
|
|
37
|
+
}
|
|
38
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
39
|
+
const arg = argv[index]
|
|
40
|
+
if (arg === '--json') {
|
|
41
|
+
options.json = true
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
const value = argv[index + 1]
|
|
45
|
+
if (arg === '--url' || arg === '--base-url') {
|
|
46
|
+
options.baseUrl = value
|
|
47
|
+
index += 1
|
|
48
|
+
} else if (arg === '--pwd' || arg === '--password') {
|
|
49
|
+
options.pwd = value
|
|
50
|
+
index += 1
|
|
51
|
+
} else if (arg === '--tab') {
|
|
52
|
+
options.tab = value
|
|
53
|
+
index += 1
|
|
54
|
+
} else if (arg === '--wait') {
|
|
55
|
+
options.waitMs = Number(value) || options.waitMs
|
|
56
|
+
index += 1
|
|
57
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
58
|
+
options.help = true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return options
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 造一个「只有 webServer」的假宿主。
|
|
66
|
+
*
|
|
67
|
+
* 真宿主里有 settings / credentials,这里故意不给:只读检查不应该有能力去写配置
|
|
68
|
+
* 或读凭据——拿不到服务,插件自己会退化成「只看不写」。
|
|
69
|
+
* @returns 假 ctx。
|
|
70
|
+
*/
|
|
71
|
+
function makeReadOnlyCtx() {
|
|
72
|
+
const routes = []
|
|
73
|
+
const effects = []
|
|
74
|
+
const disposers = []
|
|
75
|
+
const ctx = {
|
|
76
|
+
get: () => undefined,
|
|
77
|
+
inject: (_names, callback) => callback(ctx),
|
|
78
|
+
effect: (factory, label) => {
|
|
79
|
+
effects.push(label)
|
|
80
|
+
const dispose = factory()
|
|
81
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
82
|
+
},
|
|
83
|
+
webServer: {
|
|
84
|
+
register: (route) => {
|
|
85
|
+
routes.push(route)
|
|
86
|
+
return () => {}
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
return { ctx, routes, effects, disposers }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 打一次插件接口。
|
|
95
|
+
* @param routes - 注册到的路由表。
|
|
96
|
+
* @param path - 相对路径,例如 `/state`。
|
|
97
|
+
* @returns 解析后的 JSON。
|
|
98
|
+
*/
|
|
99
|
+
async function callRoute(routes, path) {
|
|
100
|
+
const route = routes[0]
|
|
101
|
+
if (route === undefined) throw new Error('插件没有注册任何路由')
|
|
102
|
+
const url = `/workbuddy2api-hub/api${path}`
|
|
103
|
+
const request = {
|
|
104
|
+
method: 'GET',
|
|
105
|
+
url,
|
|
106
|
+
headers: { host: '127.0.0.1' },
|
|
107
|
+
socket: { remoteAddress: '127.0.0.1' },
|
|
108
|
+
async *[Symbol.asyncIterator]() {},
|
|
109
|
+
}
|
|
110
|
+
const response = {
|
|
111
|
+
status: 0,
|
|
112
|
+
headers: {},
|
|
113
|
+
chunks: [],
|
|
114
|
+
setHeader(name, value) {
|
|
115
|
+
this.headers[String(name).toLowerCase()] = value
|
|
116
|
+
},
|
|
117
|
+
getHeader(name) {
|
|
118
|
+
return this.headers[String(name).toLowerCase()]
|
|
119
|
+
},
|
|
120
|
+
writeHead(status, headers) {
|
|
121
|
+
this.status = status
|
|
122
|
+
for (const [name, value] of Object.entries(headers ?? {})) this.setHeader(name, value)
|
|
123
|
+
return this
|
|
124
|
+
},
|
|
125
|
+
end(chunk) {
|
|
126
|
+
if (chunk !== undefined) this.chunks.push(String(chunk))
|
|
127
|
+
this.text = this.chunks.join('')
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
await route.handler(request, response)
|
|
131
|
+
const text = response.text ?? ''
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(text)
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error(`接口 ${path} 返回了非 JSON(HTTP ${response.status}):${text.slice(0, 120)}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 等启动那一次刷新结束。 */
|
|
140
|
+
async function waitForState(routes, waitMs) {
|
|
141
|
+
const deadline = Date.now() + waitMs
|
|
142
|
+
let last = null
|
|
143
|
+
while (Date.now() < deadline) {
|
|
144
|
+
const state = await callRoute(routes, '/state')
|
|
145
|
+
last = state
|
|
146
|
+
if ((state.counts?.total ?? 0) > 0 || (state.lastSyncError ?? '') !== '') return state
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, 120))
|
|
148
|
+
}
|
|
149
|
+
return last
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 把一行「标签 + 值」对齐输出。 */
|
|
153
|
+
function line(label, value) {
|
|
154
|
+
return `${label.padEnd(12, ' ')} ${value}`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 主流程。 */
|
|
158
|
+
async function main() {
|
|
159
|
+
const options = parseArgs(process.argv.slice(2))
|
|
160
|
+
if (options.help) {
|
|
161
|
+
console.log('用法:node scripts/smoke-live.mjs [--url http://127.0.0.1:8788] [--pwd admin] [--tab gateway] [--json] [--wait 15000]')
|
|
162
|
+
return 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'wb-hub-smoke-'))
|
|
166
|
+
const { ctx, routes, disposers } = makeReadOnlyCtx()
|
|
167
|
+
try {
|
|
168
|
+
apply(ctx, {
|
|
169
|
+
baseUrl: options.baseUrl,
|
|
170
|
+
pwd: options.pwd,
|
|
171
|
+
tab: options.tab,
|
|
172
|
+
stateDir,
|
|
173
|
+
autoApiKey: false,
|
|
174
|
+
intervalSeconds: 3600,
|
|
175
|
+
})
|
|
176
|
+
// apply 里启动是 fire-and-forget,这里等它把 /state 填上内容。
|
|
177
|
+
const state = await waitForState(routes, options.waitMs)
|
|
178
|
+
const failureKind = state?.failureKind ?? ''
|
|
179
|
+
const report = {
|
|
180
|
+
baseUrl: state?.baseUrl ?? options.baseUrl,
|
|
181
|
+
tab: state?.tab ?? options.tab,
|
|
182
|
+
healthy: state?.health?.ok === true,
|
|
183
|
+
accounts_ready: state?.health?.accountsReady ?? 0,
|
|
184
|
+
// 登录态看会话本身:`/panel/status` 是登录前抓的,天生是 false。
|
|
185
|
+
loggedIn: state?.loggedIn === true,
|
|
186
|
+
usingDefaultPassword: state?.panel?.passwordIsDefault === true,
|
|
187
|
+
apiKeySource: state?.apiKeySource ?? '',
|
|
188
|
+
apiKeyReady: (state?.apiKeySource ?? '') !== '',
|
|
189
|
+
models: (state?.models ?? []).map((model) => ({
|
|
190
|
+
id: model.id,
|
|
191
|
+
name: model.name,
|
|
192
|
+
contextWindow: model.contextWindow,
|
|
193
|
+
maxTokens: model.maxTokens,
|
|
194
|
+
efforts: model.efforts,
|
|
195
|
+
rate: model.rateText,
|
|
196
|
+
enabled: model.enabled,
|
|
197
|
+
})),
|
|
198
|
+
counts: state?.counts ?? { total: 0, enabled: 0, retired: 0 },
|
|
199
|
+
balance: state?.balance ?? null,
|
|
200
|
+
usage: state?.usage ?? null,
|
|
201
|
+
usageWeek: state?.usageWeek ?? null,
|
|
202
|
+
accounts: state?.accounts ?? null,
|
|
203
|
+
lastError: state?.lastError ?? '',
|
|
204
|
+
lastSyncError: state?.lastSyncError ?? '',
|
|
205
|
+
failureKind,
|
|
206
|
+
alerts: state?.alerts ?? [],
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (options.json) {
|
|
210
|
+
console.log(JSON.stringify(report, null, 2))
|
|
211
|
+
} else {
|
|
212
|
+
console.log(`dsh-workbuddy2api-hub ${constants.PLUGIN_VERSION} — 只读联调`)
|
|
213
|
+
console.log(line('服务地址', report.baseUrl))
|
|
214
|
+
console.log(line('看板页签', report.tab))
|
|
215
|
+
console.log(line('健康检查', report.healthy ? `ok(账号就绪 ${report.accounts_ready})` : `失败(${report.lastError || '未知'})`))
|
|
216
|
+
console.log(line('面板登录', report.loggedIn ? '成功' : '失败'))
|
|
217
|
+
if (report.usingDefaultPassword) console.log(line('提醒', '面板还在用默认密码 admin,建议改掉'))
|
|
218
|
+
console.log(line('API Key', report.apiKeyReady ? `就绪(来源:${report.apiKeySource})` : '缺失(/v1 会 401)'))
|
|
219
|
+
console.log(line('模型', `${report.counts.total} 个(启用 ${report.counts.enabled},宽限 ${report.counts.retired})`))
|
|
220
|
+
for (const model of report.models) {
|
|
221
|
+
const details = [model.contextWindow ? `${model.contextWindow} ctx` : 'ctx 未知', model.rate].filter((part) => part !== '')
|
|
222
|
+
if (model.efforts.length > 0) details.push(model.efforts.join('/'))
|
|
223
|
+
console.log(` - ${model.id}(${details.join(',')})`)
|
|
224
|
+
}
|
|
225
|
+
if (report.balance !== null) {
|
|
226
|
+
console.log(line('余额', `${report.balance.balance} ${report.balance.unit}`.trim()))
|
|
227
|
+
}
|
|
228
|
+
if (report.usage !== null) {
|
|
229
|
+
const rangeLabel = state?.usageRange === 'all' ? '全量' : state?.usageRange === 'week' ? '本周' : state?.usageRange === 'month' ? '本月' : '今日'
|
|
230
|
+
console.log(
|
|
231
|
+
line(
|
|
232
|
+
rangeLabel,
|
|
233
|
+
`${report.usage.requests} 次请求 / 失败 ${report.usage.errors} / ${report.usage.totalTokens} tokens / ${report.usage.credit} credit / ¥${report.usage.costCny}`,
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
// 徽标第三行要本周;宿主 1.0.5 起固定多拉这一份。
|
|
238
|
+
if (report.usageWeek !== null && report.usageWeek !== undefined) {
|
|
239
|
+
console.log(
|
|
240
|
+
line(
|
|
241
|
+
'本周',
|
|
242
|
+
`${report.usageWeek.requests} 次请求 / ${report.usageWeek.totalTokens} tokens / ${report.usageWeek.credit} credit / ¥${report.usageWeek.costCny}`,
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
if (report.accounts !== null) {
|
|
247
|
+
console.log(line('账号', `${report.accounts.total} 个(可用 ${report.accounts.ready},需重登 ${report.accounts.needRelogin})`))
|
|
248
|
+
}
|
|
249
|
+
if (report.lastError !== '') console.log(line('最后错误', report.lastError))
|
|
250
|
+
for (const alert of report.alerts) console.log(line('告警', `${alert.title}:${alert.detail}`))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (state === null || state === undefined) return 4
|
|
254
|
+
if (failureKind === 'unreachable') return 2
|
|
255
|
+
if (failureKind === 'auth') return 3
|
|
256
|
+
if (failureKind === 'timeout') return 4
|
|
257
|
+
// 连得上但没有 API Key:这不算插件失败,只是 /v1 用不了。
|
|
258
|
+
return report.healthy ? 0 : 4
|
|
259
|
+
} finally {
|
|
260
|
+
// 先停轮询与状态写入,再删临时目录:否则刚写的状态文件会让 rmdir 撞 ENOTEMPTY。
|
|
261
|
+
for (const dispose of disposers) {
|
|
262
|
+
try {
|
|
263
|
+
dispose()
|
|
264
|
+
} catch {
|
|
265
|
+
// 清理失败不影响结论。
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
269
|
+
await rm(stateDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
main()
|
|
274
|
+
.then((code) => {
|
|
275
|
+
process.exitCode = code
|
|
276
|
+
})
|
|
277
|
+
.catch((error) => {
|
|
278
|
+
console.error(`联调脚本自己出错了:${error?.message ?? error}`)
|
|
279
|
+
process.exitCode = 4
|
|
280
|
+
})
|