dsh-decision-engine 0.0.0-stage → 0.4.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.
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Provider 配置模型 —— 所有决策请求的唯一构造入口。
3
+ *
4
+ * 为什么要单独一层:
5
+ *
6
+ * 早期版本把 `https://api.bamboonode.dev/v1/decisions` 硬编码在请求路径、
7
+ * 配置页默认值和探测脚本三处。这带来两个问题:
8
+ *
9
+ * 1. 想接 TypeSafe 的 `/v1/systemone`、自建 llama.cpp 决策服务、
10
+ * 或者任何 system-one 风格网关时,只能改源码。
11
+ * 2. 三处硬编码会各自漂移:改了一处,配置页仍显示旧地址,用户以为没生效。
12
+ *
13
+ * 现在所有 provider 差异(base URL、端点风格、环境变量名、默认模型)都收在
14
+ * 本模块的预设表里,`index.js` / `client.js` / 探测脚本只消费解析结果。
15
+ * 本模块**零依赖、零 I/O**:不读文件、不读环境变量、不发请求,因此可以
16
+ * 在裸 Node 下直接单测(见 test/providers.test.js)。
17
+ *
18
+ * system-one 风格 API 的约定(本模块据此固定请求形状,不随 provider 变化):
19
+ *
20
+ * POST <base>/decisions 或 POST <base>/systemone
21
+ * headers: authorization: Bearer <key> / content-type: application/json
22
+ * body: { model?, state, questions, think_mode }
23
+ * resp: { answers, model?, usage? }
24
+ *
25
+ * 也就是说 provider 只决定「往哪发、用谁的 key、默认哪个模型」,
26
+ * 请求体与响应结构对所有 provider 完全一致——这样换 provider 不会改变
27
+ * 上层各决策点的解析逻辑。
28
+ *
29
+ * @module dsh-decision-engine/providers
30
+ */
31
+
32
+ /**
33
+ * 可选的 provider 标识。
34
+ *
35
+ * `systemone` / `bamboonode` 是内置预设(默认地址与参数由本模块提供);
36
+ * `custom` 使用用户在设置界面填写的 base URL 与鉴权信息。
37
+ */
38
+ export const PROVIDER_IDS = ["systemone", "bamboonode", "custom"];
39
+
40
+ /** 默认 provider。刻意选 bamboonode:老用户的 `startlux.yml` 里没有 provider 键,
41
+ * 必须继续走原来的地址与端点风格,升级不能改变既有行为。 */
42
+ export const DEFAULT_PROVIDER = "bamboonode";
43
+
44
+ /**
45
+ * 自定义 base URL 的端点风格。
46
+ *
47
+ * auto 已指向 /decisions 或 /systemone 就原样用,否则按 decisions 补
48
+ * decisions POST <base>/decisions
49
+ * systemone POST <base>/systemone
50
+ */
51
+ export const ENDPOINT_STYLES = ["auto", "decisions", "systemone"];
52
+
53
+ /** 端点风格 → 追加的路径段。 */
54
+ const STYLE_SEGMENT = { decisions: "decisions", systemone: "systemone" };
55
+
56
+ /**
57
+ * 内置预设。
58
+ *
59
+ * 每个预设必须给出:显示名称、默认 base URL、端点风格、环境变量名、
60
+ * 默认模型提示。用户选了预设就用这里的值,不需要在设置界面重复填写。
61
+ */
62
+ export const PROVIDER_PRESETS = {
63
+ systemone: {
64
+ id: "systemone",
65
+ label: "system one",
66
+ labelEn: "system one",
67
+ baseUrl: "https://api.typesafe.ai/v1",
68
+ endpointStyle: "systemone",
69
+ // 每个 provider 用**自己的** key 名:同一台机器上很可能同时配好了
70
+ // TypeSafe 与 bamboonode,共用一格会互相覆盖,切换时还得重填。
71
+ apiKeyEnv: "TYPESAFE_API_KEY",
72
+ legacyApiKeyEnvs: [],
73
+ keyField: "systemoneApiKey",
74
+ defaultModel: "jev-latest",
75
+ modelField: "systemoneModel",
76
+ keyEnvField: "systemoneApiKeyEnv",
77
+ hint: "TypeSafe / Jev 的 System One 协议(POST /v1/systemone),"
78
+ + "也是 llama.cpp、vLLM 等自建决策服务通用的线协议",
79
+ hintEn: "TypeSafe / Jev System One protocol (POST /v1/systemone); "
80
+ + "the same wire format used by llama.cpp and vLLM decision servers",
81
+ },
82
+ bamboonode: {
83
+ id: "bamboonode",
84
+ label: "bamboonode",
85
+ labelEn: "bamboonode",
86
+ baseUrl: "https://api.bamboonode.dev/v1",
87
+ endpointStyle: "decisions",
88
+ // bamboonode 保留它一直以来的名字(不是"统一成一个新名"):
89
+ // 一个 key 名对应一个服务,这才是用户能自主管理多套凭据的方式。
90
+ // DECISION_ENGINE_KEY 只作为**回退**:上一版曾短暂改用它,
91
+ // 用户可能已经把 key 存在那里,升级后不该让 key 突然读不到。
92
+ apiKeyEnv: "BAMBOO_API_KEY",
93
+ legacyApiKeyEnvs: ["DECISION_ENGINE_KEY"],
94
+ keyField: "bamboonodeApiKey",
95
+ defaultModel: "startlux-decision-27b-autothink",
96
+ modelField: "model",
97
+ keyEnvField: "bamboonodeApiKeyEnv",
98
+ hint: "StartLux 官方端点(POST /v1/decisions),本插件的原有默认值",
99
+ hintEn: "StartLux official endpoint (POST /v1/decisions); the original default",
100
+ },
101
+ custom: {
102
+ id: "custom",
103
+ label: "custom",
104
+ labelEn: "custom",
105
+ baseUrl: "",
106
+ endpointStyle: "auto",
107
+ // custom 的变量名由用户填(customApiKeyEnv);这里给出兜底默认名,
108
+ // 用户不填时也能在 .credentials.yaml 里用这个键存 key。
109
+ apiKeyEnv: "DECISION_ENGINE_CUSTOM_KEY",
110
+ legacyApiKeyEnvs: [],
111
+ keyField: "customApiKey",
112
+ defaultModel: "",
113
+ modelField: "customModel",
114
+ keyEnvField: "customApiKeyEnv",
115
+ hint: "任意 system-one 风格服务:自建 llama.cpp / vLLM、企业网关、第三方中转",
116
+ hintEn: "Any system-one style service: self-hosted llama.cpp / vLLM, a gateway, or a relay",
117
+ },
118
+ };
119
+
120
+ /**
121
+ * 去掉首尾空白与结尾斜杠,统一成可安全拼接的 base URL。
122
+ *
123
+ * 只做规范化,不做合法性判断——判断交给 `validateBaseUrl`,因为两者
124
+ * 的失败语义不同:规范化永远返回字符串(可能仍非法),校验才决定能不能存。
125
+ *
126
+ * @param raw - 用户填写的地址。
127
+ * @returns 去掉结尾斜杠的地址;输入为空时返回 ""。
128
+ */
129
+ export function normalizeBaseUrl(raw) {
130
+ const text = typeof raw === "string" ? raw.trim() : "";
131
+ if (text.length === 0) return "";
132
+ // 多个结尾斜杠一并去掉,避免拼出 `//decisions` 这种空段路径。
133
+ return text.replace(/\/+$/, "");
134
+ }
135
+
136
+ /**
137
+ * 校验 base URL 是否可用。
138
+ *
139
+ * 规则刻意严格,因为「地址写错」是本插件最难自查的故障:请求会打到一个
140
+ * 不存在的路径,服务端多半回 404,而降级是静默的——用户只看到「守卫没反应」。
141
+ *
142
+ * @param raw - 用户填写的地址。
143
+ * @returns `{ ok: true, value }` 或 `{ ok: false, error }`(error 为可直接展示的中文)。
144
+ */
145
+ export function validateBaseUrl(raw) {
146
+ const text = typeof raw === "string" ? raw.trim() : "";
147
+ if (text.length === 0) return { ok: false, error: "base URL 不能为空" };
148
+ if (/\s/.test(text)) return { ok: false, error: "base URL 不能包含空格或换行" };
149
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) {
150
+ return { ok: false, error: "base URL 必须以 http:// 或 https:// 开头" };
151
+ }
152
+ let url;
153
+ try {
154
+ url = new URL(text);
155
+ } catch {
156
+ return { ok: false, error: "base URL 不是合法地址(示例:https://api.example.com/v1)" };
157
+ }
158
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
159
+ return { ok: false, error: `base URL 只支持 http/https,当前是 ${url.protocol}` };
160
+ }
161
+ if (url.hostname.length === 0) return { ok: false, error: "base URL 缺少主机名" };
162
+ // 只允许「地址 + 路径」,带查询串会让端点拼接结果不可预测。
163
+ if (url.search.length > 0) return { ok: false, error: "base URL 不要带查询串(? 之后的部分)" };
164
+ return { ok: true, value: normalizeBaseUrl(text) };
165
+ }
166
+
167
+ /**
168
+ * 判断某个路径是否已经是完整决策端点。
169
+ *
170
+ * @param path - URL 的 pathname(可含结尾斜杠)。
171
+ * @returns "decisions" / "systemone" / undefined。
172
+ */
173
+ export function endpointStyleOfPath(path) {
174
+ const tail = String(path ?? "")
175
+ .replace(/\/+$/, "")
176
+ .split("/")
177
+ .filter((part) => part.length > 0)
178
+ .pop();
179
+ if (tail === "decisions" || tail === "systemone") return tail;
180
+ return undefined;
181
+ }
182
+
183
+ /**
184
+ * 把 base URL 拼成可直接 POST 的完整端点。
185
+ *
186
+ * 这是本插件的核心可用性修复点:旧实现把配置原样 POST,用户按各家 SDK 的
187
+ * 习惯填 `.../v1` 就必然 404(`Invalid URL (POST /v1)`)。现在的规则:
188
+ *
189
+ * - 已经以 `/decisions` 或 `/systemone` 结尾 → 原样使用(用户填了完整端点)
190
+ * - 只有 origin(没有路径)→ 补 `/v1/<段>`(对齐各家的 `/v1/...` 约定)
191
+ * - 其它路径(`/v1`、`/api/v1`、`/openai/v1`)→ 追加 `/<段>`,不重复、不丢段
192
+ *
193
+ * 结尾斜杠在拼接前统一去掉,因此 `.../v1/` 与 `.../v1` 得到同一结果。
194
+ *
195
+ * @param baseUrl - base URL(可含结尾斜杠,可缺 scheme 的旧配置由调用方补齐)。
196
+ * @param style - `decisions` / `systemone` / `auto`。
197
+ * @returns 完整端点;输入为空时返回 ""。
198
+ */
199
+ export function buildEndpoint(baseUrl, style = "decisions") {
200
+ const normalized = normalizeBaseUrl(baseUrl);
201
+ if (normalized.length === 0) return "";
202
+ let url;
203
+ try {
204
+ url = new URL(normalized);
205
+ } catch {
206
+ // 不是合法 URL:原样返回,让调用方拿到明确的 fetch 失败而不是猜。
207
+ return normalized;
208
+ }
209
+ const path = url.pathname.replace(/\/+$/, "");
210
+ // 1) 已经指向决策端点:原样使用,不再叠加。
211
+ const existing = endpointStyleOfPath(path);
212
+ if (existing !== undefined) {
213
+ url.pathname = path;
214
+ return url.toString();
215
+ }
216
+ // auto 在没有线索时按 decisions 处理(与历史行为一致)。
217
+ const segment = STYLE_SEGMENT[style === "auto" ? "decisions" : style] ?? "decisions";
218
+ // 2) 只有 origin:补 /v1/<段>,这是各家的公共约定。
219
+ if (path.length === 0) {
220
+ url.pathname = `/v1/${segment}`;
221
+ return url.toString();
222
+ }
223
+ // 3) 已有路径(/v1、/api/v1 ...):只追加最后一段,避免 /v1/v1/decisions。
224
+ url.pathname = `${path}/${segment}`;
225
+ return url.toString();
226
+ }
227
+
228
+ /**
229
+ * 解析 `customHeaders` 配置。
230
+ *
231
+ * 存储形态是 `Name: Value; Name2: Value2` 的单行字符串——本插件的配置读取器
232
+ * 是极简 YAML 子集(只认标量与 `[a, b]` 数组),嵌套映射读不出来;用分号
233
+ * 分隔既能在 `startlux.yml` 里手写,也能被面板原样回显。
234
+ *
235
+ * 受保护的头(`authorization` / `content-type` / `host`)会被忽略:鉴权方式
236
+ * 必须与 system-one 风格保持一致,否则换 provider 就可能悄悄换掉认证协议。
237
+ *
238
+ * @param raw - 字符串或字符串数组。
239
+ * @returns 可直接并入 fetch headers 的对象(可能为空对象)。
240
+ */
241
+ export function parseCustomHeaders(raw) {
242
+ /** @type {Record<string, string>} */
243
+ const out = {};
244
+ const entries = Array.isArray(raw) ? raw.map((item) => String(item ?? "")) : String(raw ?? "").split(/[;\n]/);
245
+ for (const entry of entries) {
246
+ const line = entry.trim();
247
+ if (line.length === 0) continue;
248
+ const sep = line.indexOf(":");
249
+ if (sep <= 0) continue;
250
+ const name = line.slice(0, sep).trim();
251
+ const value = line.slice(sep + 1).trim();
252
+ if (name.length === 0 || value.length === 0) continue;
253
+ // 头名只接受 RFC 7230 的 token 字符,避免把说明文字当成头。
254
+ if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name)) continue;
255
+ const lower = name.toLowerCase();
256
+ if (lower === "authorization" || lower === "content-type" || lower === "host") continue;
257
+ out[name] = value;
258
+ }
259
+ return out;
260
+ }
261
+
262
+ /**
263
+ * 解析某个 provider 此刻生效的完整配置。
264
+ *
265
+ * 这是全插件唯一的「provider → 请求参数」解析点。`classify()`、探测、
266
+ * 模型列表、状态接口都调它,因此三处不可能再各写一套地址规则。
267
+ *
268
+ * 解析顺序(内置预设):预设默认值打底,`startlux.yml` 里显式写的值覆盖。
269
+ * 对 bamboonode 额外保留 `baseUrl` 的覆盖语义——那是老版本唯一的地址字段,
270
+ * 用户可能已经指向自建的 bamboonode 兼容服务,升级不能把它改回官方地址。
271
+ *
272
+ * @param cfg - 已合并的配置对象(可能缺字段)。
273
+ * @returns 生效配置;`valid=false` 时 `error` 是可展示的中文原因。
274
+ */
275
+ export function resolveProvider(cfg = {}) {
276
+ const requested = typeof cfg.provider === "string" ? cfg.provider.trim() : "";
277
+ const id = PROVIDER_IDS.includes(requested) ? requested : DEFAULT_PROVIDER;
278
+ const preset = PROVIDER_PRESETS[id];
279
+
280
+ if (id === "custom") {
281
+ const rawBase = typeof cfg.customBaseUrl === "string" ? cfg.customBaseUrl : "";
282
+ const checked = validateBaseUrl(rawBase);
283
+ const style = ENDPOINT_STYLES.includes(cfg.customEndpointStyle) ? cfg.customEndpointStyle : "auto";
284
+ const baseUrl = checked.ok ? checked.value : normalizeBaseUrl(rawBase);
285
+ const model = typeof cfg.customModel === "string" ? cfg.customModel.trim() : "";
286
+ return {
287
+ id,
288
+ label: preset.label,
289
+ preset,
290
+ source: "custom",
291
+ baseUrl,
292
+ endpoint: checked.ok ? buildEndpoint(baseUrl, style) : "",
293
+ endpointStyle: style,
294
+ model,
295
+ defaultModel: "",
296
+ thinkMode: cfg.thinkMode,
297
+ apiKeyEnv: firstNonEmpty(cfg.customApiKeyEnv, preset.apiKeyEnv),
298
+ legacyApiKeyEnvs: [...(preset.legacyApiKeyEnvs ?? [])],
299
+ explicitKey: typeof cfg[preset.keyField] === "string" ? cfg[preset.keyField].trim() : "",
300
+ headers: parseCustomHeaders(cfg.customHeaders),
301
+ valid: checked.ok,
302
+ error: checked.ok ? undefined : `自定义 provider 的 base URL 不可用:${checked.error}`,
303
+ };
304
+ }
305
+
306
+ // 内置预设:预设默认打底,配置覆盖。
307
+ const presetBase = preset.baseUrl;
308
+ const legacyBase =
309
+ id === "bamboonode" && typeof cfg.baseUrl === "string" ? normalizeBaseUrl(cfg.baseUrl) : "";
310
+ let baseUrl = legacyBase.length > 0 ? legacyBase : presetBase;
311
+ // 老配置允许省略 scheme(`api.x.dev/v1`),预设值自带 scheme,这里只补缺。
312
+ if (baseUrl.length > 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(baseUrl)) baseUrl = `https://${baseUrl}`;
313
+ const checked = validateBaseUrl(baseUrl);
314
+ // 模型按 provider 分开读:`model` 属于 bamboonode(老版本唯一的模型字段),
315
+ // `systemoneModel` 属于 system one。共用一格会把 bamboonode 的模型名发给
316
+ // TypeSafe,得到 400 `model not served`——「切 provider 就坏」的典型来源。
317
+ const modelField = preset.modelField;
318
+ const model = typeof cfg[modelField] === "string" ? cfg[modelField].trim() : "";
319
+ // 密钥变量名:`apiKeyEnv` 是**历史字段**,只对 bamboonode 有覆盖意义
320
+ //(老版本只有这一个 provider,用户可能已经在里面写了自己的变量名)。
321
+ // 若让它参与 system one 的解析,会盖掉 TYPESAFE_API_KEY——切到 system one
322
+ // 却拿 bamboonode 的 key 去请求,得到 401 而用户完全看不出为什么。
323
+ //
324
+ // 历史字段里若写的是「旧默认名」或「旧环境变量」,不算用户自定义,
325
+ // 由 `normalizeKeyEnvOverride` 过滤掉——否则改名永远不生效。
326
+ const legacyKeyEnv = id === "bamboonode" ? normalizeKeyEnvOverride(cfg.apiKeyEnv, preset) : undefined;
327
+ // 新名优先,旧名仅作回退。旧名不能出现在 apiKeyEnv 里
328
+ //(那是「当前生效变量」的展示来源)。
329
+ const configuredKeyEnv = firstNonEmpty(cfg[preset.keyEnvField], legacyKeyEnv, preset.apiKeyEnv);
330
+ const fallbackKeyEnvs = [...(preset.legacyApiKeyEnvs ?? [])].filter(
331
+ (name) => name !== configuredKeyEnv,
332
+ );
333
+ return {
334
+ id,
335
+ label: preset.label,
336
+ preset,
337
+ source: legacyBase.length > 0 ? "legacy" : "preset",
338
+ baseUrl: checked.ok ? checked.value : baseUrl,
339
+ endpoint: checked.ok ? buildEndpoint(baseUrl, preset.endpointStyle) : "",
340
+ endpointStyle: preset.endpointStyle,
341
+ model,
342
+ defaultModel: preset.defaultModel,
343
+ thinkMode: cfg.thinkMode,
344
+ apiKeyEnv: configuredKeyEnv,
345
+ legacyApiKeyEnvs: fallbackKeyEnvs,
346
+ // 显式 key 也按 provider 分开存:切到 system one 时不会拿 bamboonode 的
347
+ // 明文 key 去请求。历史共享字段 `apiKey` 仅对 bamboonode 做一次兼容读取
348
+ //(老版本的 yml 里只有这一个 key 字段)。
349
+ explicitKey: firstNonEmpty(
350
+ cfg[preset.keyField],
351
+ id === "bamboonode" ? cfg.apiKey : undefined,
352
+ ),
353
+ headers: {},
354
+ valid: checked.ok,
355
+ error: checked.ok ? undefined : `provider ${id} 的 base URL 不可用:${checked.error}`,
356
+ };
357
+ }
358
+
359
+ /**
360
+ * 按 provider 解析生效的 API key。
361
+ *
362
+ * 优先级与旧版本一致,只是「显式 key / 环境变量名」现在由 provider 决定:
363
+ *
364
+ * 1. 该 provider 的显式 key(`apiKey` 或 `customApiKey`)
365
+ * 2. `~/.dsh/.credentials.yaml` 的 `refs:` 段(推荐,与其他服务统一托管)
366
+ * 3. 环境变量
367
+ *
368
+ * 凭据读取由调用方注入,因此本模块不碰文件系统,可在裸 Node 下单测。
369
+ *
370
+ * @param provider - `resolveProvider()` 的结果。
371
+ * @param options.credentialReader - `(ref) => string | undefined`。
372
+ * @param options.env - 环境变量表,默认 `process.env`。
373
+ * @returns 密钥,或 undefined。
374
+ */
375
+ export function resolveKeyFor(provider, options = {}) {
376
+ if (provider.explicitKey.length > 0) return provider.explicitKey;
377
+ const reader = options.credentialReader;
378
+ const env = options.env ?? process.env;
379
+
380
+ // 变量名按「新名 → 旧名」依次尝试。旧名只做回退:用户把 key 存在
381
+ // BAMBOO_API_KEY 里的旧安装,升级后不必重填就能继续工作。
382
+ const names = [provider.apiKeyEnv, ...(provider.legacyApiKeyEnvs ?? [])].filter(
383
+ (name) => typeof name === "string" && name.length > 0,
384
+ );
385
+ for (const envName of names) {
386
+ if (typeof reader === "function") {
387
+ const fromCredentials = reader(envName);
388
+ if (typeof fromCredentials === "string" && fromCredentials.length > 0) return fromCredentials;
389
+ }
390
+ const fromEnv = env?.[envName];
391
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
392
+ }
393
+ return undefined;
394
+ }
395
+
396
+ /**
397
+ * 构造请求头。
398
+ *
399
+ * `authorization` 与 `content-type` 最后写入,因此自定义头无法覆盖它们——
400
+ * 鉴权方式必须对所有 provider 一致(Bearer),这是响应结构一致性的前提。
401
+ *
402
+ * @param provider - `resolveProvider()` 的结果。
403
+ * @param key - 已解析的 API key。
404
+ * @returns fetch headers。
405
+ */
406
+ export function buildHeaders(provider, key) {
407
+ return {
408
+ ...provider.headers,
409
+ authorization: `Bearer ${key}`,
410
+ "content-type": "application/json",
411
+ };
412
+ }
413
+
414
+ /**
415
+ * 构造请求体(system-one 线协议,对所有 provider 相同)。
416
+ *
417
+ * `model` 为空时**省略字段**而不是发空串:实测端点对 `"model": ""` 回
418
+ * 400 `model must be a nonempty string`,而省略字段会回填服务端默认模型。
419
+ *
420
+ * @param provider - `resolveProvider()` 的结果。
421
+ * @param payload.state - 决策上下文。
422
+ * @param payload.questions - 问题集。
423
+ * @param payload.thinkMode - 覆盖 provider 上的 thinkMode(探测用)。
424
+ * @returns 可直接 JSON.stringify 的对象。
425
+ */
426
+ export function buildRequestBody(provider, payload) {
427
+ const model = (provider.model ?? "").trim();
428
+ return {
429
+ ...(model.length > 0 ? { model } : {}),
430
+ state: payload.state,
431
+ questions: payload.questions,
432
+ think_mode: payload.thinkMode ?? provider.thinkMode ?? "never",
433
+ };
434
+ }
435
+
436
+ /**
437
+ * 供设置界面展示的 provider 清单。
438
+ *
439
+ * 由服务端下发而不是前端硬编码:新增预设只改本文件一处,界面自动多一个选项。
440
+ *
441
+ * @returns 每个预设的 id / 显示名 / 默认地址 / 说明。
442
+ */
443
+ export function providerCatalog() {
444
+ return PROVIDER_IDS.map((id) => {
445
+ const preset = PROVIDER_PRESETS[id];
446
+ return {
447
+ id,
448
+ label: preset.label,
449
+ labelEn: preset.labelEn,
450
+ baseUrl: preset.baseUrl,
451
+ endpointStyle: preset.endpointStyle,
452
+ defaultModel: preset.defaultModel,
453
+ apiKeyEnv: preset.apiKeyEnv,
454
+ hint: preset.hint,
455
+ hintEn: preset.hintEn,
456
+ customizable: id === "custom",
457
+ };
458
+ });
459
+ }
460
+
461
+ /**
462
+ * 判断历史字段 `apiKeyEnv` 的值算不算「用户自定义」。
463
+ *
464
+ * 老版本只有 bamboonode 一个 provider,yam 里写的是旧默认名
465
+ * (`BAMBOO_API_KEY`)或对应的**旧环境变量**。这两种都不算用户自定义——
466
+ * 前者是默认值残留,后者是改名前的变量名——因此一律返回 undefined,
467
+ * 让预设自己的默认名接管。
468
+ *
469
+ * 只有用户填了**别的**变量名(例如 `MY_KEY`)才认为是他主动指定的,予以保留。
470
+ *
471
+ * @param raw - 配置里的 apiKeyEnv。
472
+ * @param preset - 该 provider 的预设。
473
+ * @returns 需要尊重的自定义变量名,或 undefined。
474
+ */
475
+ function normalizeKeyEnvOverride(raw, preset) {
476
+ const name = typeof raw === "string" ? raw.trim() : "";
477
+ if (name.length === 0) return undefined;
478
+ // 等于预设的当前默认名:不是自定义。
479
+ if (name === preset.apiKeyEnv) return undefined;
480
+ // 等于该预设的旧名:是老默认值,不算自定义(改名要能生效)。
481
+ if ((preset.legacyApiKeyEnvs ?? []).includes(name)) return undefined;
482
+ return name;
483
+ }
484
+
485
+ /** 取第一个非空字符串。 */
486
+ function firstNonEmpty(...values) {
487
+ for (const value of values) {
488
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
489
+ }
490
+ return "";
491
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Decision Engine",
4
+ "description": "Four-question risk judgement before every tool call via a decision model, deciding allow, ask or deny. Works with any system-one style API (system one, bamboonode, or a custom base URL). Shadow-first by default, fail-open on any error."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Decision Engine",
4
+ "description": "每次工具调用前用决策模型做四问风险判断,决定放行、询问或拦截;支持 system one / bamboonode / 自定义 base URL,可接任意 system-one 风格服务。默认只观察不拦截,任何异常都自动放行。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,84 @@
1
1
  {
2
2
  "name": "dsh-decision-engine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "description": "dsh-decision-engine: a cheap judgement layer for DeepSeek Harness — all nine decision points (tool authorization, model routing, delegation, compaction, turn continuation, retrieval, skill pick, session title, COT translation) are implemented and individually switchable, and it works against any system-one style API (system one, bamboonode, or a custom base URL).",
5
+ "icon": "./icon.svg",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./client": {
11
+ "default": "./lib/client.js"
12
+ },
13
+ "./providers": "./lib/providers.js",
14
+ "./cordis.patch.yml": "./cordis.patch.yml",
15
+ "./locale/*.json": "./locale/*.json",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "lib",
20
+ "cordis.patch.yml",
21
+ "README.md",
22
+ "startlux.example.yml",
23
+ "icon.svg",
24
+ "locale",
25
+ "scripts/migrate-profile.mjs"
26
+ ],
27
+ "keywords": [
28
+ "dsh-plugin",
29
+ "deepseek-harness",
30
+ "decision-engine",
31
+ "startlux",
32
+ "bamboo",
33
+ "bamboo-relay",
34
+ "decisions",
35
+ "system-one",
36
+ "system-1",
37
+ "typesafe",
38
+ "jev",
39
+ "provider",
40
+ "custom-base-url",
41
+ "tool-approval",
42
+ "risk-gating",
43
+ "model-routing",
44
+ "compaction",
45
+ "session-title",
46
+ "translation-gate"
47
+ ],
48
+ "engines": {
49
+ "node": ">=20.3.0"
50
+ },
51
+ "license": "MIT",
52
+ "author": "zuosus",
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/zuosus/dsh-decision-engine.git"
56
+ },
57
+ "bugs": {
58
+ "url": "https://github.com/zuosus/dsh-decision-engine/issues"
59
+ },
60
+ "homepage": "https://github.com/zuosus/dsh-decision-engine#readme",
61
+ "dsh": {
62
+ "bundle": {
63
+ "patch": "./cordis.patch.yml"
64
+ },
65
+ "client": {
66
+ "platform": "web",
67
+ "inject": [
68
+ "@deepseek-ai/dsh-client-ui-commands",
69
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
70
+ "@deepseek-ai/dsh-client-ui-renderer"
71
+ ]
72
+ }
73
+ },
74
+ "peerDependencies": {
75
+ "@deepseek-ai/cordis": "*",
76
+ "@deepseek-ai/schemastery": "*"
77
+ },
78
+ "scripts": {
79
+ "test": "node --test test/",
80
+ "verify:endpoint": "node scripts/verify-endpoint.mjs",
81
+ "verify:wiring": "node scripts/verify-wiring.mjs",
82
+ "verify": "node --test test/ && node scripts/verify-wiring.mjs"
83
+ }
84
+ }