dsh-decision-engine 0.6.1 → 0.7.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
@@ -28,7 +28,6 @@ import { dirname, join } from "node:path";
28
28
  import z from "@deepseek-ai/schemastery";
29
29
 
30
30
  import { DecisionCache } from "./decision-cache.js";
31
- import { TokenBudget } from "./budget.js";
32
31
  import { DECISION_POINTS, POINT_MODES, readPointModes } from "./decision-points.js";
33
32
  import { createDecisionRunner } from "./engine.js";
34
33
  import { POINT_SPECS } from "./points/spec.js";
@@ -89,6 +88,16 @@ const DEFAULTS = {
89
88
  model: DEFAULT_MODEL,
90
89
  /** system one 的模型字段;留空 = 由服务端选默认(jev-latest)。 */
91
90
  systemoneModel: "",
91
+ /**
92
+ * 硅基流动 / Silicon Flow 的模型字段。
93
+ *
94
+ * 刻意留空、不在这里钉模型名:这两个端点把 `model` 列为必填(实测省略或
95
+ * 空串都回 400 `Model field is required.`),「留空」的语义由
96
+ * `resolveProvider` 解释为「用预设默认 Kev-4B」。填了就以填的为准
97
+ * (想换 SemIf 填 SemIf),两条路都不会发出注定失败的请求。
98
+ */
99
+ siliconflowModel: "",
100
+ siliconflowIntlModel: "",
92
101
  /** 自定义 provider:地址、端点风格、鉴权、请求头、模型。 */
93
102
  customBaseUrl: "",
94
103
  customEndpointStyle: "auto",
@@ -97,11 +106,15 @@ const DEFAULTS = {
97
106
  /** 各 provider 的显式 key(与变量名分开存,互不覆盖)。 */
98
107
  systemoneApiKey: "",
99
108
  bamboonodeApiKey: "",
109
+ siliconflowApiKey: "",
110
+ siliconflowIntlApiKey: "",
100
111
  customModel: "",
101
112
  customHeaders: "",
102
113
  /** 各预设的密钥来源变量名,留空即用预设内置值。 */
103
114
  bamboonodeApiKeyEnv: "",
104
115
  systemoneApiKeyEnv: "",
116
+ siliconflowApiKeyEnv: "",
117
+ siliconflowIntlApiKeyEnv: "",
105
118
  /**
106
119
  * 密钥保存位置:
107
120
  * credentials = ~/.dsh/.credentials.yaml 的 refs 段(默认,与其他服务统一托管)
@@ -187,9 +200,6 @@ const DEFAULTS = {
187
200
  pointSkillPick: "off",
188
201
  pointCotTranslate: "off",
189
202
  // ---- 自身开销护栏 ----------------------------------------------------
190
- // 决策模型每次约 1.9k token;不设上限时,多决策点会把它变成主要消耗方。
191
- budgetTokens: 400000,
192
- budgetWindowMs: 3600000,
193
203
  cacheTtlMs: 600000,
194
204
  };
195
205
 
@@ -209,7 +219,10 @@ export const Config = z.object({
209
219
  provider: z.union([...PROVIDER_IDS]).default(DEFAULTS.provider)
210
220
  .description(
211
221
  "systemone = 任意 system-one 风格服务(TypeSafe / 自建 llama.cpp);"
212
- + "bamboonode = StartLux 官方端点(原有默认);custom = 自己填地址",
222
+ + "bamboonode = StartLux 官方端点(原有默认);"
223
+ + "siliconflow = 硅基流动(api.siliconflow.cn,POST /v1/systemone);"
224
+ + "siliconflow-intl = Silicon Flow 国际站(api.siliconflow.com);"
225
+ + "custom = 自己填地址",
213
226
  ),
214
227
  baseUrl: z.string().default(DEFAULTS.baseUrl)
215
228
  .description("bamboonode 预设的地址覆盖;/v1 与 /v1/decisions 两种写法都接受"),
@@ -217,6 +230,10 @@ export const Config = z.object({
217
230
  .description("bamboonode 预设的模型 id;留空表示由服务端选默认"),
218
231
  systemoneModel: z.string().default(DEFAULTS.systemoneModel)
219
232
  .description("system one 预设的模型 id;留空表示由服务端选默认(jev-latest)"),
233
+ siliconflowModel: z.string().default(DEFAULTS.siliconflowModel)
234
+ .description("硅基流动预设的模型 id;留空用预设默认 Kev-4B(该端点 model 必填,不可省略)"),
235
+ siliconflowIntlModel: z.string().default(DEFAULTS.siliconflowIntlModel)
236
+ .description("Silicon Flow 国际站预设的模型 id;留空用预设默认 Kev-4B(该端点 model 必填)"),
220
237
  customBaseUrl: z.string().default(DEFAULTS.customBaseUrl)
221
238
  .description("provider=custom 时的 base URL,必须是 http/https 地址"),
222
239
  customEndpointStyle: z.union([...ENDPOINT_STYLES]).default(DEFAULTS.customEndpointStyle)
@@ -227,6 +244,10 @@ export const Config = z.object({
227
244
  .description("system one 的 API key;留空则读 TYPESAFE_API_KEY"),
228
245
  bamboonodeApiKey: z.string().role("secret").default(DEFAULTS.bamboonodeApiKey)
229
246
  .description("bamboonode 的 API key;留空则读 BAMBOO_API_KEY"),
247
+ siliconflowApiKey: z.string().role("secret").default(DEFAULTS.siliconflowApiKey)
248
+ .description("硅基流动的 API key;留空则读 SILICONFLOW_API_KEY"),
249
+ siliconflowIntlApiKey: z.string().role("secret").default(DEFAULTS.siliconflowIntlApiKey)
250
+ .description("Silicon Flow 国际站的 API key;留空则读 SILICONFLOW_INTL_API_KEY"),
230
251
  customApiKeyEnv: z.string().default(DEFAULTS.customApiKeyEnv)
231
252
  .description("custom 的密钥来源变量名(环境变量或 .credentials.yaml 的 refs 键)"),
232
253
  customModel: z.string().default(DEFAULTS.customModel)
@@ -237,6 +258,10 @@ export const Config = z.object({
237
258
  .description("bamboonode 预设的密钥变量名覆盖;留空用 BAMBOO_API_KEY"),
238
259
  systemoneApiKeyEnv: z.string().default(DEFAULTS.systemoneApiKeyEnv)
239
260
  .description("systemone 预设的密钥变量名覆盖;留空用 TYPESAFE_API_KEY"),
261
+ siliconflowApiKeyEnv: z.string().default(DEFAULTS.siliconflowApiKeyEnv)
262
+ .description("siliconflow 预设的密钥变量名覆盖;留空用 SILICONFLOW_API_KEY"),
263
+ siliconflowIntlApiKeyEnv: z.string().default(DEFAULTS.siliconflowIntlApiKeyEnv)
264
+ .description("siliconflow-intl 预设的密钥变量名覆盖;留空用 SILICONFLOW_INTL_API_KEY"),
240
265
  apiKey: z.string().role("secret")
241
266
  .description("当前 provider 的 API key;留空则读 apiKeyEnv 指定的环境变量"),
242
267
  apiKeyEnv: z.string().default(DEFAULTS.apiKeyEnv)
@@ -319,9 +344,6 @@ export const Config = z.object({
319
344
  .description(`${point.label}:off 不参与 / observe 只记录 / enforce 生效`),
320
345
  ]),
321
346
  ),
322
- budgetTokens: z.number().step(1).min(0).default(DEFAULTS.budgetTokens)
323
- .description("决策模型自身每窗口的 token 上限;0 表示不限制。超限后自动退回启发式"),
324
- budgetWindowMs: z.number().step(1).min(60000).default(DEFAULTS.budgetWindowMs),
325
347
  cacheTtlMs: z.number().step(1).min(1000).default(DEFAULTS.cacheTtlMs)
326
348
  .description("缓存存活时间;改策略或配置后旧裁决不应继续被复用"),
327
349
  });
@@ -455,7 +477,11 @@ export function validateProviderPatch(patch, current) {
455
477
  }
456
478
 
457
479
  // 变量名必须能当环境变量用,否则凭据永远读不到。
458
- for (const key of ["customApiKeyEnv", "bamboonodeApiKeyEnv", "systemoneApiKeyEnv"]) {
480
+ // 逐个预设取它自己的变量名覆盖字段,而不是写死三个名字——否则新增预设时
481
+ // 漏加这一项,用户填的变量名会被原样写进 yml,然后永远读不到 key
482
+ //(守卫是 fail-open 的,症状又是「配了没反应」)。
483
+ for (const preset of Object.values(PROVIDER_PRESETS)) {
484
+ const key = preset.keyEnvField;
459
485
  if (!Object.hasOwn(patch, key)) continue;
460
486
  const value = typeof patch[key] === "string" ? patch[key].trim() : "";
461
487
  if (value.length === 0) continue;
@@ -936,16 +962,12 @@ class StartluxClient {
936
962
  // 遥测目录由 apply() 注入:它要兼顾「更名前的旧目录里已有历史记录」,
937
963
  // 这个判断需要文件系统探测,不适合放进纯客户端。
938
964
  this.telemetryDir = options.telemetryDir;
939
- // 缓存与预算都传「取值函数」而不是快照:与 endpoint/model/key 同一套
965
+ // 缓存也传「取值函数」而不是快照:与 endpoint/model/key 同一套
940
966
  // 原则——配置改了立刻对下一次生效,不要求重启。
941
967
  this.cache = new DecisionCache(
942
968
  () => this.getConfig().cacheSize,
943
969
  () => this.getConfig().cacheTtlMs,
944
970
  );
945
- this.budget = new TokenBudget(
946
- () => this.getConfig().budgetTokens,
947
- () => this.getConfig().budgetWindowMs,
948
- );
949
971
  this.failures = 0;
950
972
  this.cooldownUntil = 0;
951
973
  this.lastDegrade = undefined;
@@ -1032,19 +1054,15 @@ class StartluxClient {
1032
1054
  provider: provider.id,
1033
1055
  hint:
1034
1056
  provider.apiKeyEnv.length > 0
1035
- ? `设置 apiKey(或 customApiKey)或导出 $${provider.apiKeyEnv}`
1036
- : "为 custom provider 填写 customApiKey,或设置 customApiKeyEnv",
1057
+ // 指名该 provider 自己的字段(siliconflowApiKey / bamboonodeApiKey /
1058
+ // customApiKey …)。写死 apiKey 会让人把 key 填进 bamboonode 的格子,
1059
+ // 而其它 provider 并不读它——遥测里就会出现「填了 key 仍报 no-key」。
1060
+ ? `设置 ${provider.preset?.keyField ?? "apiKey"} 或导出 $${provider.apiKeyEnv}`
1061
+ : `为 ${provider.id} 填写 ${provider.preset?.keyField ?? "自定义 key 字段"},或设置 ${provider.preset?.keyEnvField ?? "密钥变量名"}`,
1037
1062
  });
1038
1063
  }
1039
1064
  if (Date.now() < this.cooldownUntil) return degrade("cooldown");
1040
1065
 
1041
- // 预算闸:决策模型自己也烧 token。超限就退回启发式并 fail-open,
1042
- // 等窗口滚过去自动恢复——绝不因为「省钱的东西」把活卡住。
1043
- if (this.budget.exhausted()) {
1044
- this.budget.noteBlocked();
1045
- return degrade("budget-exhausted", { budget: this.budget.report });
1046
- }
1047
-
1048
1066
  const endpoint = provider.endpoint;
1049
1067
  // 允许留空:不发送 model 字段时,服务端会用它当前的默认决策模型
1050
1068
  //(实测省略 model 返回 200 且 model 回填为实际使用的模型)。
@@ -1065,8 +1083,8 @@ class StartluxClient {
1065
1083
  return { ...hit, cached: true, latencyMs: 0 };
1066
1084
  }
1067
1085
 
1068
- const budget = Math.max(1000, cfg.timeoutMs);
1069
- const timer = AbortSignal.timeout(budget);
1086
+ const timeoutMs = Math.max(1000, cfg.timeoutMs);
1087
+ const timer = AbortSignal.timeout(timeoutMs);
1070
1088
  const signal = call.signal === undefined ? timer : AbortSignal.any([call.signal, timer]);
1071
1089
 
1072
1090
  this.stats.requests += 1;
@@ -1111,7 +1129,7 @@ class StartluxClient {
1111
1129
  if (call.signal?.aborted) return degrade("caller-aborted");
1112
1130
  if (timer.aborted || error?.name === "AbortError") {
1113
1131
  this.noteFailure();
1114
- return degrade("timeout", { budgetMs: budget, endpoint: usedEndpoint });
1132
+ return degrade("timeout", { timeoutMs, endpoint: usedEndpoint });
1115
1133
  }
1116
1134
  this.noteFailure();
1117
1135
  return degrade("network-error", {
@@ -1197,8 +1215,6 @@ class StartluxClient {
1197
1215
  counter.latencyMax = Math.max(counter.latencyMax, result.latencyMs);
1198
1216
  if (typeof inputTokens === "number") {
1199
1217
  counter.tokens += inputTokens;
1200
- // 记进自身预算:这是「省钱插件」自己的花销。
1201
- this.budget.spend(inputTokens);
1202
1218
  }
1203
1219
 
1204
1220
  this.cache.set(exactKey, result);
@@ -1369,14 +1385,18 @@ export function apply(ctx, config) {
1369
1385
  /** 本次要写进 yml 的键值。 */
1370
1386
  const ymlPatch = { ...patch };
1371
1387
 
1372
- // provider → 它的显式 key 字段名与 refs 键名
1373
- const slots = [
1374
- { keyField: "systemoneApiKey", refName: current.systemoneApiKeyEnv || "TYPESAFE_API_KEY" },
1375
- { keyField: "bamboonodeApiKey", refName: current.bamboonodeApiKeyEnv || "BAMBOO_API_KEY" },
1376
- { keyField: "customApiKey", refName: current.customApiKeyEnv || "DECISION_ENGINE_CUSTOM_KEY" },
1377
- // 历史共享字段:老前端只认 apiKey,按 bamboonode 处理
1378
- { keyField: "apiKey", refName: current.bamboonodeApiKeyEnv || "BAMBOO_API_KEY" },
1379
- ];
1388
+ // provider → 它的显式 key 字段名与 refs 键名。
1389
+ // 逐个预设生成而不是写死:漏掉一个 provider,它的 key 就会**绕过凭据文件
1390
+ // 直接以明文写进 config.yml**——那是「托管到凭据文件」这条承诺的破口。
1391
+ const slots = Object.values(PROVIDER_PRESETS).map((preset) => {
1392
+ const override = typeof current[preset.keyEnvField] === "string" ? current[preset.keyEnvField].trim() : "";
1393
+ return { keyField: preset.keyField, refName: override.length > 0 ? override : preset.apiKeyEnv };
1394
+ });
1395
+ // 历史共享字段:老前端只认 apiKey,按 bamboonode 处理
1396
+ slots.push({
1397
+ keyField: "apiKey",
1398
+ refName: current.bamboonodeApiKeyEnv || PROVIDER_PRESETS.bamboonode.apiKeyEnv,
1399
+ });
1380
1400
 
1381
1401
  for (const slot of slots) {
1382
1402
  if (!Object.hasOwn(patch, slot.keyField)) continue;
@@ -1548,7 +1568,7 @@ export function apply(ctx, config) {
1548
1568
  // ---- 其余八个决策点 ----------------------------------------------------
1549
1569
  //
1550
1570
  // 与工具授权不同,这八个点共用一条 execution 链路(engine.js):模式判断、
1551
- // 预算、冷却、答案校验、遥测都由 runner.ask() 统一处理,适配器只负责
1571
+ // 冷却、答案校验、遥测都由 runner.ask() 统一处理,适配器只负责
1552
1572
  // 「给出 state」与「落地副作用」。这样任何一处降级逻辑都不会漏。
1553
1573
  const runner = createDecisionRunner({
1554
1574
  getConfig,
@@ -1641,8 +1661,13 @@ export function apply(ctx, config) {
1641
1661
  ok: false,
1642
1662
  text:
1643
1663
  `未找到 API key(provider: ${provider.id}):\n` +
1644
- `请设置 config.apiKey${provider.id === "custom" ? " / customApiKey" : ""}` +
1645
- (provider.apiKeyEnv.length > 0 ? `,或导出 $${provider.apiKeyEnv}` : ",或设置 customApiKeyEnv"),
1664
+ // 指名该 provider 自己的那一格(bamboonodeApiKey / siliconflowApiKey /
1665
+ // customApiKey …)。写死 apiKey 会让人把 key 填进 bamboonode 的字段,
1666
+ // 而其它 provider 根本不读它——「填了 key 还是说没 key」就是这么来的。
1667
+ `请在 ${CONFIG_FILE_NAME} 写 ${provider.preset?.keyField ?? "显式 key 字段"},` +
1668
+ (provider.apiKeyEnv.length > 0
1669
+ ? `或导出 $${provider.apiKeyEnv}(也可存入 .credentials.yaml 的 refs 段)`
1670
+ : `或设置 ${provider.preset?.keyEnvField ?? "密钥变量名"}`),
1646
1671
  };
1647
1672
  }
1648
1673
  const started = Date.now();
@@ -1670,7 +1695,13 @@ export function apply(ctx, config) {
1670
1695
  );
1671
1696
  const response = result.response;
1672
1697
  const elapsed = Date.now() - started;
1673
- const requestId = response?.headers?.get?.("x-oneapi-request-id") ?? "(无)";
1698
+ // 追踪 id 各家名字不同:bamboonode 走 x-oneapi-request-id,SiliconFlow
1699
+ // 走 x-siliconcloud-trace-id(实测)。只读一家会让另一家的诊断永远显示
1700
+ //「(无)」,而排查问题时这个 id 是唯一的抓手。
1701
+ const requestId =
1702
+ response?.headers?.get?.("x-oneapi-request-id")
1703
+ ?? response?.headers?.get?.("x-siliconcloud-trace-id")
1704
+ ?? "(无)";
1674
1705
  let bodyTextOut = "";
1675
1706
  try {
1676
1707
  bodyTextOut = (await response.text()).slice(0, 300);
@@ -1818,6 +1849,28 @@ export function apply(ctx, config) {
1818
1849
  defaultModel: provider.defaultModel,
1819
1850
  rawModel: provider.model,
1820
1851
  rawSystemoneModel: typeof cfg.systemoneModel === "string" ? cfg.systemoneModel : "",
1852
+ rawSiliconflowModel: typeof cfg.siliconflowModel === "string" ? cfg.siliconflowModel : "",
1853
+ rawSiliconflowIntlModel:
1854
+ typeof cfg.siliconflowIntlModel === "string" ? cfg.siliconflowIntlModel : "",
1855
+ /**
1856
+ * 全部 provider 的模型原值,键 = config.yml 里的字段名(model /
1857
+ * systemoneModel / siliconflowModel / …)。
1858
+ *
1859
+ * 上面几项是历史散字段(回显用),这张表是给「模型输入框写回配置」用的:
1860
+ * 界面必须知道当前 provider 的那一格叫什么名字,而名字只有预设表知道。
1861
+ * 由预设表生成并下发,前端就不必为每个 provider 抄一份分支——新增预设
1862
+ * 只改 providers.js 一处,界面的模型框自动接上。
1863
+ *
1864
+ * 值取 cfg 原文而非 provider.model:后者对 requiresModel 的 provider
1865
+ * 已经回落到预设默认(Kev-4B),把它回显成「当前值」会让用户以为自己
1866
+ * 填过模型,进而不知道留空本来就有默认。
1867
+ */
1868
+ rawModels: Object.fromEntries(
1869
+ Object.values(PROVIDER_PRESETS).map((preset) => [
1870
+ preset.modelField,
1871
+ typeof cfg[preset.modelField] === "string" ? cfg[preset.modelField] : "",
1872
+ ]),
1873
+ ),
1821
1874
  // custom 的字段原样回显(除 key 明文):用户保存后再打开必须看到自己填的值。
1822
1875
  customBaseUrl: cfg.customBaseUrl ?? "",
1823
1876
  customEndpointStyle: cfg.customEndpointStyle ?? "auto",
@@ -1852,9 +1905,8 @@ export function apply(ctx, config) {
1852
1905
  keyEnvName: provider.apiKeyEnv,
1853
1906
  configPath: ownConfigPath,
1854
1907
  stats: client.stats,
1855
- // 决策点与自身开销:配置页靠这三项显示「哪个点在花多少钱」。
1908
+ // 决策点与缓存:配置页靠这两项显示「哪个点在花多少钱」。
1856
1909
  points: effectivePointModes(cfg),
1857
- budget: client.budget.report,
1858
1910
  cache: client.cache.report,
1859
1911
  };
1860
1912
  };
@@ -1877,27 +1929,27 @@ export function apply(ctx, config) {
1877
1929
  try {
1878
1930
  const body = JSON.parse(await request.text());
1879
1931
  // 只允许改这些键,避免前端误写任意字段。
1932
+ //
1933
+ // 各 provider 自己的三格(模型 / 显式 key / 密钥变量名)由预设表生成,
1934
+ // 不逐个写死:漏掉一个 provider,界面上「粘贴 key / 选模型」会 200
1935
+ // 成功但什么都没写——正是最难自查的「保存了没生效」。
1880
1936
  const allowed = [
1881
1937
  "enabled",
1882
1938
  "mode",
1883
1939
  "apiKey",
1884
1940
  "baseUrl",
1885
- "model",
1886
- "systemoneModel",
1887
1941
  "thinkMode",
1888
1942
  "timeoutMs",
1889
1943
  "provider",
1890
1944
  "customBaseUrl",
1891
1945
  "customEndpointStyle",
1892
- "customApiKey",
1893
- "customApiKeyEnv",
1894
- "customModel",
1895
1946
  "customHeaders",
1896
- "bamboonodeApiKeyEnv",
1897
- "systemoneApiKeyEnv",
1898
- "systemoneApiKey",
1899
- "bamboonodeApiKey",
1900
1947
  "keyStorage",
1948
+ ...Object.values(PROVIDER_PRESETS).flatMap((preset) => [
1949
+ preset.modelField,
1950
+ preset.keyField,
1951
+ preset.keyEnvField,
1952
+ ]),
1901
1953
  ];
1902
1954
  const patch = {};
1903
1955
  for (const key of allowed) {
@@ -1950,7 +2002,7 @@ export function apply(ctx, config) {
1950
2002
  * 决策点总览与切换。
1951
2003
  *
1952
2004
  * GET 返回注册表 + 当前模式 + 分账实测数据。
1953
- * PUT 改模式与自身预算。
2005
+ * PUT 改模式。
1954
2006
  *
1955
2007
  * 合成一个注册项按 method 分支,而不是同路径注册两次:
1956
2008
  * DSH 的 fetch 注册表以 path 为键,重复注册会互相覆盖(实测踩过)。
@@ -1994,7 +2046,6 @@ export function apply(ctx, config) {
1994
2046
  };
1995
2047
  }),
1996
2048
  modes: POINT_MODES,
1997
- budget: client.budget.report,
1998
2049
  cache: client.cache.report,
1999
2050
  };
2000
2051
  };
@@ -2034,9 +2085,6 @@ export function apply(ctx, config) {
2034
2085
  patch[point.key] = mode;
2035
2086
  }
2036
2087
  }
2037
- if (Number.isFinite(body.budgetTokens)) {
2038
- patch.budgetTokens = Math.max(0, Math.floor(body.budgetTokens));
2039
- }
2040
2088
  if (Object.keys(patch).length === 0) return json({ error: "no updatable fields" }, 400);
2041
2089
  await persistConfig(patch);
2042
2090
  // 立即回一份新的总览,前端不必再发一次 GET。
@@ -2086,7 +2134,7 @@ export function apply(ctx, config) {
2086
2134
  const response = await fetch(`${origin}/v1/models`, {
2087
2135
  headers: buildHeaders(provider, key),
2088
2136
  // 列表接口比决策接口慢(实测数秒),且只在用户打开配置页时调用一次,
2089
- // 用独立预算,不占用决策路径的 timeoutMs。
2137
+ // 用独立超时,不占用决策路径的 timeoutMs。
2090
2138
  signal: AbortSignal.timeout(20000),
2091
2139
  });
2092
2140
  if (!response.ok) {
@@ -2104,10 +2152,21 @@ export function apply(ctx, config) {
2104
2152
  // 放进下拉会让用户选了就失败,因此排除。
2105
2153
  const decisionTypes = ["StartLux Decision", "StartLux SystemOne"];
2106
2154
  const typedOnly = provider.id === "bamboonode";
2155
+ // 预设给了 knownModels 的 provider(SiliconFlow 实测):它的
2156
+ // /v1/models 会一并返回 98 个模型,其中决策模型只有 Kev-4B / SemIf,
2157
+ // 且**没有任何一条**带 supported_endpoint_types——按类型过滤会得到
2158
+ // 空列表,全收则把 96 个 chat 模型塞进下拉,选一个就是一个 400
2159
+ // `Model does not exist.`。这类端点只认预设清单里的名字。
2160
+ const knownModels = provider.preset?.knownModels ?? [];
2107
2161
  const models = [];
2108
2162
  for (const row of rows) {
2109
2163
  const id = typeof row?.id === "string" ? row.id : "";
2110
2164
  if (id.length === 0) continue;
2165
+ if (knownModels.length > 0) {
2166
+ if (!knownModels.some((name) => name.toLowerCase() === id.toLowerCase())) continue;
2167
+ models.push({ id, types: [] });
2168
+ continue;
2169
+ }
2111
2170
  const types = Array.isArray(row?.supported_endpoint_types) ? row.supported_endpoint_types : [];
2112
2171
  const isDecision = types.some((type) =>
2113
2172
  decisionTypes.some((needle) => String(type).toLowerCase() === needle.toLowerCase()),
@@ -2126,7 +2185,7 @@ export function apply(ctx, config) {
2126
2185
  current: provider.model,
2127
2186
  defaultHint: provider.defaultModel || DEFAULT_MODEL,
2128
2187
  provider: provider.id,
2129
- filtered: typedOnly,
2188
+ filtered: typedOnly || knownModels.length > 0,
2130
2189
  });
2131
2190
  } catch (error) {
2132
2191
  return json({
package/lib/providers.js CHANGED
@@ -32,10 +32,13 @@
32
32
  /**
33
33
  * 可选的 provider 标识。
34
34
  *
35
- * `systemone` / `bamboonode` 是内置预设(默认地址与参数由本模块提供);
36
- * `custom` 使用用户在设置界面填写的 base URL 与鉴权信息。
35
+ * `systemone` / `bamboonode` / `siliconflow` / `siliconflow-intl` 是内置预设
36
+ *(默认地址与参数由本模块提供);`custom` 使用用户在设置界面填写的
37
+ * base URL 与鉴权信息。
38
+ *
39
+ * 顺序即设置界面下拉的顺序:常用项在前,`custom` 收尾(它要填的东西最多)。
37
40
  */
38
- export const PROVIDER_IDS = ["systemone", "bamboonode", "custom"];
41
+ export const PROVIDER_IDS = ["systemone", "bamboonode", "siliconflow", "siliconflow-intl", "custom"];
39
42
 
40
43
  /** 默认 provider。刻意选 bamboonode:老用户的 `startlux.yml` 里没有 provider 键,
41
44
  * 必须继续走原来的地址与端点风格,升级不能改变既有行为。 */
@@ -102,7 +105,22 @@ export function normalizeThinkMode(raw) {
102
105
  * 内置预设。
103
106
  *
104
107
  * 每个预设必须给出:显示名称、默认 base URL、端点风格、环境变量名、
105
- * 默认模型提示。用户选了预设就用这里的值,不需要在设置界面重复填写。
108
+ * 默认模型与三个配置字段名。用户选了预设就用这里的值,不需要在设置界面
109
+ * 重复填写。
110
+ *
111
+ * 两个可选标记(都是「按服务商实测」得出的,不是风格偏好):
112
+ *
113
+ * - `requiresModel: true`:该端点**不接受**省略 model 的请求(实测
114
+ * SiliconFlow 的 /v1/systemone 省略或空串都回 400
115
+ * `Model field is required.`)。这类预设在模型留空时回落到
116
+ * `defaultModel`,而不是沿用「留空 = 省略字段」的通用规则——
117
+ * 那会导致一次注定 400 的请求,而守卫是 fail-open 的,表现为静默失效。
118
+ * 其余服务商(bamboonode / system one)省略 model 会由服务端回填默认
119
+ * 模型,保留省略语义才能在它们改名时不失效。
120
+ * - `knownModels`:该服务 `/v1/models` 会一并返回大量**非决策**模型且不带
121
+ * `supported_endpoint_types`(实测 SiliconFlow 返回 98 个,其中 chat
122
+ * 模型打到 /v1/systemone 是 400 `Model does not exist.`)。
123
+ * 列了这份清单就按它筛,不让下拉里出现「选了就失败」的选项。
106
124
  */
107
125
  export const PROVIDER_PRESETS = {
108
126
  systemone: {
@@ -143,6 +161,55 @@ export const PROVIDER_PRESETS = {
143
161
  hint: "StartLux 官方端点(POST /v1/decisions),本插件的原有默认值",
144
162
  hintEn: "StartLux official endpoint (POST /v1/decisions); the original default",
145
163
  },
164
+ siliconflow: {
165
+ id: "siliconflow",
166
+ label: "硅基流动",
167
+ labelEn: "SiliconFlow",
168
+ baseUrl: "https://api.siliconflow.cn/v1",
169
+ // 实测(2026-10-25,真实 key):该站只认 /systemone,/v1/decisions 回 404。
170
+ // 因此这里直接给 systemone,不必让 404 回退先烧一次请求。
171
+ endpointStyle: "systemone",
172
+ // 两个 SiliconFlow 站点是**两套账号**,key 互不通用,因此各占一格。
173
+ apiKeyEnv: "SILICONFLOW_API_KEY",
174
+ legacyApiKeyEnvs: [],
175
+ keyField: "siliconflowApiKey",
176
+ // 实测省略 model 会 400 `Model field is required.`,而 bamboonode 省略会
177
+ // 由服务端回填默认模型。因此这个 provider 必须**总带着一个模型名**——
178
+ // 留空时回落到下面的 defaultModel,而不是省略字段(见 resolveProvider)。
179
+ // 这正是「只填一个 API key 就能用」的前提:模型名是预设给好的。
180
+ requiresModel: true,
181
+ // /v1/models 里这条就叫 Kev-4B(官方文档两处分别写作 Kev-4b 与 Kev-4B,
182
+ // 实测三种大小写都 200),取服务返回的写法,免得下拉里出现两条同模型。
183
+ defaultModel: "Kev-4B",
184
+ // 官方只支持这两个决策模型;/v1/models 却会一并返回 98 个 chat 模型且
185
+ // 不带 supported_endpoint_types(实测把 chat 模型打过来是 400
186
+ // `Model does not exist.`),所以按这份清单筛,不让下拉里出现选了就坏的项。
187
+ knownModels: ["Kev-4B", "SemIf"],
188
+ modelField: "siliconflowModel",
189
+ keyEnvField: "siliconflowApiKeyEnv",
190
+ hint: "硅基流动(国内站)的 System One 端点(POST /v1/systemone);可用模型 Kev-4B / SemIf,默认 Kev-4B,只填一个 API key 即可使用",
191
+ hintEn: "SiliconFlow China (POST /v1/systemone); models Kev-4B / SemIf, default Kev-4B — just add an API key",
192
+ },
193
+ "siliconflow-intl": {
194
+ id: "siliconflow-intl",
195
+ label: "Silicon Flow",
196
+ labelEn: "Silicon Flow",
197
+ baseUrl: "https://api.siliconflow.com/v1",
198
+ // 同样只认 /systemone:实测打 /v1/decisions 回 404 Not Found。
199
+ endpointStyle: "systemone",
200
+ // 与国内站分开:两套账号的 key 不能共用一格,否则切过去就 401,
201
+ // 而用户看不出是「拿错了 key」还是「key 失效」。
202
+ apiKeyEnv: "SILICONFLOW_INTL_API_KEY",
203
+ legacyApiKeyEnvs: [],
204
+ keyField: "siliconflowIntlApiKey",
205
+ requiresModel: true,
206
+ defaultModel: "Kev-4B",
207
+ knownModels: ["Kev-4B", "SemIf"],
208
+ modelField: "siliconflowIntlModel",
209
+ keyEnvField: "siliconflowIntlApiKeyEnv",
210
+ hint: "Silicon Flow(国际站)的 System One 端点(POST /v1/systemone);与国内站账号不通用,可用模型 Kev-4B / SemIf,默认 Kev-4B,只填一个 API key 即可使用",
211
+ hintEn: "Silicon Flow global (POST /v1/systemone); separate account from the CN site, models Kev-4B / SemIf, default Kev-4B — just add an API key",
212
+ },
146
213
  custom: {
147
214
  id: "custom",
148
215
  label: "custom",
@@ -417,7 +484,17 @@ export function resolveProvider(cfg = {}) {
417
484
  // `systemoneModel` 属于 system one。共用一格会把 bamboonode 的模型名发给
418
485
  // TypeSafe,得到 400 `model not served`——「切 provider 就坏」的典型来源。
419
486
  const modelField = preset.modelField;
420
- const model = typeof cfg[modelField] === "string" ? cfg[modelField].trim() : "";
487
+ const rawModel = typeof cfg[modelField] === "string" ? cfg[modelField].trim() : "";
488
+ // 模型留空有两种含义,取决于该服务商的端点接不接受「不带 model」的请求:
489
+ //
490
+ // - requiresModel(SiliconFlow 实测):省略或空串都回 400
491
+ // `Model field is required.`,因此留空必须回落到预设的 defaultModel
492
+ // ——不发一个注定失败的请求,守卫才不会又一次静默失效。
493
+ // - 其余服务商:留空 = 省略字段,由服务端回填默认模型。保留省略语义,
494
+ // 服务商将来给模型改名时本配置不会失效(反过来把模型名钉死在这里
495
+ // 才是最脆的那种配法)。
496
+ const model =
497
+ rawModel.length > 0 ? rawModel : preset.requiresModel === true ? preset.defaultModel : "";
421
498
  // 密钥变量名:`apiKeyEnv` 是**历史字段**,只对 bamboonode 有覆盖意义
422
499
  //(老版本只有这一个 provider,用户可能已经在里面写了自己的变量名)。
423
500
  // 若让它参与 system one 的解析,会盖掉 TYPESAFE_API_KEY——切到 system one
@@ -542,8 +619,11 @@ export function buildRequestBody(provider, payload) {
542
619
  * 供设置界面展示的 provider 清单。
543
620
  *
544
621
  * 由服务端下发而不是前端硬编码:新增预设只改本文件一处,界面自动多一个选项。
622
+ * `keyField` / `modelField` 也一并下发——界面写回配置时必须知道「这一格在
623
+ * config.yml 里叫什么」,而这个名字只有预设表知道;让前端自己再抄一份,
624
+ * 就是「界面显示存到 A、实际写进 B」这种最难自查的偏差。
545
625
  *
546
- * @returns 每个预设的 id / 显示名 / 默认地址 / 说明。
626
+ * @returns 每个预设的 id / 显示名 / 默认地址 / 字段名 / 说明。
547
627
  */
548
628
  export function providerCatalog() {
549
629
  return PROVIDER_IDS.map((id) => {
@@ -560,6 +640,14 @@ export function providerCatalog() {
560
640
  // 界面用它说明「key 存在配置文件的哪一格」——只报变量名会让人
561
641
  // 以为 key 在 .credentials.yaml 里,而实际生效的可能是配置文件。
562
642
  keyField: preset.keyField,
643
+ // 该 provider 的模型字段名(`model` / `systemoneModel` / …)。
644
+ // 模型输入框按它写回配置,切 provider 不会把 A 的模型名写进 B 那一格。
645
+ modelField: preset.modelField,
646
+ // 「模型」那一行的语义全靠这两项:端点是否强制要 model(决定留空的
647
+ // 写法是「预设默认」还是「服务端默认」),以及能选的模型有哪些。
648
+ // 都由预设表下发,前端不必知道任何一家服务商的脾气。
649
+ requiresModel: preset.requiresModel === true,
650
+ knownModels: [...(preset.knownModels ?? [])],
563
651
  hint: preset.hint,
564
652
  hintEn: preset.hintEn,
565
653
  customizable: id === "custom",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-decision-engine",
3
- "version": "0.6.1",
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).",
3
+ "version": "0.7.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, SiliconFlow / Silicon Flow, or a custom base URL).",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -36,6 +36,9 @@
36
36
  "system-1",
37
37
  "typesafe",
38
38
  "jev",
39
+ "siliconflow",
40
+ "siliconflow-cn",
41
+ "kev-4b",
39
42
  "provider",
40
43
  "custom-base-url",
41
44
  "tool-approval",
package/lib/budget.js DELETED
@@ -1,92 +0,0 @@
1
- /**
2
- * 决策模型自身的 token 预算。
3
- *
4
- * 引入理由:StartLux 每次决策实测约 1,900 token,p50 延迟约 2 秒。
5
- * 把这个判断层挂到多个决策点上,调用量会成倍上升——如果不设上限,
6
- * 「省 token 的插件」反而会变成主要的 token 消耗方。
7
- *
8
- * 预算按滑动窗口统计(默认 1 小时)。一旦超限,所有决策点立刻退回
9
- * 本地启发式并 fail-open,等窗口滚过去自动恢复;不需要重启,也不会
10
- * 让工具调用被卡住。
11
- */
12
- export class TokenBudget {
13
- /**
14
- * @param getLimitTokens - 返回当前上限的取值函数;0 表示不限制。
15
- * 刻意接收函数而不是数值:本插件的原则是「配置每次现读」,
16
- * 若在构造时固化,用户在配置页改了预算就得重启才生效。
17
- * @param getWindowMs - 返回当前窗口长度的取值函数。
18
- */
19
- constructor(getLimitTokens, getWindowMs) {
20
- this.getLimitTokens = getLimitTokens;
21
- this.getWindowMs = getWindowMs;
22
- /** @type {Array<{at: number, tokens: number}>} */
23
- this.entries = [];
24
- this.blocked = 0;
25
- }
26
-
27
- /** 当前生效的上限(读不到时按不限制处理,绝不因预算自身出错而卡住决策)。 */
28
- get limitTokens() {
29
- const raw = typeof this.getLimitTokens === "function" ? this.getLimitTokens() : this.getLimitTokens;
30
- const value = Number(raw);
31
- return Number.isFinite(value) && value > 0 ? value : 0;
32
- }
33
-
34
- /** 当前生效的窗口长度。 */
35
- get windowMs() {
36
- const raw = typeof this.getWindowMs === "function" ? this.getWindowMs() : this.getWindowMs;
37
- const value = Number(raw);
38
- return Number.isFinite(value) && value > 0 ? value : 3600000;
39
- }
40
-
41
- /** 丢弃窗口外的记录。 */
42
- prune(now = Date.now()) {
43
- const cutoff = now - this.windowMs;
44
- let drop = 0;
45
- while (drop < this.entries.length && this.entries[drop].at < cutoff) drop += 1;
46
- if (drop > 0) this.entries.splice(0, drop);
47
- }
48
-
49
- /** 窗口内已消耗的 token。 */
50
- used(now = Date.now()) {
51
- this.prune(now);
52
- let total = 0;
53
- for (const entry of this.entries) total += entry.tokens;
54
- return total;
55
- }
56
-
57
- /** 预算是否已耗尽(未设置上限时永远 false)。 */
58
- exhausted(now = Date.now()) {
59
- if (this.limitTokens <= 0) return false;
60
- return this.used(now) >= this.limitTokens;
61
- }
62
-
63
- /**
64
- * 记一次消耗。
65
- * @param tokens - 本次估算或实测的 input token 数。
66
- */
67
- spend(tokens) {
68
- const amount = Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
69
- if (amount === 0) return;
70
- this.entries.push({ at: Date.now(), tokens: amount });
71
- }
72
-
73
- /** 记一次「因为预算耗尽而放弃判断」,用于配置页展示。 */
74
- noteBlocked() {
75
- this.blocked += 1;
76
- }
77
-
78
- /** 供 UI 展示的快照。 */
79
- get report() {
80
- const now = Date.now();
81
- const used = this.used(now);
82
- const limit = this.limitTokens;
83
- return {
84
- used,
85
- limit,
86
- windowMs: this.windowMs,
87
- remaining: limit <= 0 ? null : Math.max(0, limit - used),
88
- exhausted: this.exhausted(now),
89
- blocked: this.blocked,
90
- };
91
- }
92
- }