@dsh-plugins/dsh-llm-hub 0.7.3 → 1.3.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 CHANGED
@@ -35,13 +35,90 @@ import { readFileSync } from 'node:fs'
35
35
  import { fileURLToPath } from 'node:url'
36
36
  import { applyHarness, harnessReport } from './harness.js'
37
37
 
38
+ /**
39
+ * 用量统计 domain 的 spec。
40
+ *
41
+ * 必须是模块顶层的 const —— storageDomain 的 domain name + version 在 apply 之外
42
+ * 就被多处复用(spec 自检、清理路由、导出 CSV 的列顺序),放到 apply 里会逼着每次
43
+ * 都重建。
44
+ *
45
+ * schema 字段固定下来后**不要**再加可空字段 —— 上游 schema 升级要走 storageDomain 的
46
+ * version + compatibleVersions 机制(见 `defineDomain` 文档),不在记录里堆空字段。
47
+ */
48
+ export const UsageRecordSchema = Object.freeze({
49
+ id: 'string',
50
+ provider: 'string',
51
+ model: 'string',
52
+ at: 'number',
53
+ inputTokens: 'number',
54
+ outputTokens: 'number',
55
+ cacheReadTokens: 'number',
56
+ cacheWriteTokens: 'number',
57
+ totalTokens: 'number',
58
+ finished: 'boolean'
59
+ })
60
+
61
+ /**
62
+ * 解析 `llm/stream` 的 usage chunk 字段为正数。
63
+ *
64
+ * 上游字段可能 undefined / 字符串 / NaN —— 全部折成数字或 0。
65
+ * `negative tokens` 没有意义,clamp 到 0(实测 anthropic-messages 在 0 token 完成时偶尔报 0,
66
+ * 而不是负数;但缓存命中时 cache_read 可能报超大值,得兜住)。
67
+ * @param usage - 上游的 usage 字段。
68
+ * @returns 归一化后的数字字段。
69
+ */
70
+ export function normalizeUsageForTest(usage) {
71
+ const pick = (value) => {
72
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return 0
73
+ return Math.floor(value)
74
+ }
75
+ return {
76
+ inputTokens: pick(usage?.inputTokens),
77
+ outputTokens: pick(usage?.outputTokens),
78
+ cacheReadTokens: pick(usage?.cacheReadTokens),
79
+ cacheWriteTokens: pick(usage?.cacheWriteTokens),
80
+ totalTokens: pick(usage?.totalTokens)
81
+ }
82
+ }
83
+ const normalizeUsage = normalizeUsageForTest
84
+
85
+ /**
86
+ * 把一条用量记录写进指定 table —— 测试桩可以直接调,绕开 storageDomain open。
87
+ *
88
+ * 与 observer 写记录的字段保持一致;id 用时间戳 + 随机后缀避免同一毫秒冲突。
89
+ * @param table - 任意带 put 的 KV(真 storageDomain table 或内存桩都行)。
90
+ * @param entry - 与 observer 写记录相同的入参形态。
91
+ * @returns 写入的记录 id。
92
+ */
93
+ export function recordUsageInto(table, entry) {
94
+ const id = `${entry.at}-${Math.random().toString(36).slice(2, 10)}`
95
+ table.put(id, {
96
+ id,
97
+ provider: entry.provider,
98
+ model: entry.model,
99
+ at: entry.at,
100
+ inputTokens: entry.usage.inputTokens,
101
+ outputTokens: entry.usage.outputTokens,
102
+ cacheReadTokens: entry.usage.cacheReadTokens,
103
+ cacheWriteTokens: entry.usage.cacheWriteTokens,
104
+ totalTokens: entry.usage.totalTokens,
105
+ finished: entry.finished
106
+ })
107
+ return id
108
+ }
109
+
38
110
  const NS = 'llm-deepseek'
39
111
  /** 适配器自身的公网端点默认值。 */
40
112
  const DEFAULT_BASE_URL = 'https://api.deepseek.com'
41
113
  /** 适配器自身的凭据引用默认值。 */
42
114
  const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
43
115
  /** 调用方给的端点不得让我们无上限缓冲。 */
44
- const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
116
+ /**
117
+ * 单个响应体的字节上限。网关可能返回超大 body(异常、被劫持、或对端 bug),
118
+ * 不设上限会把宿主内存打爆 —— DSH 是常驻进程,一次就够。
119
+ * 导出是为了测试能断言这个边界:变异测试确认过,改掉它而无测试失败等于没护栏。
120
+ */
121
+ export const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
45
122
  /** client 半读取余额的路由。 */
46
123
  const BALANCE_PATH = '/api/dsh-llm-hub/balance'
47
124
  /** 官方 pi-ai 适配器拥有的 settings 命名空间;这里只旁路读取,绝不注册 discovery。 */
@@ -58,10 +135,75 @@ const HARNESS_PATH = '/api/dsh-llm-hub/harness'
58
135
  /** 模型下拉可用性:读缓存 / 强制全量重探。 */
59
136
  const AVAILABILITY_PATH = '/api/dsh-llm-hub/availability'
60
137
  const AVAILABILITY_RECHECK_PATH = '/api/dsh-llm-hub/availability/recheck'
138
+ /**
139
+ * 健康看板路由 —— 跟 availability 同一份数据,承诺的字段更全:
140
+ * - providers[].latencyMs:探测耗时(健康看板要看的延迟)
141
+ * - providers[].status:上游 HTTP 状态码(401 / 403 / 402 / 5xx 一眼可分)
142
+ * availability 路由沿用旧响应(向后兼容,老 client 不会炸)。
143
+ */
144
+ const HEALTH_PATH = '/api/dsh-llm-hub/health'
145
+ /**
146
+ * 用量统计:月度聚合 + 清除。
147
+ *
148
+ * 存储走 storageDomain —— 这是 DSH 的官方本地持久化服务,本插件依赖它写一条
149
+ * per-call 记录(provider / model / input / output / cacheRead / cacheWrite /
150
+ * 时间戳),路由层做月度聚合(按本地日历月,不是 UTC 月 —— 用户看的是「本月花了多少」)。
151
+ * 钱不在这算:开源 DSH 的 llm 服务只暴露 imageRequestPricing,没有 chat 文本定价接口,
152
+ * 强行给每个 provider 写硬编码价目表既过时又快塌。把 token 用量留给用户自己对照官方价目表,
153
+ * 导出见 `GET /api/dsh-llm-hub/usage/export`(CSV)。
154
+ */
155
+ const USAGE_SUMMARY_PATH = '/api/dsh-llm-hub/usage/summary'
156
+ const USAGE_EXPORT_PATH = '/api/dsh-llm-hub/usage/export'
157
+ const USAGE_CLEAR_PATH = '/api/dsh-llm-hub/usage/clear'
158
+ /**
159
+ * 余额预警:客户端把当前余额信封 POST 进来,host 半按 settings 阈值判定是否低于红线。
160
+ * 阈值不直接暴露前端 —— 一是数字本身没多大用,二是怕人改 settings 把阈值关掉再回来说没提示。
161
+ */
162
+ const WARNING_CHECK_PATH = '/api/dsh-llm-hub/warning/check'
163
+ /**
164
+ * 智能路由建议:把当前可用性 + 用户声明的 fallback 顺序折成「该用哪个」。
165
+ *
166
+ * **不做自动切换** —— DSH 的 `conversation.input.model` 是 single + user-controlled
167
+ * slot(`replaceRisk: shadows-shipped-ui`),没有供插件改的 setter;强行覆盖会
168
+ * 撞 README 红线。这条路由返回的是**建议**,真值为用户在 UI 里点一下。
169
+ *
170
+ * 请求体:当前已激活用户的 provider/model(如 'modelgo' / 'gpt-4o'),返回:
171
+ * {
172
+ * ok: true,
173
+ * active: { provider, model, state: 'available'|'unknown'|'unavailable' },
174
+ * candidates: [{ provider, model, state, ... }, ...], // 主 → fallback → 备用
175
+ * recommendation: { provider, model } | null, // 第一个 available 的
176
+ * reason: '...' // 拿不到 recommendation 时
177
+ * }
178
+ */
179
+ const ROUTING_RESOLVE_PATH = '/api/dsh-llm-hub/routing/resolve'
180
+ /** 模型别名:客户端渲染 model id 时显示短名(典型场景:deepseek-reasoner → 推理)。 */
181
+ const ALIASES_PATH = '/api/dsh-llm-hub/aliases'
182
+ /**
183
+ * 多账号 key 轮换:settings.dsh-llm-hub.keyPool[provider] 是 entry 数组。
184
+ *
185
+ * 设计取舍:
186
+ * - **向后兼容**:未配 keyPool 段时直接走原 apiKeyEnv 单 key 路径,行为完全
187
+ * 不变;现有 80 个测试不破。
188
+ * - **round-robin**(非失败感知):每次解析按索引取下一个 entry;不加锁、不跨
189
+ * session 持久化(重启会从 0 开始)。失败的 key 不会自动跳过 —— 用户从
190
+ * debug 路由看到「上次失败的是 X」,下次请求自然轮换过去。这是 1.x 的轻量方案,
191
+ * 真要自动跳过失败 key 等 1.1 收集了真实失败场景再加。
192
+ * - **不覆盖显式 key**:request.apiKey 是一次性覆盖(发现草稿用的),绕过
193
+ * 轮换直接用它;轮换索引不动。
194
+ * - **provider 范围**:DeepSeek 官方直连用 NS = 'llm-deepseek' 作为 key,pi-ai
195
+ * provider 用 provider 自己的 id(modelgo / minimax ...);keyPool 段里
196
+ * 直接用 provider id 作 key。
197
+ */
198
+ const KEYPOOL_STATUS_PATH = '/api/dsh-llm-hub/keypool/status'
199
+ /** 路由配置从 settings 哪一段读 —— 顶层 dsh-llm-hub 段下,与 warning 同级。 */
200
+ const ROUTING_KEY = 'routing'
61
201
  /** 探针结论的保鲜期;过期后下次读取顺手在后台重探,不阻塞下拉。 */
62
- const AVAILABILITY_TTL_MS = 5 * 60 * 1000
202
+ /** 可用性缓存有效期。太长则换了 key 还在报旧状态,太短则每次开设置页都打一圈网关。 */
203
+ export const AVAILABILITY_TTL_MS = 5 * 60 * 1000
63
204
  /** 一轮探测最长允许在途的时间;超过就允许再开一轮(超时兜底的兜底)。 */
64
- const STUCK_PROBE_MS = 60 * 1000
205
+ /** 超过这个时间仍未结束的探测视为卡死,允许重新发起 —— 否则一次卡死永久占住槽位。 */
206
+ export const STUCK_PROBE_MS = 60 * 1000
65
207
  /** 运行期失败标记的保鲜期:充值/续期后不必等手动刷新。 */
66
208
  const RUNTIME_TTL_MS = 10 * 60 * 1000
67
209
  /**
@@ -71,7 +213,8 @@ const RUNTIME_TTL_MS = 10 * 60 * 1000
71
213
  * 于是 `probing` 一直 true、判定再也不刷新 —— 表现为「面板停在旧结论上,下拉跟着旧结论走」。
72
214
  * 2026-09-16 实测踩到过一次。
73
215
  */
74
- const REQUEST_TIMEOUT_MS = 6000
216
+ /** 单次外部请求超时。网关不响应时不能让探测挂死,否则 UI 一直转圈。 */
217
+ export const REQUEST_TIMEOUT_MS = 6000
75
218
  /**
76
219
  * 给一次外部请求加超时,并保留调用方自己的取消。
77
220
  * @param signal - 调用方取消信号(可选)。
@@ -165,7 +308,7 @@ function readListing(body) {
165
308
  * @returns 解码后的正文。
166
309
  * @throws {Error} 当正文超过 {@link MAX_RESPONSE_BYTES} 时。
167
310
  */
168
- async function readBounded(response, url) {
311
+ export async function readBounded(response, url) {
169
312
  const oversized = () => new Error(`${url} answered with more than ${MAX_RESPONSE_BYTES} bytes`)
170
313
  const declared = Number(response.headers.get('content-length') ?? NaN)
171
314
  if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
@@ -214,7 +357,12 @@ function sendJson(response, status, payload) {
214
357
  }
215
358
 
216
359
  export const name = 'dsh-llm-hub'
217
- /** 发现注册表在 llm 服务上;余额路由在 webServer 上。两者都是硬依赖。 */
360
+ /**
361
+ * 硬依赖:llm(注册发现 + 监听 llm/stream + 过滤 listProviders)、webServer(HTTP 路由)。
362
+ *
363
+ * 用量统计走的是**局部注入**:`storageDomain` 缺席时不报错,本插件其余功能照常工作
364
+ * —— 没有 storageDomain 也能用余额/可用性/harness,只是没法跨会话记 token 用量。
365
+ */
218
366
  export const inject = ['llm', 'webServer']
219
367
 
220
368
  /**
@@ -242,6 +390,84 @@ export function apply(ctx) {
242
390
  }
243
391
  }
244
392
 
393
+ /**
394
+ * 余额预警阈值(按 provider / kind)。
395
+ *
396
+ * 设置来源:`dsh-llm-hub` 段下的 `warning` 子对象,形如:
397
+ * dsh-llm-hub:
398
+ * warning:
399
+ * deepseek-official: { cashCNY: 10 }
400
+ * minimax: { planPercent: 10 }
401
+ *
402
+ * 单位说明:
403
+ * - `cashCNY`:CNY 现金余额下限,低于此值红色警告。默认 10 元。
404
+ * - `planPercent`:plan 配额「余量」下限,低于此值红色警告。默认 10%。
405
+ *
406
+ * 阈值不暴露给前端 —— 客户端只渲染「是否低于阈值 + 该充钱了」的二元判断;
407
+ * 数字本身留在 host 半,跨域读取会把它推走不是用户的本意。
408
+ *
409
+ * @returns 阈值表,未配置时返回默认值。
410
+ */
411
+ const warningThresholds = () => {
412
+ const section = sectionOf() ?? {}
413
+ const warning = section.warning !== null && typeof section.warning === 'object' ? section.warning : {}
414
+ const readNumber = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
415
+ return {
416
+ cashCNY: readNumber(warning.cashCNY, 10),
417
+ planPercent: readNumber(warning.planPercent, 10)
418
+ }
419
+ }
420
+
421
+ /**
422
+ * 按 provider id + 余额信封判断「是否低于阈值、显示什么文案」。
423
+ *
424
+ * cash 类(DeepSeek 官方直连 / moonshot / stepfun 等):
425
+ * primary currency 是 CNY 时按 cashCNY 比,金额作为数字;
426
+ * 非 CNY 时按 USD 1 元近似换算(保守提示)。
427
+ * plan 类(minimax / zhipu 配额):任一 item 的 percent < planPercent 即触发。
428
+ *
429
+ * 没匹配到 kind / 缺数字 → 返回 null(不显示警告)。
430
+ * @param provider - 段内键名。
431
+ * @param balance - 余额信封(PiAiCard 走的形态)。
432
+ * @param total - DeepSeek 官方直连 primary.total 字符串。
433
+ * @param currency - DeepSeek 官方直连 primary.currency。
434
+ * @returns 警告文本或 null。
435
+ */
436
+ const warningFor = (provider, balance, total, currency) => {
437
+ const thresholds = warningThresholds()
438
+ if (balance !== undefined && balance !== null) {
439
+ if (balance.kind === 'cash' && Array.isArray(balance.items) && balance.items.length > 0) {
440
+ const first = balance.items[0]
441
+ const amountStr = typeof first?.amount === 'string' ? first.amount : ''
442
+ const amount = Number(amountStr)
443
+ if (Number.isFinite(amount) && amount <= thresholds.cashCNY) {
444
+ return { level: 'low', text: `${provider} 余额 ${amountStr} 低于 ${thresholds.cashCNY},充值一下` }
445
+ }
446
+ }
447
+ if ((balance.kind === 'plan' || balance.kind === 'quota') && Array.isArray(balance.items)) {
448
+ for (const item of balance.items) {
449
+ if (typeof item?.percent !== 'number') continue
450
+ if (item.percent < thresholds.planPercent) {
451
+ return { level: 'low', text: `${provider} ${item.label ?? '配额'} 余 ${item.percent}% 低于 ${thresholds.planPercent}%` }
452
+ }
453
+ }
454
+ }
455
+ }
456
+ if (typeof total === 'string' && currency === 'CNY') {
457
+ const value = Number(total)
458
+ if (Number.isFinite(value) && value <= thresholds.cashCNY) {
459
+ return { level: 'low', text: `DeepSeek 余额 ¥${total} 低于 ${thresholds.cashCNY},充值一下` }
460
+ }
461
+ }
462
+ if (typeof total === 'string' && currency === 'USD') {
463
+ const value = Number(total)
464
+ if (Number.isFinite(value) && value <= 1) {
465
+ return { level: 'low', text: `DeepSeek 余额 $${total} 已不足 1 美元,充值一下` }
466
+ }
467
+ }
468
+ return null
469
+ }
470
+
245
471
  /**
246
472
  * 巡检 settings 段里 `models: []` 的 provider,去对应网关拉一份目录写回。
247
473
  * 启动时跑一次,避免 settings.yaml 里留 `models: []` 这个事实空头;下次 reload / 重启
@@ -279,9 +505,60 @@ export function apply(ctx) {
279
505
  return updates
280
506
  }
281
507
 
508
+ /**
509
+ * 读 `settings.dsh-llm-hub.keyPool[provider]` 段。
510
+ *
511
+ * 每条 entry 是 `{ name, env }` —— name 是人话(debug 用),env 是
512
+ * 凭据服务引用的环境变量名;后者用 `process.env[ref]` 直接读,**不**走
513
+ * credentials 服务,因为这些 key 多半不会进凭据库(轮换场景通常是
514
+ * 测试 / 临时 key,手贴环境变量更简单)。
515
+ *
516
+ * @returns 规范化后的 entry 数组(缺 name 或 env 的过滤掉);没配或非数组返 `undefined`。
517
+ */
518
+ const keyPoolOf = (provider) => {
519
+ const section = sectionOf() ?? {}
520
+ const raw = section.keyPool
521
+ if (raw === null || typeof raw !== 'object') return undefined
522
+ const entries = raw[provider]
523
+ if (!Array.isArray(entries)) return undefined
524
+ const out = []
525
+ for (const entry of entries) {
526
+ if (entry === null || typeof entry !== 'object') continue
527
+ const name = typeof entry.name === 'string' && entry.name.trim().length > 0 ? entry.name.trim() : undefined
528
+ const env = typeof entry.env === 'string' && entry.env.trim().length > 0 ? entry.env.trim() : undefined
529
+ if (env === undefined) continue
530
+ out.push({ name: name ?? env, env })
531
+ }
532
+ return out.length > 0 ? out : undefined
533
+ }
534
+
535
+ /**
536
+ * 给一个 provider 取轮换到的下一个 entry + 索引推进 +1。
537
+ * @returns `{ entry, index }` —— entry 是 { name, env };index 是 0-based 位置。
538
+ */
539
+ const rotateKeyPool = (provider) => {
540
+ const entries = keyPoolOf(provider)
541
+ if (entries === undefined) return undefined
542
+ const current = keyPoolIndex.current.get(provider) ?? 0
543
+ const index = current % entries.length
544
+ keyPoolIndex.current.set(provider, (current + 1) % entries.length)
545
+ const entry = entries[index]
546
+ keyPoolLastChosen.current.set(provider, { entry, index, at: Date.now() })
547
+ return { entry, index }
548
+ }
549
+
282
550
  /**
283
551
  * 解析本次调用要用的连接事实。
284
- * @param request - 可选的一次性覆盖(发现草稿会带 baseURL / apiKey)。
552
+ *
553
+ * 解析顺序(向后兼容):
554
+ * 1) request.apiKey 一次性覆盖 —— 跳过轮换、原样返回(不污染索引)
555
+ * 2) keyPool[provider] 命中 → round-robin 取下一个,env var 解析密钥
556
+ * 3) 旧的 `apiKeyEnv` 单 key 路径(行为完全不变)
557
+ *
558
+ * 返 ref 不变 —— 客户端拿到的是凭据引用(keyPool 的 env 名),实际值是
559
+ * `process.env[ref]` 解出来的字符串;下游诊断(探针 / 余额路由)能继续
560
+ * 看到当前用的是哪个 env。
561
+ * @param request - 可选的一次性覆盖(发现草稿会带 baseURL / apiKey / provider)。
285
562
  * @returns baseURL、凭据引用与可用密钥。
286
563
  */
287
564
  const connectionFacts = async (request = {}) => {
@@ -292,15 +569,26 @@ export function apply(ctx) {
292
569
  process.env.DEEPSEEK_BASE_URL,
293
570
  DEFAULT_BASE_URL
294
571
  ) ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
295
- const ref = label(request.apiKeyEnv, section.apiKeyEnv) ?? DEFAULT_API_KEY_ENV
296
572
  const explicit = label(request.apiKey)
297
- let apiKey = explicit
298
- if (apiKey === undefined) {
299
- const credentials = ctx.get('credentials')
300
- if (credentials !== undefined) {
301
- const hit = await credentials.resolve(ref)
302
- apiKey = label(hit?.value)
303
- }
573
+ if (explicit !== undefined) {
574
+ // 一次性覆盖:保留旧 ref 行为(client.js 的 request.apiKeyEnv 兜底)。
575
+ const ref = label(request.apiKeyEnv, section.apiKeyEnv) ?? DEFAULT_API_KEY_ENV
576
+ return { baseURL, ref, apiKey: explicit }
577
+ }
578
+ const providerKey = label(request.provider) ?? DEEPSEEK_PROVIDER
579
+ const pool = rotateKeyPool(providerKey)
580
+ if (pool !== undefined) {
581
+ const apiKey = label(process.env[pool.entry.env])
582
+ if (apiKey !== undefined) return { baseURL, ref: pool.entry.env, apiKey }
583
+ // 配了 keyPool 但 env var 没值 —— 退回旧单 key 路径,不让 keyPool
584
+ // 静默吞掉请求。
585
+ }
586
+ const ref = label(request.apiKeyEnv, section.apiKeyEnv) ?? DEFAULT_API_KEY_ENV
587
+ let apiKey
588
+ const credentials = ctx.get('credentials')
589
+ if (credentials !== undefined) {
590
+ const hit = await credentials.resolve(ref)
591
+ apiKey = label(hit?.value)
304
592
  }
305
593
  if (apiKey === undefined) apiKey = label(process.env[ref])
306
594
  return { baseURL, ref, apiKey }
@@ -946,36 +1234,38 @@ export function apply(ctx) {
946
1234
  return { state: 'unavailable', code: 'CREDENTIAL_MISSING', source: 'credential', reason: `没配 API key(${ref} 解析不到),请求会直接失败` }
947
1235
  }
948
1236
  const url = `${baseURL}/models`
1237
+ const startedAt = Date.now()
949
1238
  let response
950
1239
  try {
951
1240
  response = await fetch(url, { method: 'GET', headers: { accept: 'application/json', authorization: `Bearer ${apiKey}` }, signal: timeoutSignal() })
952
1241
  } catch (error) {
953
- return { state: 'unknown', code: 'UNREACHABLE', source: 'probe', reason: `探测不到 ${baseURL}:${describeError(error)}` }
1242
+ return { state: 'unknown', code: 'UNREACHABLE', source: 'probe', reason: `探测不到 ${baseURL}:${describeError(error)}`, latencyMs: Date.now() - startedAt }
954
1243
  }
1244
+ const latencyMs = Date.now() - startedAt
955
1245
  if (response.status === 401 || response.status === 403) {
956
- return { state: 'unavailable', code: 'AUTH_REJECTED', source: 'probe', reason: `API key 被拒(HTTP ${response.status})` }
1246
+ return { state: 'unavailable', code: 'AUTH_REJECTED', source: 'probe', status: response.status, reason: `API key 被拒(HTTP ${response.status})`, latencyMs }
957
1247
  }
958
1248
  if (response.status === 402) {
959
- return { state: 'unavailable', code: 'PAYMENT_REQUIRED', source: 'probe', reason: '账户欠费(HTTP 402)' }
1249
+ return { state: 'unavailable', code: 'PAYMENT_REQUIRED', source: 'probe', status: response.status, reason: '账户欠费(HTTP 402)', latencyMs }
960
1250
  }
961
1251
  if (!response.ok && response.status !== 404) {
962
- return { state: 'unknown', code: 'HTTP_ERROR', source: 'probe', reason: `${url} 返回 HTTP ${response.status}` }
1252
+ return { state: 'unknown', code: 'HTTP_ERROR', source: 'probe', status: response.status, reason: `${url} 返回 HTTP ${response.status}`, latencyMs }
963
1253
  }
964
1254
  // 余额:官方直连有公开的 /user/balance,is_available=false 就是「调不了」。
965
1255
  // 查不通不构成隐藏理由 —— 那只说明余额接口这次没通。
966
1256
  try {
967
1257
  const body = shapeBalance(await getJson('/user/balance'))
968
1258
  if (body.isAvailable !== true) {
969
- return { state: 'unavailable', code: 'ACCOUNT_UNAVAILABLE', source: 'balance', reason: '账户不可用(/user/balance 报 is_available=false)' }
1259
+ return { state: 'unavailable', code: 'ACCOUNT_UNAVAILABLE', source: 'balance', reason: '账户不可用(/user/balance 报 is_available=false)', latencyMs }
970
1260
  }
971
1261
  const total = Number(body.balances[0]?.total ?? NaN)
972
1262
  if (Number.isFinite(total) && total <= 0) {
973
- return { state: 'unavailable', code: 'BALANCE_EMPTY', source: 'balance', reason: '账户余额为 0' }
1263
+ return { state: 'unavailable', code: 'BALANCE_EMPTY', source: 'balance', reason: '账户余额为 0', latencyMs }
974
1264
  }
975
1265
  } catch {
976
1266
  // 静默:余额查不通 ≠ 模型不可用
977
1267
  }
978
- return { state: 'available', code: 'OK', source: 'probe', reason: '' }
1268
+ return { state: 'available', code: 'OK', source: 'probe', reason: '', latencyMs }
979
1269
  }
980
1270
 
981
1271
  /**
@@ -1034,22 +1324,26 @@ export function apply(ctx) {
1034
1324
  return { state: 'unavailable', code: 'CREDENTIAL_MISSING', source: 'credential', reason: `apiKeyEnv 指向的 ${ref} 解析不到,请求会直接失败` }
1035
1325
  }
1036
1326
  const listing = await piaiListing(profile, {}, provider)
1327
+ const latencyMs = typeof listing.latencyMs === 'number' ? listing.latencyMs : undefined
1037
1328
  if (!listing.ok && listing.code === 'HTTP_ERROR') {
1038
1329
  if (listing.status === 401 || listing.status === 403) {
1039
- return { state: 'unavailable', code: 'AUTH_REJECTED', source: 'probe', reason: `网关拒绝了这个 key(HTTP ${listing.status})` }
1330
+ return { state: 'unavailable', code: 'AUTH_REJECTED', source: 'probe', status: listing.status, reason: `网关拒绝了这个 key(HTTP ${listing.status})`, latencyMs }
1040
1331
  }
1041
1332
  if (listing.status === 402) {
1042
- return { state: 'unavailable', code: 'PAYMENT_REQUIRED', source: 'probe', reason: '网关要求付费(HTTP 402)' }
1333
+ return { state: 'unavailable', code: 'PAYMENT_REQUIRED', source: 'probe', status: listing.status, reason: '网关要求付费(HTTP 402)', latencyMs }
1043
1334
  }
1044
- return { state: 'unknown', code: 'HTTP_ERROR', source: 'probe', reason: `网关返回 HTTP ${listing.status}` }
1335
+ return { state: 'unknown', code: 'HTTP_ERROR', source: 'probe', status: listing.status, reason: `网关返回 HTTP ${listing.status}`, latencyMs }
1045
1336
  }
1046
1337
  const exhausted = await exhaustedFromBalance(provider)
1047
- if (exhausted !== undefined) return exhausted
1338
+ if (exhausted !== undefined) {
1339
+ if (latencyMs !== undefined) exhausted.latencyMs = latencyMs
1340
+ return exhausted
1341
+ }
1048
1342
  if (!listing.ok) {
1049
1343
  // 404 / 连不上 / 协议不支持目录端点 —— 都不足以断言模型不可用。
1050
- return { state: 'unknown', code: listing.code ?? 'PROBE_UNKNOWN', source: 'probe', reason: listing.error ?? '网关探测无结论' }
1344
+ return { state: 'unknown', code: listing.code ?? 'PROBE_UNKNOWN', source: 'probe', reason: listing.error ?? '网关探测无结论', latencyMs }
1051
1345
  }
1052
- return { state: 'available', code: 'OK', source: 'probe', reason: '' }
1346
+ return { state: 'available', code: 'OK', source: 'probe', reason: '', latencyMs }
1053
1347
  }
1054
1348
 
1055
1349
  /**
@@ -1241,10 +1535,40 @@ export function apply(ctx) {
1241
1535
 
1242
1536
  ctx.on('llm/stream', (options, next) => {
1243
1537
  const provider = typeof options?.provider === 'string' ? options.provider : undefined
1538
+ const modelId = typeof options?.model === 'string' ? options.model : ''
1244
1539
  const inner = next()
1245
1540
  if (provider === undefined) return inner
1541
+ // 用量聚合:usage chunk 可能在 finish 之前或同时到达。
1542
+ // 累积(而非只留最后一次)—— 部分 provider 会先发 cached tokens 的 usage、
1543
+ // 再发最终的 output tokens usage,两次叠加才是这轮的真实成本。
1544
+ let usageAccum = null
1545
+ const onFinish = (finished) => {
1546
+ try {
1547
+ recordUsage({
1548
+ provider,
1549
+ model: modelId,
1550
+ at: Date.now(),
1551
+ usage: usageAccum ?? { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0 },
1552
+ finished
1553
+ })
1554
+ } catch {
1555
+ // 记不上就算了,不能因为记账把流弄坏
1556
+ }
1557
+ }
1246
1558
  return (async function* observeProviderOutcome() {
1247
1559
  for await (const chunk of inner) {
1560
+ if (chunk?.type === 'usage') {
1561
+ const normalized = normalizeUsage(chunk.usage)
1562
+ usageAccum = usageAccum === null
1563
+ ? normalized
1564
+ : {
1565
+ inputTokens: usageAccum.inputTokens + normalized.inputTokens,
1566
+ outputTokens: usageAccum.outputTokens + normalized.outputTokens,
1567
+ cacheReadTokens: usageAccum.cacheReadTokens + normalized.cacheReadTokens,
1568
+ cacheWriteTokens: usageAccum.cacheWriteTokens + normalized.cacheWriteTokens,
1569
+ totalTokens: Math.max(usageAccum.totalTokens, normalized.totalTokens)
1570
+ }
1571
+ }
1248
1572
  if (chunk?.type === 'finish') {
1249
1573
  try {
1250
1574
  const kind = chunk.reason?.kind
@@ -1252,9 +1576,11 @@ export function apply(ctx) {
1252
1576
  if (kind === 'error' || kind === 'aborted') {
1253
1577
  const classified = classifyFailure(chunk.reason?.failure)
1254
1578
  if (classified !== undefined) runtimeMarks.set(provider, { ...classified, at: Date.now() })
1579
+ onFinish(false)
1255
1580
  } else {
1256
1581
  successAt.set(provider, Date.now())
1257
1582
  runtimeMarks.delete(provider)
1583
+ onFinish(true)
1258
1584
  }
1259
1585
  announceHiddenChange(before)
1260
1586
  } catch {
@@ -1369,6 +1695,35 @@ export function apply(ctx) {
1369
1695
  }
1370
1696
  }), 'dsh-llm-hub: availability route')
1371
1697
 
1698
+ // 健康看板:复用 availability 数据,附加 latencyMs / status 字段。
1699
+ // 触发重探是异步的、不阻塞响应 —— 用户点重探看到 loading,点完了切回稳态。
1700
+ const healthPayload = () => {
1701
+ const payload = availabilityPayload()
1702
+ ensureAvailabilityFresh()
1703
+ return payload
1704
+ }
1705
+ ctx.effect(() => ctx.webServer.register({
1706
+ kind: 'exact',
1707
+ path: HEALTH_PATH,
1708
+ handler: (request, response) => {
1709
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
1710
+ response.writeHead(405, { allow: 'GET, HEAD' })
1711
+ response.end()
1712
+ return
1713
+ }
1714
+ if (!sameOrigin(request)) {
1715
+ sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
1716
+ return
1717
+ }
1718
+ if (request.method === 'HEAD') {
1719
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
1720
+ response.end()
1721
+ return
1722
+ }
1723
+ sendJson(response, 200, healthPayload())
1724
+ }
1725
+ }), 'dsh-llm-hub: health route')
1726
+
1372
1727
  ctx.effect(() => ctx.webServer.register({
1373
1728
  kind: 'exact',
1374
1729
  path: AVAILABILITY_RECHECK_PATH,
@@ -1430,20 +1785,627 @@ export function apply(ctx) {
1430
1785
  })()
1431
1786
  })
1432
1787
 
1433
- // ── harness 子代理 ────────────────────────────────────────────────────────
1788
+ // ── 用量统计 ────────────────────────────────────────────────────────────
1434
1789
  //
1435
- // 把本机装好的外部 agent CLI(codex / claude / agy)注册成子代理提供方,详见
1436
- // `./harness.js`。
1790
+ // 每次真实模型调用都记一条 per-call 记录(provider / model / tokens / 时间),
1791
+ // 通过 storageDomain 落到本机 SQLite。client 半按月聚合展示。
1437
1792
  //
1438
- // 为什么用**局部注入**而不是加进模块顶部的 `inject` 数组:`inject` 是硬依赖,
1439
- // 少一个服务整个插件就不加载 —— 那会让「宿主没装子代理栈」的部署连余额和模型
1440
- // 发现一起失去。局部注入只让 harness 这一段跟着服务走,缺了就不启用。
1441
- ctx.inject(['subagents', 'subprocess'], (scope) => {
1442
- applyHarness(scope).catch((error) => {
1443
- scope.logger?.warn?.(`dsh-llm-hub: harness 探测失败:${error?.message ?? error}`)
1444
- })
1793
+ // 与余额/可用性**不**共享失败路径:storageDomain 缺席或损坏只关掉用量统计,
1794
+ // 其余功能照常工作 —— 因此走局部注入,不写进模块顶部的 inject 数组。
1795
+ //
1796
+ // 故意**不**算钱:开源 DSH 的 llm 服务只暴露 imageRequestPricing,没 chat 文本
1797
+ // 定价接口。每个 provider 写硬编码价目表既过时又快塌;把 token 用量留给用户自己
1798
+ // 对照官方价目表,CSV 导出(`GET /api/dsh-llm-hub/usage/export`)已经做了。
1799
+ const usageTable = { current: null }
1800
+ const usageReady = { current: false }
1801
+ // 多账号 key 轮换:每个 provider 一个 round-robin 索引。重启后归零,跨 session
1802
+ // 不持久化 —— 失败的 key 不会自动跳过,下次自然轮换过去(看 debug 路由查清)。
1803
+ const keyPoolIndex = { current: new Map() }
1804
+ const keyPoolLastChosen = { current: new Map() }
1805
+ const recordUsage = (entry) => {
1806
+ const table = usageTable.current
1807
+ if (table === null) return
1808
+ recordUsageInto(table, entry)
1809
+ }
1810
+
1811
+ ctx.inject(['storageDomain'], async (scope) => {
1812
+ let disposed = false
1813
+ let handle = null
1814
+ try {
1815
+ const sd = await import('@deepseek-ai/dsh-storage-domain')
1816
+ const defineDomain = sd.defineDomain
1817
+ const domainTable = sd.domainTable
1818
+ if (typeof defineDomain !== 'function' || typeof domainTable !== 'function') {
1819
+ throw new Error('dsh-storage-domain exports missing')
1820
+ }
1821
+ // v1:首次发布。schema 字段已稳定(参见顶部的 UsageRecordSchema)。
1822
+ // 上游 schema 升级要走 version bump,不在记录里堆可空字段。
1823
+ const UsageDomain = defineDomain({
1824
+ name: 'dsh_llm_hub_usage',
1825
+ version: 1,
1826
+ tables: {
1827
+ records: domainTable({
1828
+ id: 'string',
1829
+ provider: 'string',
1830
+ model: 'string',
1831
+ at: 'number',
1832
+ inputTokens: 'number',
1833
+ outputTokens: 'number',
1834
+ cacheReadTokens: 'number',
1835
+ cacheWriteTokens: 'number',
1836
+ totalTokens: 'number',
1837
+ finished: 'boolean'
1838
+ })
1839
+ }
1840
+ })
1841
+ const domain = await scope.storageDomain.open(UsageDomain)
1842
+ if (disposed) {
1843
+ domain.close()
1844
+ return
1845
+ }
1846
+ handle = domain
1847
+ usageTable.current = domain.table('records')
1848
+ usageReady.current = true
1849
+ ctx.logger.info('dsh-llm-hub: 用量统计已就绪(按月聚合见 /api/dsh-llm-hub/usage/summary)')
1850
+ } catch (error) {
1851
+ ctx.logger.warn(`dsh-llm-hub: 用量统计未启用:${describeError(error)}`)
1852
+ }
1853
+ // 卸载时关 domain(参照 dsh-user-mirror 的处理:句柄漏了会在 turn/end 炸
1854
+ // "Cannot read properties of undefined (reading 'prepare')")。
1855
+ return () => {
1856
+ disposed = true
1857
+ usageReady.current = false
1858
+ usageTable.current = null
1859
+ if (handle !== null) {
1860
+ try { handle.close() } catch { /* 关失败即进程退出 */ }
1861
+ handle = null
1862
+ }
1863
+ }
1445
1864
  })
1446
1865
 
1866
+ /**
1867
+ * 给一个时间戳算「本地日历月」的起点 —— 用户看的是"本月",不是 UTC 月。
1868
+ * 跨年/跨月自动滚到下个月的第一天零点。
1869
+ * @param timestamp - 任意 ms 时刻。
1870
+ * @returns 同月零点的 ms 时刻。
1871
+ */
1872
+ const monthStartOf = (timestamp) => {
1873
+ const d = new Date(timestamp)
1874
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime()
1875
+ }
1876
+
1877
+ /**
1878
+ * 把全部记录按月聚合,返回「本月 + 历史月份」两组数据。
1879
+ * 客户端默认渲染本月;要更长的历史用 export 路由。
1880
+ * @returns 月度聚合结果。
1881
+ */
1882
+ const aggregateUsage = () => {
1883
+ const table = usageTable.current
1884
+ if (table === null) return { ready: false, current: null, history: [] }
1885
+ const now = Date.now()
1886
+ const currentMonth = monthStartOf(now)
1887
+ const byMonth = new Map()
1888
+ try {
1889
+ // storageDomain 的 table API:list() 拿全部记录。
1890
+ // 实际签名以 dsh-storage-domain 的 .d.ts 为准 —— 这一行写错了整个聚合就空。
1891
+ const records = typeof table.list === 'function' ? table.list() : []
1892
+ for (const record of records) {
1893
+ if (record === null || typeof record !== 'object') continue
1894
+ const at = typeof record.at === 'number' ? record.at : 0
1895
+ const monthStart = monthStartOf(at)
1896
+ const bucket = byMonth.get(monthStart) ?? {
1897
+ monthStart,
1898
+ calls: 0,
1899
+ inputTokens: 0,
1900
+ outputTokens: 0,
1901
+ cacheReadTokens: 0,
1902
+ cacheWriteTokens: 0,
1903
+ totalTokens: 0,
1904
+ byModel: {}
1905
+ }
1906
+ accumulateInto(bucket, record)
1907
+ byMonth.set(monthStart, bucket)
1908
+ }
1909
+ } catch {
1910
+ // 聚合失败不算账
1911
+ }
1912
+ const months = [...byMonth.values()].sort((left, right) => right.monthStart - left.monthStart)
1913
+ const current = months.find((bucket) => bucket.monthStart === currentMonth) ?? { monthStart: currentMonth, calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0, byModel: {} }
1914
+ const history = months.filter((bucket) => bucket.monthStart !== currentMonth).slice(0, 12)
1915
+ return {
1916
+ ready: true,
1917
+ current: shapeMonth(current),
1918
+ history: history.map(shapeMonth)
1919
+ }
1920
+ }
1921
+
1922
+ /**
1923
+ * 月份桶 → 给客户端的序列化形态:按 model 分组排个序(calls 降序),只保留前 5。
1924
+ * @param bucket - 累计的原始桶。
1925
+ * @returns 序列化后的月份。
1926
+ */
1927
+ const shapeMonth = (bucket) => {
1928
+ const byModel = Object.values(bucket.byModel)
1929
+ .sort((left, right) => right.calls - left.calls)
1930
+ .slice(0, 5)
1931
+ return {
1932
+ monthStart: bucket.monthStart,
1933
+ calls: bucket.calls,
1934
+ inputTokens: bucket.inputTokens,
1935
+ outputTokens: bucket.outputTokens,
1936
+ cacheReadTokens: bucket.cacheReadTokens,
1937
+ cacheWriteTokens: bucket.cacheWriteTokens,
1938
+ totalTokens: bucket.totalTokens,
1939
+ topModels: byModel
1940
+ }
1941
+ }
1942
+
1943
+ const accumulateInto = (bucket, record) => {
1944
+ bucket.calls += 1
1945
+ bucket.inputTokens += typeof record.inputTokens === 'number' ? record.inputTokens : 0
1946
+ bucket.outputTokens += typeof record.outputTokens === 'number' ? record.outputTokens : 0
1947
+ bucket.cacheReadTokens += typeof record.cacheReadTokens === 'number' ? record.cacheReadTokens : 0
1948
+ bucket.cacheWriteTokens += typeof record.cacheWriteTokens === 'number' ? record.cacheWriteTokens : 0
1949
+ bucket.totalTokens += typeof record.totalTokens === 'number' ? record.totalTokens : 0
1950
+ const key = `${record.provider}/${record.model}`
1951
+ const slot = bucket.byModel[key] ?? { provider: record.provider, model: record.model, calls: 0, inputTokens: 0, outputTokens: 0 }
1952
+ slot.calls += 1
1953
+ slot.inputTokens += typeof record.inputTokens === 'number' ? record.inputTokens : 0
1954
+ slot.outputTokens += typeof record.outputTokens === 'number' ? record.outputTokens : 0
1955
+ bucket.byModel[key] = slot
1956
+ }
1957
+
1958
+ ctx.effect(() => ctx.webServer.register({
1959
+ kind: 'exact',
1960
+ path: USAGE_SUMMARY_PATH,
1961
+ handler: (request, response) => {
1962
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
1963
+ response.writeHead(405, { allow: 'GET, HEAD' })
1964
+ response.end()
1965
+ return
1966
+ }
1967
+ if (!sameOrigin(request)) {
1968
+ sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
1969
+ return
1970
+ }
1971
+ if (request.method === 'HEAD') {
1972
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
1973
+ response.end()
1974
+ return
1975
+ }
1976
+ sendJson(response, 200, aggregateUsage())
1977
+ }
1978
+ }), 'dsh-llm-hub: usage summary route')
1979
+
1980
+ /**
1981
+ * 把所有记录导出成 CSV —— 不算钱:留给用户对照官方价目表自己定价。
1982
+ *
1983
+ * 字段顺序固定,方便 grep / awk;列名与 summary 路由对齐。
1984
+ */
1985
+ ctx.effect(() => ctx.webServer.register({
1986
+ kind: 'exact',
1987
+ path: USAGE_EXPORT_PATH,
1988
+ handler: (request, response) => {
1989
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
1990
+ response.writeHead(405, { allow: 'GET, HEAD' })
1991
+ response.end()
1992
+ return
1993
+ }
1994
+ if (!sameOrigin(request)) {
1995
+ response.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
1996
+ response.end(JSON.stringify({ ok: false, error: 'cross-origin reads are refused' }))
1997
+ return
1998
+ }
1999
+ const table = usageTable.current
2000
+ if (table === null) {
2001
+ response.writeHead(503, { 'content-type': 'application/json; charset=utf-8' })
2002
+ response.end(JSON.stringify({ ok: false, error: '用量统计未启用' }))
2003
+ return
2004
+ }
2005
+ if (request.method === 'HEAD') {
2006
+ response.writeHead(200, { 'content-type': 'text/csv; charset=utf-8' })
2007
+ response.end()
2008
+ return
2009
+ }
2010
+ let records = []
2011
+ try {
2012
+ records = typeof table.list === 'function' ? table.list() : []
2013
+ } catch {
2014
+ records = []
2015
+ }
2016
+ const header = 'id,provider,model,at,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,total_tokens,finished\n'
2017
+ const escape = (value) => {
2018
+ const text = String(value ?? '')
2019
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
2020
+ }
2021
+ const lines = records.map((record) => [
2022
+ escape(record.id),
2023
+ escape(record.provider),
2024
+ escape(record.model),
2025
+ record.at ?? 0,
2026
+ record.inputTokens ?? 0,
2027
+ record.outputTokens ?? 0,
2028
+ record.cacheReadTokens ?? 0,
2029
+ record.cacheWriteTokens ?? 0,
2030
+ record.totalTokens ?? 0,
2031
+ record.finished === true ? 'true' : 'false'
2032
+ ].join(','))
2033
+ response.writeHead(200, {
2034
+ 'content-type': 'text/csv; charset=utf-8',
2035
+ 'content-disposition': `attachment; filename="dsh-llm-hub-usage-${new Date().toISOString().slice(0, 10)}.csv"`,
2036
+ 'cache-control': 'no-store'
2037
+ })
2038
+ response.end(header + lines.join('\n') + (lines.length > 0 ? '\n' : ''))
2039
+ }
2040
+ }), 'dsh-llm-hub: usage export route')
2041
+
2042
+ /**
2043
+ * 清空用量记录 —— 显式 POST,避免被预取/前进后退误触发。
2044
+ */
2045
+ ctx.effect(() => ctx.webServer.register({
2046
+ kind: 'exact',
2047
+ path: USAGE_CLEAR_PATH,
2048
+ handler: (request, response) => {
2049
+ if (request.method !== 'POST') {
2050
+ response.writeHead(405, { allow: 'POST' })
2051
+ response.end()
2052
+ return
2053
+ }
2054
+ if (!sameOrigin(request)) {
2055
+ response.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
2056
+ response.end(JSON.stringify({ ok: false, error: 'cross-origin writes are refused' }))
2057
+ return
2058
+ }
2059
+ const table = usageTable.current
2060
+ if (table === null) {
2061
+ response.writeHead(503, { 'content-type': 'application/json; charset=utf-8' })
2062
+ response.end(JSON.stringify({ ok: false, error: '用量统计未启用' }))
2063
+ return
2064
+ }
2065
+ let removed = 0
2066
+ try {
2067
+ const records = typeof table.list === 'function' ? table.list() : []
2068
+ for (const record of records) {
2069
+ if (record?.id === undefined) continue
2070
+ table.delete(record.id)
2071
+ removed += 1
2072
+ }
2073
+ } catch (error) {
2074
+ response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
2075
+ response.end(JSON.stringify({ ok: false, error: describeError(error) }))
2076
+ return
2077
+ }
2078
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
2079
+ response.end(JSON.stringify({ ok: true, removed }))
2080
+ }
2081
+ }), 'dsh-llm-hub: usage clear route')
2082
+
2083
+ /**
2084
+ * 余额预警判定 —— 客户端把当前余额信封 POST 进来,host 半返回
2085
+ * 「是否低于阈值 + 人话提示」。
2086
+ *
2087
+ * 请求体形态(任一字段缺失都按 null 处理):
2088
+ * {
2089
+ * provider: 'deepseek-official' | 'minimax' | 'moonshot' | ...,
2090
+ * // 任选一种:
2091
+ * deepseek: { total: '8.50', currency: 'CNY' }, // 官方直连 cash
2092
+ * envelope: { ok: true, kind: 'cash'|'plan'|'quota', items: [...] } // pi-ai
2093
+ * }
2094
+ *
2095
+ * 响应:`{ ok, level: 'low'|null, text: '...', thresholds: { cashCNY, planPercent } }`。
2096
+ * 阈值不在响应里:客户端不需要数字本身,只判定是否警告。
2097
+ */
2098
+ ctx.effect(() => ctx.webServer.register({
2099
+ kind: 'exact',
2100
+ path: WARNING_CHECK_PATH,
2101
+ handler: async (request, response) => {
2102
+ if (request.method !== 'POST') {
2103
+ response.writeHead(405, { allow: 'POST' })
2104
+ response.end()
2105
+ return
2106
+ }
2107
+ if (!sameOrigin(request)) {
2108
+ sendJson(response, 403, { ok: false, error: 'cross-origin writes are refused' })
2109
+ return
2110
+ }
2111
+ let payload
2112
+ try {
2113
+ const collected = []
2114
+ for await (const chunk of request) collected.push(chunk)
2115
+ payload = JSON.parse(Buffer.concat(collected).toString('utf8'))
2116
+ } catch {
2117
+ sendJson(response, 400, { ok: false, error: '请求内容不是合法 JSON' })
2118
+ return
2119
+ }
2120
+ const provider = typeof payload?.provider === 'string' ? payload.provider : ''
2121
+ const deepseekTotal = typeof payload?.deepseek?.total === 'string' ? payload.deepseek.total : ''
2122
+ const deepseekCurrency = typeof payload?.deepseek?.currency === 'string' ? payload.deepseek.currency : ''
2123
+ const envelope = payload?.envelope
2124
+ const warn = warningFor(provider, envelope, deepseekTotal, deepseekCurrency)
2125
+ sendJson(response, 200, {
2126
+ ok: true,
2127
+ level: warn?.level ?? null,
2128
+ text: warn?.text ?? null
2129
+ })
2130
+ }
2131
+ }), 'dsh-llm-hub: warning check route')
2132
+
2133
+ // ── 智能路由建议 ─────────────────────────────────────────────────────────
2134
+ //
2135
+ // 用户在 settings 里声明 `routing.primary` + `routing.fallbacks[]`,host 半按
2136
+ // 当前可用性判定,给出「该用哪个」。**只建议,不替用户点** —— DSH 的
2137
+ // `conversation.input.model` 是 single + user-controlled slot,没有供插件
2138
+ // 改值的 setter;强行覆盖会撞 README 红线(`replaceRisk: shadows-shipped-ui`)。
2139
+ //
2140
+ // 设计取舍:
2141
+ // - 配置形态:「provider/model」字符串数组,与 pi-ai 段内 provider 键名 + 已配
2142
+ // model id 对齐;不正则解析,避免「用户写 'minimax'」在 settings 改后无法识别。
2143
+ // - 判定顺序:primary → fallbacks[];任一 available 的就是 recommendation。
2144
+ // - 全不可用:返 null + reason「全部不可用」,让 UI 显示而不要 fallback 兜底。
2145
+ // - 主动查询当前用户已选的(POST 进来),把它的状态塞进 active 字段 —— UI 用
2146
+ // 这个渲染「你现在用的 X 不可用了,点这里切到 Y」的引导。
2147
+ const ROUTE_KEY_RE = /^[a-z0-9][a-z0-9_-]*$/i
2148
+ // model id 放宽一点:DSH 真实 id 会含 '.'(claude-3.5-sonnet、deepseek-reasoner 之类)。
2149
+ // 还是禁掉 '/' '/' 与空白(避免再被切回去当路由),其他字符都放行。
2150
+ const ROUTE_MODEL_RE = /^[a-z0-9][a-z0-9_.+-]*$/i
2151
+
2152
+ /**
2153
+ * 解析「provider/model」字符串。
2154
+ * @param entry - 配置里的一行。
2155
+ * @returns 解析后的 { provider, model },无效时为 `undefined`。
2156
+ */
2157
+ const parseRouteEntry = (entry) => {
2158
+ if (typeof entry !== 'string') return undefined
2159
+ const slash = entry.indexOf('/')
2160
+ if (slash <= 0 || slash === entry.length - 1) return undefined
2161
+ const provider = entry.slice(0, slash).trim()
2162
+ const model = entry.slice(slash + 1).trim()
2163
+ // provider 严格(要拿去找 settings 段查),model 放宽(DSH 真实 id 可能含 .)
2164
+ if (!ROUTE_KEY_RE.test(provider) || !ROUTE_MODEL_RE.test(model)) return undefined
2165
+ return { provider, model }
2166
+ }
2167
+
2168
+ /**
2169
+ * 读 `routing.primary` + `routing.fallbacks[]`。
2170
+ * @returns 配置好的顺序数组(primary 在前),无效项过滤掉。空数组表示未配置。
2171
+ */
2172
+ const routingCandidatesOf = () => {
2173
+ const section = sectionOf() ?? {}
2174
+ const routing = section[ROUTING_KEY] !== null && typeof section[ROUTING_KEY] === 'object' ? section[ROUTING_KEY] : {}
2175
+ const out = []
2176
+ const primary = parseRouteEntry(routing.primary)
2177
+ if (primary !== undefined) out.push(primary)
2178
+ const fallbacks = Array.isArray(routing.fallbacks) ? routing.fallbacks : []
2179
+ for (const fallback of fallbacks) {
2180
+ const parsed = parseRouteEntry(fallback)
2181
+ if (parsed !== undefined) out.push(parsed)
2182
+ }
2183
+ return out
2184
+ }
2185
+
2186
+ /**
2187
+ * 给一个 (provider, model) 找它在当前可用性表里的判定。
2188
+ * @param provider - provider id。
2189
+ * @param model - model id。
2190
+ * @returns 该组合的 state(unknown / unavailable / available),未探测到为 'unknown'。
2191
+ */
2192
+ const stateFor = (provider, model) => {
2193
+ // 直接去 baseVerdicts 拿 —— availability payload 是这个的视图。
2194
+ for (const verdict of baseVerdicts.values()) {
2195
+ if (verdict.provider === provider) return verdict.state
2196
+ }
2197
+ return 'unknown'
2198
+ }
2199
+
2200
+ /**
2201
+ * 路由解析:给客户端返回「该用哪个 + 原因」。
2202
+ * @param active - 客户端 POST 进来的当前已激活 (provider, model)。
2203
+ * @returns 路由建议载荷。
2204
+ */
2205
+ const resolveRoute = (active) => {
2206
+ const candidates = routingCandidatesOf()
2207
+ const verdicts = new Map()
2208
+ for (const candidate of candidates) {
2209
+ verdicts.set(`${candidate.provider}/${candidate.model}`, stateFor(candidate.provider, candidate.model))
2210
+ }
2211
+ // active 可能是 undefined / null / 字符串 —— undefined 与 null 都视为「没激活」。
2212
+ const activeParsed = active === undefined || active === null ? null : parseRouteEntry(active)
2213
+ const activeState = activeParsed === null
2214
+ ? null
2215
+ : verdicts.get(`${activeParsed.provider}/${activeParsed.model}`) ?? stateFor(activeParsed.provider, activeParsed.model)
2216
+ const recommendation = candidates.find((candidate) => verdicts.get(`${candidate.provider}/${candidate.model}`) === 'available')
2217
+ const reason = candidates.length === 0
2218
+ ? '尚未在 settings.dsh-llm-hub.routing 配置主力模型 / fallbacks'
2219
+ : (recommendation === undefined ? '主力与 fallbacks 全部不可用 —— 充值或换一个再说' : null)
2220
+ return {
2221
+ ok: true,
2222
+ active: activeParsed === null
2223
+ ? null
2224
+ : { provider: activeParsed.provider, model: activeParsed.model, state: activeState },
2225
+ candidates: candidates.map((candidate) => ({
2226
+ provider: candidate.provider,
2227
+ model: candidate.model,
2228
+ state: verdicts.get(`${candidate.provider}/${candidate.model}`) ?? 'unknown'
2229
+ })),
2230
+ recommendation: recommendation === undefined ? null : { provider: recommendation.provider, model: recommendation.model },
2231
+ reason
2232
+ }
2233
+ }
2234
+
2235
+ ctx.effect(() => ctx.webServer.register({
2236
+ kind: 'exact',
2237
+ path: ROUTING_RESOLVE_PATH,
2238
+ handler: async (request, response) => {
2239
+ if (request.method !== 'POST') {
2240
+ response.writeHead(405, { allow: 'POST' })
2241
+ response.end()
2242
+ return
2243
+ }
2244
+ if (!sameOrigin(request)) {
2245
+ sendJson(response, 403, { ok: false, error: 'cross-origin writes are refused' })
2246
+ return
2247
+ }
2248
+ let payload
2249
+ try {
2250
+ const collected = []
2251
+ for await (const chunk of request) collected.push(chunk)
2252
+ payload = JSON.parse(Buffer.concat(collected).toString('utf8'))
2253
+ } catch {
2254
+ sendJson(response, 400, { ok: false, error: '请求内容不是合法 JSON' })
2255
+ return
2256
+ }
2257
+ sendJson(response, 200, resolveRoute(payload?.active))
2258
+ }
2259
+ }), 'dsh-llm-hub: routing resolve route')
2260
+
2261
+ ctx.effect(() => ctx.webServer.register({
2262
+ kind: 'exact',
2263
+ path: ROUTING_RESOLVE_PATH,
2264
+ handler: async (request, response) => {
2265
+ if (request.method !== 'POST') {
2266
+ response.writeHead(405, { allow: 'POST' })
2267
+ response.end()
2268
+ return
2269
+ }
2270
+ if (!sameOrigin(request)) {
2271
+ sendJson(response, 403, { ok: false, error: 'cross-origin writes are refused' })
2272
+ return
2273
+ }
2274
+ let payload
2275
+ try {
2276
+ const collected = []
2277
+ for await (const chunk of request) collected.push(chunk)
2278
+ payload = JSON.parse(Buffer.concat(collected).toString('utf8'))
2279
+ } catch {
2280
+ sendJson(response, 400, { ok: false, error: '请求内容不是合法 JSON' })
2281
+ return
2282
+ }
2283
+ sendJson(response, 200, resolveRoute(payload?.active))
2284
+ }
2285
+ }), 'dsh-llm-hub: routing resolve route')
2286
+
2287
+ // ── 模型别名 ──────────────────────────────────────────────────────────────
2288
+ //
2289
+ // settings.dsh-llm-hub.aliases:把长 model id 映射成短显示名,典型场景:
2290
+ // aliases:
2291
+ // 'deepseek-official/deepseek-reasoner': '推理'
2292
+ // 'modelgo/claude-3.5-sonnet': 'Sonnet'
2293
+ //
2294
+ // 设计取舍:
2295
+ // - **只渲染短名 + 留完整 id 在 title**:别名只是「人话」,不能让 client 把 id
2296
+ // 也丢了 —— 用户复制 model id 粘到配置文件里的时候不能是「推理」。
2297
+ // - **不绑死 provider**:key 是 `<provider>/<model>`,缺斜杠或 provider 含
2298
+ // '/' 的不合法;保证 key 是 settings 段内的稳定引用。
2299
+ // - **空值过滤**:值是空字符串或纯空白时视为未配置;不让「短名 = 」这种
2300
+ // 占位塞进 UI。
2301
+ // - **路由是 GET**:纯读,不需要 POST;每次拉目录时连同读。
2302
+ const ALIAS_KEY_RE = /^[a-z0-9][a-z0-9_-]*\/[a-z0-9][a-z0-9_.+-]*$/i
2303
+
2304
+ /**
2305
+ * 读 `aliases` 段为「键 → 短名」map。
2306
+ * @returns 规范化后的别名表(空值 / 非法 key 全过滤掉)。
2307
+ */
2308
+ const aliasesOf = () => {
2309
+ const section = sectionOf() ?? {}
2310
+ const raw = section.aliases !== null && typeof section.aliases === 'object' ? section.aliases : {}
2311
+ const out = {}
2312
+ for (const [key, value] of Object.entries(raw)) {
2313
+ if (!ALIAS_KEY_RE.test(key)) continue
2314
+ if (typeof value !== 'string') continue
2315
+ const trimmed = value.trim()
2316
+ if (trimmed.length === 0) continue
2317
+ out[key] = trimmed
2318
+ }
2319
+ return out
2320
+ }
2321
+
2322
+ /**
2323
+ * 给一个 (provider, model) 找短名(无别名时返 null)。
2324
+ * @param provider - provider id。
2325
+ * @param model - model id。
2326
+ * @returns 别名或 null。
2327
+ */
2328
+ const aliasFor = (provider, model) => {
2329
+ if (typeof provider !== 'string' || typeof model !== 'string') return null
2330
+ const key = `${provider}/${model}`
2331
+ const aliases = aliasesOf()
2332
+ return aliases[key] ?? null
2333
+ }
2334
+
2335
+ ctx.effect(() => ctx.webServer.register({
2336
+ kind: 'exact',
2337
+ path: ALIASES_PATH,
2338
+ handler: (request, response) => {
2339
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
2340
+ response.writeHead(405, { allow: 'GET, HEAD' })
2341
+ response.end()
2342
+ return
2343
+ }
2344
+ if (!sameOrigin(request)) {
2345
+ sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
2346
+ return
2347
+ }
2348
+ if (request.method === 'HEAD') {
2349
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
2350
+ response.end()
2351
+ return
2352
+ }
2353
+ sendJson(response, 200, { ok: true, aliases: aliasesOf() })
2354
+ }
2355
+ }), 'dsh-llm-hub: aliases route')
2356
+
2357
+ /**
2358
+ * KeyPool 调试路由 —— 让 client 看到每个 provider 当前轮到了哪个 entry。
2359
+ * 不暴露 apiKey 实际值(只有 name + env 名);401 / 403 类失败时用户从
2360
+ * 这里看「轮到哪个 env 上次失败」,下次请求轮换过去。
2361
+ */
2362
+ const keyPoolDebugPayload = () => {
2363
+ const section = sectionOf() ?? {}
2364
+ const raw = section.keyPool
2365
+ const pool = raw !== null && typeof raw === 'object' ? raw : {}
2366
+ const providers = []
2367
+ for (const [provider, entries] of Object.entries(pool)) {
2368
+ if (!Array.isArray(entries)) continue
2369
+ const lastChosen = keyPoolLastChosen.current.get(provider)
2370
+ providers.push({
2371
+ provider,
2372
+ total: entries.length,
2373
+ lastChosen: lastChosen === undefined ? null : {
2374
+ name: lastChosen.entry.name,
2375
+ env: lastChosen.entry.env,
2376
+ index: lastChosen.index,
2377
+ at: lastChosen.at
2378
+ },
2379
+ nextIndex: keyPoolIndex.current.get(provider) ?? 0
2380
+ })
2381
+ }
2382
+ return { ok: true, providers }
2383
+ }
2384
+
2385
+ ctx.effect(() => ctx.webServer.register({
2386
+ kind: 'exact',
2387
+ path: KEYPOOL_STATUS_PATH,
2388
+ handler: (request, response) => {
2389
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
2390
+ response.writeHead(405, { allow: 'GET, HEAD' })
2391
+ response.end()
2392
+ return
2393
+ }
2394
+ if (!sameOrigin(request)) {
2395
+ sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
2396
+ return
2397
+ }
2398
+ if (request.method === 'HEAD') {
2399
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
2400
+ response.end()
2401
+ return
2402
+ }
2403
+ sendJson(response, 200, keyPoolDebugPayload())
2404
+ }
2405
+ }), 'dsh-llm-hub: keypool status route')
2406
+
2407
+ // ── harness 子代理 ────────────────────────────────────────────────────────
2408
+
1447
2409
  // 自检路由:`GET /api/dsh-llm-hub/harness` 报告探到了什么、注册了几个。
1448
2410
  //
1449
2411
  // 为什么需要它:宿主的 `info` 级日志**不落盘**(实测 launchd.log 里只有