@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/CHANGELOG.md +115 -0
- package/README.md +185 -4
- package/lib/client.js +627 -7
- package/lib/index.js +1000 -38
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
202
|
+
/** 可用性缓存有效期。太长则换了 key 还在报旧状态,太短则每次开设置页都打一圈网关。 */
|
|
203
|
+
export const AVAILABILITY_TTL_MS = 5 * 60 * 1000
|
|
63
204
|
/** 一轮探测最长允许在途的时间;超过就允许再开一轮(超时兜底的兜底)。 */
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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)
|
|
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
|
-
// ──
|
|
1788
|
+
// ── 用量统计 ────────────────────────────────────────────────────────────
|
|
1434
1789
|
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1790
|
+
// 每次真实模型调用都记一条 per-call 记录(provider / model / tokens / 时间),
|
|
1791
|
+
// 通过 storageDomain 落到本机 SQLite。client 半按月聚合展示。
|
|
1437
1792
|
//
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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 里只有
|