@waterplus-ai/waterbuddy 0.1.17 → 0.1.34
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/README.md +11 -2
- package/lib/client/index.js +466 -36
- package/lib/host/index.js +250 -23
- package/lib/host/llm-gateway.js +424 -0
- package/package.json +2 -1
- package/patches/cordis.patch.yml +67 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import http from 'node:http'
|
|
5
|
+
import https from 'node:https'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* LLM 网关客户端:把「本机设备」换成一把受额度约束的网关 key。
|
|
10
|
+
*
|
|
11
|
+
* 边界:官方 DeepSeek key 只存在于 sub2api 服务端;本模块拿到的是网关 key,
|
|
12
|
+
* 写进 harness 凭据存储后由 `llm-pi-ai` 的 `apiKeyEnv` 引用按请求解析,
|
|
13
|
+
* 因此换 key 无需重启(凭据文件是热重载的)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 与 patches/cordis.patch.yml 中 waterplus-ai 路由的 apiKeyEnv 保持一致。 */
|
|
17
|
+
export const LLM_GATEWAY_CREDENTIAL_REF = 'WATERPLUS_AI_LLM_KEY'
|
|
18
|
+
/** 提供方路由名,同时也是设置里的默认提供方值。 */
|
|
19
|
+
export const LLM_GATEWAY_PROVIDER = 'waterplus-ai'
|
|
20
|
+
/** 网关默认走平台域名;部署不同可用插件 config 或环境变量覆盖。 */
|
|
21
|
+
export const DEFAULT_LLM_GATEWAY_BASE_URL = 'https://ai.shuiwujia.com'
|
|
22
|
+
/** 首次安装时指向的默认模型。 */
|
|
23
|
+
export const DEFAULT_LLM_GATEWAY_MODEL = 'deepseek-flash'
|
|
24
|
+
/** 未登录设备的额度用尽后由客户端引导登录(见 P3)。 */
|
|
25
|
+
export const LOW_BALANCE_HINT = 0
|
|
26
|
+
|
|
27
|
+
const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/
|
|
28
|
+
|
|
29
|
+
let llmGatewayTransport
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Test seam only, mirroring `setUpstreamTransport` in `lib/host/index.js`.
|
|
33
|
+
* Production always uses the protocol-matched node request; regression tests
|
|
34
|
+
* install a scripted transport so the gateway contract is asserted offline.
|
|
35
|
+
*/
|
|
36
|
+
export function setLlmGatewayTransport(transport) {
|
|
37
|
+
llmGatewayTransport = transport
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function dshHome() {
|
|
41
|
+
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function deviceStateFile() {
|
|
45
|
+
return path.join(dshHome(), 'waterbuddy-device.json')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveGatewayBaseUrl(config) {
|
|
49
|
+
const raw = config?.llmGatewayBaseUrl || process.env.WATERBUDDY_LLM_GATEWAY_URL || DEFAULT_LLM_GATEWAY_BASE_URL
|
|
50
|
+
return String(raw).trim().replace(/\/+$/, '')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readDeviceState() {
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(fs.readFileSync(deviceStateFile(), 'utf8'))
|
|
56
|
+
return value && typeof value === 'object' ? value : {}
|
|
57
|
+
} catch {
|
|
58
|
+
return {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeDeviceState(state) {
|
|
63
|
+
try {
|
|
64
|
+
const file = deviceStateFile()
|
|
65
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
66
|
+
fs.writeFileSync(file, JSON.stringify(state), { mode: 0o600 })
|
|
67
|
+
try { fs.chmodSync(file, 0o600) } catch {}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.warn('[WaterBuddy] failed to persist device state:', error.message)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 读取本机 deviceId,没有则生成并持久化。
|
|
75
|
+
*
|
|
76
|
+
* 未登录时的额度按 deviceId 一人一份,所以它必须稳定:重装插件不能让用户
|
|
77
|
+
* 白拿一份新试用额度。
|
|
78
|
+
*/
|
|
79
|
+
export function readOrCreateDeviceId() {
|
|
80
|
+
const state = readDeviceState()
|
|
81
|
+
const existing = typeof state.deviceId === 'string' ? state.deviceId.trim() : ''
|
|
82
|
+
if (DEVICE_ID_PATTERN.test(existing)) {
|
|
83
|
+
return { deviceId: existing, state, created: false }
|
|
84
|
+
}
|
|
85
|
+
const deviceId = randomUUID()
|
|
86
|
+
const next = { ...state, deviceId, createdAt: state.createdAt || new Date().toISOString() }
|
|
87
|
+
writeDeviceState(next)
|
|
88
|
+
return { deviceId, state: next, created: true }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function markDefaultProviderApplied() {
|
|
92
|
+
writeDeviceState({ ...readDeviceState(), defaultProviderApplied: true, defaultProviderAppliedAt: new Date().toISOString() })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 拆掉 swj-kb-api 的全局响应信封。
|
|
97
|
+
*
|
|
98
|
+
* 后端注册了 `TransformInterceptor`,所有非 SSE 响应都被包成
|
|
99
|
+
* `{ success, message, code, result, timestamp }`;这里同时兼容未包裹的扁平
|
|
100
|
+
* 结构,便于直连调试或将来去掉拦截器。
|
|
101
|
+
*/
|
|
102
|
+
export function unwrapGatewayPayload(raw) {
|
|
103
|
+
if (raw && typeof raw === 'object' && raw.result && typeof raw.result === 'object') {
|
|
104
|
+
return raw.result
|
|
105
|
+
}
|
|
106
|
+
return raw
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 校验后端响应,缺失 apiKey 视为失败而不是写入空凭据。 */
|
|
110
|
+
export function normalizeCredentialPayload(raw) {
|
|
111
|
+
const payload = unwrapGatewayPayload(raw)
|
|
112
|
+
const apiKey = typeof payload?.apiKey === 'string' ? payload.apiKey.trim() : ''
|
|
113
|
+
if (!apiKey) {
|
|
114
|
+
throw new Error('llm-credential-missing-api-key')
|
|
115
|
+
}
|
|
116
|
+
const baseURL = typeof payload?.baseURL === 'string' ? payload.baseURL.trim() : ''
|
|
117
|
+
return {
|
|
118
|
+
apiKey,
|
|
119
|
+
baseURL,
|
|
120
|
+
provider: typeof payload?.provider === 'string' && payload.provider.trim() ? payload.provider.trim() : LLM_GATEWAY_PROVIDER,
|
|
121
|
+
balance: Number.isFinite(Number(payload?.balance)) ? Number(payload.balance) : 0,
|
|
122
|
+
subjectType: payload?.subjectType === 'user' ? 'user' : 'device',
|
|
123
|
+
isAnonymous: payload?.isAnonymous !== false,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 按协议选择请求实现。
|
|
129
|
+
*
|
|
130
|
+
* 生产网关走 https,但本机联调/私有部署会用 http(例如 10.x 网段的 sub2api),
|
|
131
|
+
* 硬编码 `https.request` 会直接抛 ERR_INVALID_PROTOCOL。
|
|
132
|
+
*/
|
|
133
|
+
export function selectTransport(url) {
|
|
134
|
+
return new URL(url).protocol === 'http:' ? http.request : https.request
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function defaultTransport(url, options, callback) {
|
|
138
|
+
// 必须把 callback 透传下去:漏掉它就永远收不到响应,只能等到超时。
|
|
139
|
+
return selectTransport(url)(url, { method: options.method, headers: options.headers, insecureHTTPParser: true }, callback)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 调一次 swj-kb-api 的 llm 端点,返回拆掉信封后的业务对象。
|
|
144
|
+
*
|
|
145
|
+
* 注意这里**不是**平台的 `{code, message}` 信封:swj-kb-api 是 NestJS,
|
|
146
|
+
* 响应经 `TransformInterceptor` 包成 `{success, code, result}`(见 unwrapGatewayPayload),
|
|
147
|
+
* 非 2xx 即失败并把上游 message 带出来。
|
|
148
|
+
*/
|
|
149
|
+
export async function callLlmGateway(options) {
|
|
150
|
+
const { baseUrl, path: requestPath, method = 'POST', token, body, transport, timeoutMs = 15000 } = options
|
|
151
|
+
const url = new URL(`${baseUrl}${requestPath}`)
|
|
152
|
+
const payloadText = body === undefined ? undefined : JSON.stringify(body)
|
|
153
|
+
const send = transport || llmGatewayTransport || defaultTransport
|
|
154
|
+
|
|
155
|
+
return await new Promise((resolve, reject) => {
|
|
156
|
+
const timer = setTimeout(() => reject(new Error('llm-gateway-timeout')), timeoutMs)
|
|
157
|
+
const fail = error => { clearTimeout(timer); reject(error) }
|
|
158
|
+
const headers = {}
|
|
159
|
+
if (payloadText !== undefined) {
|
|
160
|
+
headers['content-type'] = 'application/json'
|
|
161
|
+
headers['content-length'] = Buffer.byteLength(payloadText)
|
|
162
|
+
}
|
|
163
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
164
|
+
|
|
165
|
+
let request
|
|
166
|
+
try {
|
|
167
|
+
request = send(url, { method, headers }, response => {
|
|
168
|
+
const chunks = []
|
|
169
|
+
response.on('data', chunk => chunks.push(chunk))
|
|
170
|
+
response.on('end', () => {
|
|
171
|
+
clearTimeout(timer)
|
|
172
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
173
|
+
let payload
|
|
174
|
+
try { payload = text ? JSON.parse(text) : {} } catch { payload = { message: text } }
|
|
175
|
+
const status = response.statusCode || 500
|
|
176
|
+
if (status >= 400) {
|
|
177
|
+
return reject(new Error(payload?.message || `llm-gateway-failed:${status}`))
|
|
178
|
+
}
|
|
179
|
+
resolve(unwrapGatewayPayload(payload))
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return fail(error)
|
|
184
|
+
}
|
|
185
|
+
request.on('error', fail)
|
|
186
|
+
if (payloadText !== undefined) request.write(payloadText)
|
|
187
|
+
request.end()
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 领取设备档凭证(幂等:同一 deviceId 返回同一把 key)。 */
|
|
192
|
+
export async function requestDeviceCredential(options) {
|
|
193
|
+
const { baseUrl, deviceId, transport, timeoutMs = 15000 } = options
|
|
194
|
+
const payload = await callLlmGateway({
|
|
195
|
+
baseUrl,
|
|
196
|
+
path: '/swj-kb-api/client/llm/credentials',
|
|
197
|
+
method: 'POST',
|
|
198
|
+
body: { deviceId },
|
|
199
|
+
transport,
|
|
200
|
+
timeoutMs,
|
|
201
|
+
})
|
|
202
|
+
return normalizeCredentialPayload(payload)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 用登录态换账号档凭证:额度升档,并把设备试用额度并入账号。 */
|
|
206
|
+
export async function requestUserCredential(options) {
|
|
207
|
+
const { baseUrl, token, deviceId, mergeDeviceQuota = true, transport, timeoutMs = 15000 } = options
|
|
208
|
+
if (!token) throw new Error('llm-bind-missing-token')
|
|
209
|
+
const payload = await callLlmGateway({
|
|
210
|
+
baseUrl,
|
|
211
|
+
path: '/swj-kb-api/client/llm/bind',
|
|
212
|
+
method: 'POST',
|
|
213
|
+
token,
|
|
214
|
+
body: { deviceId, mergeDeviceQuota },
|
|
215
|
+
transport,
|
|
216
|
+
timeoutMs,
|
|
217
|
+
})
|
|
218
|
+
return normalizeCredentialPayload(payload)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 查当前额度。
|
|
223
|
+
*
|
|
224
|
+
* 带 token 查账号档,否则按 deviceId 查设备档——与后端 `GET /client/llm/quota`
|
|
225
|
+
* 的两种鉴权方式一致(JWT 或 `?deviceId=`)。
|
|
226
|
+
*/
|
|
227
|
+
export async function fetchLlmQuota(options) {
|
|
228
|
+
const { baseUrl, token, deviceId, transport, timeoutMs = 8000 } = options
|
|
229
|
+
const query = !token && deviceId ? `?deviceId=${encodeURIComponent(deviceId)}` : ''
|
|
230
|
+
const payload = await callLlmGateway({
|
|
231
|
+
baseUrl,
|
|
232
|
+
path: `/swj-kb-api/client/llm/quota${query}`,
|
|
233
|
+
method: 'GET',
|
|
234
|
+
token,
|
|
235
|
+
transport,
|
|
236
|
+
timeoutMs,
|
|
237
|
+
})
|
|
238
|
+
return {
|
|
239
|
+
balance: Number.isFinite(Number(payload?.balance)) ? Number(payload.balance) : 0,
|
|
240
|
+
totalRecharged: Number.isFinite(Number(payload?.totalRecharged)) ? Number(payload.totalRecharged) : 0,
|
|
241
|
+
isAnonymous: payload?.isAnonymous !== false,
|
|
242
|
+
exhausted: payload?.exhausted === true,
|
|
243
|
+
lowBalance: payload?.lowBalance === true,
|
|
244
|
+
lowBalanceThreshold: Number(payload?.lowBalanceThreshold) || 0,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 领取(或复用)设备凭证并写入 harness 凭据存储。
|
|
250
|
+
*
|
|
251
|
+
* 幂等:后端按 deviceId 返回同一把 key;凭据写入是热生效的,不需要重启。
|
|
252
|
+
*/
|
|
253
|
+
export async function ensureLlmCredential(ctx, options = {}) {
|
|
254
|
+
// 先确认凭据存储可用再发请求:没有 credentials 服务的宿主不该产生任何网络流量。
|
|
255
|
+
if (!ctx?.credentials?.set) {
|
|
256
|
+
throw new Error('llm-credential-store-unavailable')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const baseUrl = resolveGatewayBaseUrl(options.config)
|
|
260
|
+
const { deviceId, state } = readOrCreateDeviceId()
|
|
261
|
+
const credential = await requestDeviceCredential({ baseUrl, deviceId, transport: options.transport })
|
|
262
|
+
await ctx.credentials.set(LLM_GATEWAY_CREDENTIAL_REF, credential.apiKey)
|
|
263
|
+
|
|
264
|
+
return { deviceId, credential, state, baseUrl }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 首次安装把默认提供方指向 waterplus-ai。
|
|
269
|
+
*
|
|
270
|
+
* 只做一次并留标记:用户之后在 Models 设置页改回自有提供方时,插件不再抢回。
|
|
271
|
+
*/
|
|
272
|
+
export async function applyDefaultProviderOnce(ctx) {
|
|
273
|
+
const state = readDeviceState()
|
|
274
|
+
if (state.defaultProviderApplied === true) {
|
|
275
|
+
return { applied: false, reason: 'already-applied' }
|
|
276
|
+
}
|
|
277
|
+
if (!ctx?.settings?.update) {
|
|
278
|
+
return { applied: false, reason: 'settings-unavailable' }
|
|
279
|
+
}
|
|
280
|
+
await ctx.settings.update('agent-default-model', {
|
|
281
|
+
provider: LLM_GATEWAY_PROVIDER,
|
|
282
|
+
model: DEFAULT_LLM_GATEWAY_MODEL,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// 回读确认后再置标记。插件 `apply()` 跑在宿主启动早期,此时 settings 命名空间
|
|
286
|
+
// 可能尚未就绪,`update` 会静默不落盘(实测过);若无条件置标记,用户就永远
|
|
287
|
+
// 停在默认提供方上。回读不到就当作未生效,留给下一次启动 / refresh 重试。
|
|
288
|
+
const persisted = ctx.settings.get?.('agent-default-model')
|
|
289
|
+
if (persisted && typeof persisted === 'object' && persisted.provider && persisted.provider !== LLM_GATEWAY_PROVIDER) {
|
|
290
|
+
return { applied: false, reason: 'not-persisted' }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
markDefaultProviderApplied()
|
|
294
|
+
return { applied: true }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 启动早期切换默认提供方可能因命名空间未就绪而失败,这里带退避重试。
|
|
299
|
+
*
|
|
300
|
+
* 只在「确实没落盘」时重试:用户自己改过提供方(already-applied)或宿主没有
|
|
301
|
+
* settings 服务(settings-unavailable)都直接结束。
|
|
302
|
+
*/
|
|
303
|
+
export async function switchDefaultProviderWithRetry(ctx, options = {}) {
|
|
304
|
+
const attempts = options.attempts ?? 4
|
|
305
|
+
const delayMs = options.delayMs ?? 3000
|
|
306
|
+
let last = { applied: false, reason: 'not-attempted' }
|
|
307
|
+
|
|
308
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
309
|
+
last = await applyDefaultProviderOnce(ctx)
|
|
310
|
+
if (last.applied || last.reason !== 'not-persisted') {
|
|
311
|
+
return { ...last, attempts: attempt }
|
|
312
|
+
}
|
|
313
|
+
if (attempt < attempts) {
|
|
314
|
+
await new Promise(resolve => setTimeout(resolve, delayMs))
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { ...last, attempts }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 额度查询失败不该让状态接口失败,降级为 null 由调用方沿用上次读数。 */
|
|
321
|
+
async function safeQuota(options) {
|
|
322
|
+
try {
|
|
323
|
+
return await fetchLlmQuota(options)
|
|
324
|
+
} catch {
|
|
325
|
+
return null
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 登录后把 key 换成账号档(热生效),并带回最新额度。 */
|
|
330
|
+
export async function bindLlmCredential(ctx, token, options = {}) {
|
|
331
|
+
if (!ctx?.credentials?.set) throw new Error('llm-credential-store-unavailable')
|
|
332
|
+
const baseUrl = resolveGatewayBaseUrl(options.config)
|
|
333
|
+
const { deviceId } = readOrCreateDeviceId()
|
|
334
|
+
const credential = await requestUserCredential({
|
|
335
|
+
baseUrl,
|
|
336
|
+
token,
|
|
337
|
+
deviceId,
|
|
338
|
+
mergeDeviceQuota: options.mergeDeviceQuota !== false,
|
|
339
|
+
transport: options.transport,
|
|
340
|
+
})
|
|
341
|
+
await ctx.credentials.set(LLM_GATEWAY_CREDENTIAL_REF, credential.apiKey)
|
|
342
|
+
const quota = await safeQuota({ baseUrl, token, deviceId, transport: options.transport })
|
|
343
|
+
return { deviceId, credential, quota }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 登出后回落到设备档 key(设备档是幂等的,直接重领即可)。 */
|
|
347
|
+
export async function restoreDeviceLlmCredential(ctx, options = {}) {
|
|
348
|
+
if (!ctx?.credentials?.set) throw new Error('llm-credential-store-unavailable')
|
|
349
|
+
const baseUrl = resolveGatewayBaseUrl(options.config)
|
|
350
|
+
const { deviceId } = readOrCreateDeviceId()
|
|
351
|
+
const credential = await requestDeviceCredential({ baseUrl, deviceId, transport: options.transport })
|
|
352
|
+
await ctx.credentials.set(LLM_GATEWAY_CREDENTIAL_REF, credential.apiKey)
|
|
353
|
+
const quota = await safeQuota({ baseUrl, deviceId, transport: options.transport })
|
|
354
|
+
return { deviceId, credential, quota }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 只刷额度不换 key,供状态接口按需更新余额。 */
|
|
358
|
+
export async function refreshLlmQuota(options = {}) {
|
|
359
|
+
const baseUrl = resolveGatewayBaseUrl(options.config)
|
|
360
|
+
const { deviceId } = readOrCreateDeviceId()
|
|
361
|
+
const quota = await safeQuota({
|
|
362
|
+
baseUrl,
|
|
363
|
+
token: options.token,
|
|
364
|
+
deviceId,
|
|
365
|
+
transport: options.transport,
|
|
366
|
+
timeoutMs: options.timeoutMs ?? 3000,
|
|
367
|
+
})
|
|
368
|
+
return { deviceId, quota }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 读当前选中的默认提供方。
|
|
373
|
+
*
|
|
374
|
+
* 用户可以在模型页换成自己的提供方(我们**不锁定**),此时插件不应再展示额度:
|
|
375
|
+
* 消耗的不是我们的额度,显示出来只会误导。
|
|
376
|
+
*/
|
|
377
|
+
export function readActiveProvider(ctx) {
|
|
378
|
+
try {
|
|
379
|
+
const section = ctx?.settings?.get?.('agent-default-model')
|
|
380
|
+
return typeof section?.provider === 'string' ? section.provider.trim() : ''
|
|
381
|
+
} catch {
|
|
382
|
+
return ''
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 额度是否由我们的网关管辖(即当前默认提供方就是 waterplus-ai)。 */
|
|
387
|
+
export function isGatewayActive(ctx) {
|
|
388
|
+
return readActiveProvider(ctx) === LLM_GATEWAY_PROVIDER
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** 合并凭据与额度,得到下发给客户端的完整状态。 */
|
|
392
|
+
export function describeLlmState(credential, quota, deviceId, extra = {}) {
|
|
393
|
+
const balance = quota?.balance ?? credential?.balance ?? 0
|
|
394
|
+
return {
|
|
395
|
+
provider: LLM_GATEWAY_PROVIDER,
|
|
396
|
+
subjectType: credential?.subjectType ?? 'device',
|
|
397
|
+
isAnonymous: credential?.isAnonymous !== false,
|
|
398
|
+
balance,
|
|
399
|
+
totalRecharged: quota?.totalRecharged ?? 0,
|
|
400
|
+
// 额度耗尽是登录引导的唯一触发条件(见 SUB2API-TOKEN-QUOTA-PLAN §7.2)
|
|
401
|
+
exhausted: quota?.exhausted ?? balance <= LOW_BALANCE_HINT,
|
|
402
|
+
lowBalance: quota?.lowBalance === true,
|
|
403
|
+
baseURL: credential?.baseURL ?? '',
|
|
404
|
+
deviceId,
|
|
405
|
+
...extra,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** 供本地路由返回的状态快照。 */
|
|
410
|
+
export function describeLlmCredential(credential, deviceId) {
|
|
411
|
+
return {
|
|
412
|
+
provider: LLM_GATEWAY_PROVIDER,
|
|
413
|
+
subjectType: credential.subjectType,
|
|
414
|
+
isAnonymous: credential.isAnonymous,
|
|
415
|
+
balance: credential.balance,
|
|
416
|
+
exhausted: credential.balance <= LOW_BALANCE_HINT,
|
|
417
|
+
baseURL: credential.baseURL,
|
|
418
|
+
deviceId,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function readDeviceStateSnapshot() {
|
|
423
|
+
return readDeviceState()
|
|
424
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waterplus-ai/waterbuddy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"description": "WaterBuddy branding and Web profile bundle for DeepSeek Harness",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"lib/host/index.js",
|
|
16
|
+
"lib/host/llm-gateway.js",
|
|
16
17
|
"lib/client/index.js",
|
|
17
18
|
"lib/shared/persona.js",
|
|
18
19
|
"patches/cordis.patch.yml",
|
package/patches/cordis.patch.yml
CHANGED
|
@@ -4,9 +4,76 @@
|
|
|
4
4
|
- id: ui-brand-official
|
|
5
5
|
disabled: true
|
|
6
6
|
|
|
7
|
+
# WaterPlus LLM gateway route.
|
|
8
|
+
#
|
|
9
|
+
# pi-ai ships no catalog entry for our gateway, so this route declares its
|
|
10
|
+
# protocol, endpoint, credential reference, model list, and wire-compat switch
|
|
11
|
+
# outright — a route the catalog does not describe must name all of them.
|
|
12
|
+
#
|
|
13
|
+
# No secret lives here: `apiKeyEnv` is a reference the harness resolves per
|
|
14
|
+
# request from its credential store, and `lib/host` writes the gateway key into
|
|
15
|
+
# that store on startup. Because the store is hot-reloaded, rotating the key
|
|
16
|
+
# (for example after the user signs in) never needs a restart.
|
|
17
|
+
#
|
|
18
|
+
# `baseURL` must equal the backend's `SUB2API_PUBLIC_BASE_URL` plus `/v1`; the
|
|
19
|
+
# host reports the address the backend actually returned under
|
|
20
|
+
# `GET /api/waterbuddy/llm/status`, so drift is visible rather than silent.
|
|
21
|
+
#
|
|
22
|
+
# 本机联调:把 baseURL 改成 http://<你的局域网IP>:8080/v1,并把 llmGatewayBaseUrl
|
|
23
|
+
# 改成 http://<你的局域网IP>:3099(或设 WATERBUDDY_LLM_GATEWAY_URL),改的是
|
|
24
|
+
# profile 里安装后的那份 patch,不要改这里——这里是发布值。
|
|
25
|
+
- id: llm-pi-ai
|
|
26
|
+
config:
|
|
27
|
+
providers:
|
|
28
|
+
waterplus-ai:
|
|
29
|
+
displayName: WaterPlus AI
|
|
30
|
+
api: openai-completions
|
|
31
|
+
baseURL: https://token.shuiwujia.com/v1
|
|
32
|
+
apiKeyEnv: WATERPLUS_AI_LLM_KEY
|
|
33
|
+
compat:
|
|
34
|
+
thinkingFormat: deepseek
|
|
35
|
+
# DeepSeek 官方适配器对每个等级发 `thinking:{type:enabled}` + `reasoning_effort:<等级>`,
|
|
36
|
+
# 因此这里要打开,否则 pi-ai 只发 thinking 开关、等级不生效。
|
|
37
|
+
supportsReasoningEffort: true
|
|
38
|
+
# 必须关掉:pi-ai 在 `model.reasoning && supportsDeveloperRole` 时把系统提示词
|
|
39
|
+
# 的角色发成 `developer`,而 DeepSeek 只认 system/user/assistant/tool,
|
|
40
|
+
# 会直接 400 invalid_request_error。启用 reasoning 之前不发 developer,
|
|
41
|
+
# 所以这个坑是「声明 reasoningEfforts」之后才暴露的(实测踩过)。
|
|
42
|
+
supportsDeveloperRole: false
|
|
43
|
+
defaultContextWindow: 1000000
|
|
44
|
+
defaultMaxTokens: 256000
|
|
45
|
+
models:
|
|
46
|
+
- id: deepseek-flash
|
|
47
|
+
name: DeepSeek-V4-Flash
|
|
48
|
+
contextWindow: 1000000
|
|
49
|
+
maxTokens: 256000
|
|
50
|
+
input: [text, image]
|
|
51
|
+
# 必须声明:模型级省略 reasoningEfforts 会被解析成 reasoning:false,
|
|
52
|
+
# harness 里任何已保存的等级都会以 UNSUPPORTED_REASONING_EFFORT 直接跑挂。
|
|
53
|
+
# 取值与官方 DeepSeek 适配器契约一致:只接受 low / high / max。
|
|
54
|
+
reasoningEfforts:
|
|
55
|
+
low: low
|
|
56
|
+
high: high
|
|
57
|
+
max: max
|
|
58
|
+
- id: deepseek-v4-pro
|
|
59
|
+
name: DeepSeek-V4-Pro
|
|
60
|
+
contextWindow: 1000000
|
|
61
|
+
maxTokens: 256000
|
|
62
|
+
input: [text]
|
|
63
|
+
# 必须声明:模型级省略 reasoningEfforts 会被解析成 reasoning:false,
|
|
64
|
+
# harness 里任何已保存的等级都会以 UNSUPPORTED_REASONING_EFFORT 直接跑挂。
|
|
65
|
+
# 取值与官方 DeepSeek 适配器契约一致:只接受 low / high / max。
|
|
66
|
+
reasoningEfforts:
|
|
67
|
+
low: low
|
|
68
|
+
high: high
|
|
69
|
+
max: max
|
|
70
|
+
|
|
7
71
|
- insert:
|
|
8
72
|
- id: ui-brand-waterplus
|
|
9
73
|
name: '@waterplus-ai/waterbuddy'
|
|
74
|
+
# 设备领 key 的平台地址;换部署只改这里,无需改代码。
|
|
75
|
+
config:
|
|
76
|
+
llmGatewayBaseUrl: https://ai.shuiwujia.com
|
|
10
77
|
- id: mcp-xiaojia
|
|
11
78
|
name: '@deepseek-ai/dsh-mcp-client'
|
|
12
79
|
config:
|