dsh-decision-engine 0.6.0 → 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";
@@ -37,6 +36,7 @@ import { mountRoutePoint } from "./adapters/route.js";
37
36
  import { mountAgentPoints } from "./adapters/agent.js";
38
37
  import { mountCotTranslatePoint } from "./adapters/translate.js";
39
38
  import { applyConfigUpdates, parseConfigText } from "./config-file.js";
39
+ import { EndpointMemory, sendWithEndpointFallback } from "./endpoint-fallback.js";
40
40
  import { resolveDshHome } from "./home-paths.js";
41
41
  import { applyRefUpdates, listRefNames, pickRefValue } from "./credentials.js";
42
42
  import {
@@ -49,6 +49,7 @@ import {
49
49
  buildEndpoint,
50
50
  buildHeaders,
51
51
  buildRequestBody,
52
+ endpointStyleOfPath,
52
53
  normalizeBaseUrl,
53
54
  normalizeThinkMode,
54
55
  parseCustomHeaders,
@@ -87,6 +88,16 @@ const DEFAULTS = {
87
88
  model: DEFAULT_MODEL,
88
89
  /** system one 的模型字段;留空 = 由服务端选默认(jev-latest)。 */
89
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: "",
90
101
  /** 自定义 provider:地址、端点风格、鉴权、请求头、模型。 */
91
102
  customBaseUrl: "",
92
103
  customEndpointStyle: "auto",
@@ -95,11 +106,15 @@ const DEFAULTS = {
95
106
  /** 各 provider 的显式 key(与变量名分开存,互不覆盖)。 */
96
107
  systemoneApiKey: "",
97
108
  bamboonodeApiKey: "",
109
+ siliconflowApiKey: "",
110
+ siliconflowIntlApiKey: "",
98
111
  customModel: "",
99
112
  customHeaders: "",
100
113
  /** 各预设的密钥来源变量名,留空即用预设内置值。 */
101
114
  bamboonodeApiKeyEnv: "",
102
115
  systemoneApiKeyEnv: "",
116
+ siliconflowApiKeyEnv: "",
117
+ siliconflowIntlApiKeyEnv: "",
103
118
  /**
104
119
  * 密钥保存位置:
105
120
  * credentials = ~/.dsh/.credentials.yaml 的 refs 段(默认,与其他服务统一托管)
@@ -185,9 +200,6 @@ const DEFAULTS = {
185
200
  pointSkillPick: "off",
186
201
  pointCotTranslate: "off",
187
202
  // ---- 自身开销护栏 ----------------------------------------------------
188
- // 决策模型每次约 1.9k token;不设上限时,多决策点会把它变成主要消耗方。
189
- budgetTokens: 400000,
190
- budgetWindowMs: 3600000,
191
203
  cacheTtlMs: 600000,
192
204
  };
193
205
 
@@ -207,7 +219,10 @@ export const Config = z.object({
207
219
  provider: z.union([...PROVIDER_IDS]).default(DEFAULTS.provider)
208
220
  .description(
209
221
  "systemone = 任意 system-one 风格服务(TypeSafe / 自建 llama.cpp);"
210
- + "bamboonode = StartLux 官方端点(原有默认);custom = 自己填地址",
222
+ + "bamboonode = StartLux 官方端点(原有默认);"
223
+ + "siliconflow = 硅基流动(api.siliconflow.cn,POST /v1/systemone);"
224
+ + "siliconflow-intl = Silicon Flow 国际站(api.siliconflow.com);"
225
+ + "custom = 自己填地址",
211
226
  ),
212
227
  baseUrl: z.string().default(DEFAULTS.baseUrl)
213
228
  .description("bamboonode 预设的地址覆盖;/v1 与 /v1/decisions 两种写法都接受"),
@@ -215,6 +230,10 @@ export const Config = z.object({
215
230
  .description("bamboonode 预设的模型 id;留空表示由服务端选默认"),
216
231
  systemoneModel: z.string().default(DEFAULTS.systemoneModel)
217
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 必填)"),
218
237
  customBaseUrl: z.string().default(DEFAULTS.customBaseUrl)
219
238
  .description("provider=custom 时的 base URL,必须是 http/https 地址"),
220
239
  customEndpointStyle: z.union([...ENDPOINT_STYLES]).default(DEFAULTS.customEndpointStyle)
@@ -225,6 +244,10 @@ export const Config = z.object({
225
244
  .description("system one 的 API key;留空则读 TYPESAFE_API_KEY"),
226
245
  bamboonodeApiKey: z.string().role("secret").default(DEFAULTS.bamboonodeApiKey)
227
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"),
228
251
  customApiKeyEnv: z.string().default(DEFAULTS.customApiKeyEnv)
229
252
  .description("custom 的密钥来源变量名(环境变量或 .credentials.yaml 的 refs 键)"),
230
253
  customModel: z.string().default(DEFAULTS.customModel)
@@ -235,6 +258,10 @@ export const Config = z.object({
235
258
  .description("bamboonode 预设的密钥变量名覆盖;留空用 BAMBOO_API_KEY"),
236
259
  systemoneApiKeyEnv: z.string().default(DEFAULTS.systemoneApiKeyEnv)
237
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"),
238
265
  apiKey: z.string().role("secret")
239
266
  .description("当前 provider 的 API key;留空则读 apiKeyEnv 指定的环境变量"),
240
267
  apiKeyEnv: z.string().default(DEFAULTS.apiKeyEnv)
@@ -317,9 +344,6 @@ export const Config = z.object({
317
344
  .description(`${point.label}:off 不参与 / observe 只记录 / enforce 生效`),
318
345
  ]),
319
346
  ),
320
- budgetTokens: z.number().step(1).min(0).default(DEFAULTS.budgetTokens)
321
- .description("决策模型自身每窗口的 token 上限;0 表示不限制。超限后自动退回启发式"),
322
- budgetWindowMs: z.number().step(1).min(60000).default(DEFAULTS.budgetWindowMs),
323
347
  cacheTtlMs: z.number().step(1).min(1000).default(DEFAULTS.cacheTtlMs)
324
348
  .description("缓存存活时间;改策略或配置后旧裁决不应继续被复用"),
325
349
  });
@@ -453,7 +477,11 @@ export function validateProviderPatch(patch, current) {
453
477
  }
454
478
 
455
479
  // 变量名必须能当环境变量用,否则凭据永远读不到。
456
- 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;
457
485
  if (!Object.hasOwn(patch, key)) continue;
458
486
  const value = typeof patch[key] === "string" ? patch[key].trim() : "";
459
487
  if (value.length === 0) continue;
@@ -934,22 +962,26 @@ class StartluxClient {
934
962
  // 遥测目录由 apply() 注入:它要兼顾「更名前的旧目录里已有历史记录」,
935
963
  // 这个判断需要文件系统探测,不适合放进纯客户端。
936
964
  this.telemetryDir = options.telemetryDir;
937
- // 缓存与预算都传「取值函数」而不是快照:与 endpoint/model/key 同一套
965
+ // 缓存也传「取值函数」而不是快照:与 endpoint/model/key 同一套
938
966
  // 原则——配置改了立刻对下一次生效,不要求重启。
939
967
  this.cache = new DecisionCache(
940
968
  () => this.getConfig().cacheSize,
941
969
  () => this.getConfig().cacheTtlMs,
942
970
  );
943
- this.budget = new TokenBudget(
944
- () => this.getConfig().budgetTokens,
945
- () => this.getConfig().budgetWindowMs,
946
- );
947
971
  this.failures = 0;
948
972
  this.cooldownUntil = 0;
949
973
  this.lastDegrade = undefined;
950
974
  this.stats = { requests: 0, cached: 0, degraded: 0, ok: 0, lastStatus: undefined };
951
975
  /** 按决策点分账:不分开统计就无法判断哪个点值得留。 */
952
976
  this.byPoint = new Map();
977
+ /**
978
+ * 记住「哪条路径真的能用」。
979
+ *
980
+ * 路径后缀由服务商决定(实测 SiliconFlow 只通 /systemone),而守卫是
981
+ * fail-open 的——路径踩空等于静默失效。404 时换另一条再试,并记住胜出的
982
+ * 那条,下次直接打它,省掉热路径上多余的一次往返。
983
+ */
984
+ this.endpointMemory = new EndpointMemory();
953
985
  }
954
986
 
955
987
  /** 取(或建)某个决策点的计数器。 */
@@ -1022,19 +1054,15 @@ class StartluxClient {
1022
1054
  provider: provider.id,
1023
1055
  hint:
1024
1056
  provider.apiKeyEnv.length > 0
1025
- ? `设置 apiKey(或 customApiKey)或导出 $${provider.apiKeyEnv}`
1026
- : "为 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 ?? "密钥变量名"}`,
1027
1062
  });
1028
1063
  }
1029
1064
  if (Date.now() < this.cooldownUntil) return degrade("cooldown");
1030
1065
 
1031
- // 预算闸:决策模型自己也烧 token。超限就退回启发式并 fail-open,
1032
- // 等窗口滚过去自动恢复——绝不因为「省钱的东西」把活卡住。
1033
- if (this.budget.exhausted()) {
1034
- this.budget.noteBlocked();
1035
- return degrade("budget-exhausted", { budget: this.budget.report });
1036
- }
1037
-
1038
1066
  const endpoint = provider.endpoint;
1039
1067
  // 允许留空:不发送 model 字段时,服务端会用它当前的默认决策模型
1040
1068
  //(实测省略 model 返回 200 且 model 回填为实际使用的模型)。
@@ -1055,37 +1083,57 @@ class StartluxClient {
1055
1083
  return { ...hit, cached: true, latencyMs: 0 };
1056
1084
  }
1057
1085
 
1058
- const budget = Math.max(1000, cfg.timeoutMs);
1059
- const timer = AbortSignal.timeout(budget);
1086
+ const timeoutMs = Math.max(1000, cfg.timeoutMs);
1087
+ const timer = AbortSignal.timeout(timeoutMs);
1060
1088
  const signal = call.signal === undefined ? timer : AbortSignal.any([call.signal, timer]);
1061
1089
 
1062
1090
  this.stats.requests += 1;
1063
1091
  counter.requests += 1;
1092
+ // 请求体只构造一次:换路径重试时内容必须完全相同,否则第二次请求
1093
+ // 与第一次不可比(缓存键也随之失配)。
1094
+ const bodyText = JSON.stringify(
1095
+ buildRequestBody(provider, {
1096
+ state: call.state,
1097
+ questions: call.questions,
1098
+ thinkMode: cfg.thinkMode,
1099
+ }),
1100
+ );
1101
+ // 候选端点:主路径优先,404 时换另一条(实测 SiliconFlow 只通
1102
+ // /systemone、bamboonode 两条都通)。记忆里验证过的路径会被提到最前,
1103
+ // 因此只有第一次探测会多一次往返。
1104
+ const candidates = this.endpointMemory.order(provider.baseUrl, provider.candidates ?? [endpoint]);
1064
1105
  let response;
1106
+ let usedEndpoint = endpoint;
1107
+ let attempts = [];
1065
1108
  try {
1066
- response = await this.fetchFn(endpoint, {
1067
- method: "POST",
1068
- // 鉴权与请求头同样由 provider 构造:custom 可以带额外头,
1069
- // 但 authorization / content-type 永远由本插件决定。
1070
- headers: buildHeaders(provider, key),
1071
- body: JSON.stringify(
1072
- buildRequestBody(provider, {
1073
- state: call.state,
1074
- questions: call.questions,
1075
- thinkMode: cfg.thinkMode,
1076
- }),
1077
- ),
1078
- signal,
1109
+ const result = await sendWithEndpointFallback(candidates, async (target) => {
1110
+ usedEndpoint = target;
1111
+ return this.fetchFn(target, {
1112
+ method: "POST",
1113
+ // 鉴权与请求头同样由 provider 构造:custom 可以带额外头,
1114
+ // 但 authorization / content-type 永远由本插件决定。
1115
+ headers: buildHeaders(provider, key),
1116
+ body: bodyText,
1117
+ signal,
1118
+ });
1079
1119
  });
1120
+ response = result.response;
1121
+ usedEndpoint = result.endpoint;
1122
+ attempts = result.attempts;
1123
+ // 探测到可用路径:记住它,下次直接打(省掉一次多余往返)。
1124
+ if (response?.ok === true) {
1125
+ const style = styleOfEndpoint(usedEndpoint);
1126
+ if (style !== undefined) this.endpointMemory.record(provider.baseUrl, usedEndpoint, style);
1127
+ }
1080
1128
  } catch (error) {
1081
1129
  if (call.signal?.aborted) return degrade("caller-aborted");
1082
1130
  if (timer.aborted || error?.name === "AbortError") {
1083
1131
  this.noteFailure();
1084
- return degrade("timeout", { budgetMs: budget, endpoint });
1132
+ return degrade("timeout", { timeoutMs, endpoint: usedEndpoint });
1085
1133
  }
1086
1134
  this.noteFailure();
1087
1135
  return degrade("network-error", {
1088
- endpoint,
1136
+ endpoint: usedEndpoint,
1089
1137
  message: error instanceof Error ? error.message : String(error),
1090
1138
  });
1091
1139
  }
@@ -1106,14 +1154,31 @@ class StartluxClient {
1106
1154
  if (response.status === 401 || response.status === 403) {
1107
1155
  return degrade("auth-failed", {
1108
1156
  provider: provider.id,
1109
- endpoint,
1157
+ endpoint: usedEndpoint,
1110
1158
  status: response.status,
1111
1159
  keyEnv: provider.apiKeyEnv,
1112
1160
  body: bodyText,
1113
1161
  hint: `鉴权被拒(HTTP ${response.status}):检查 ${provider.id} 的 API key 是否有效、是否与地址匹配`,
1114
1162
  });
1115
1163
  }
1116
- return degrade(`http-${response.status}`, { provider: provider.id, endpoint, model, body: bodyText });
1164
+ // 404 且两条路径都试过:路径风格不对是最常见的原因,必须说清楚
1165
+ // 试过哪两条——否则用户只看到「HTTP 404」,完全猜不到是路径选错。
1166
+ const tried = attempts.map((item) => item.endpoint);
1167
+ const allMissing = response.status === 404 && attempts.length > 1;
1168
+ return degrade(`http-${response.status}`, {
1169
+ provider: provider.id,
1170
+ endpoint: usedEndpoint,
1171
+ model,
1172
+ body: bodyText,
1173
+ ...(tried.length > 1 ? { tried } : {}),
1174
+ ...(allMissing
1175
+ ? {
1176
+ hint:
1177
+ `路径不存在(HTTP 404):已试过 ${tried.join(" 与 ")}。` +
1178
+ `若该服务用的是另一条路径,请在配置页把「端点风格」改成对应的那条。`,
1179
+ }
1180
+ : {}),
1181
+ });
1117
1182
  }
1118
1183
 
1119
1184
  let body;
@@ -1121,15 +1186,17 @@ class StartluxClient {
1121
1186
  body = await response.json();
1122
1187
  } catch {
1123
1188
  this.noteFailure();
1124
- return degrade("invalid-json", { endpoint });
1189
+ return degrade("invalid-json", { endpoint: usedEndpoint });
1125
1190
  }
1126
1191
  if (body === null || typeof body !== "object") {
1127
1192
  this.noteFailure();
1128
- return degrade("invalid-response", { endpoint });
1193
+ return degrade("invalid-response", { endpoint: usedEndpoint });
1129
1194
  }
1130
1195
 
1131
1196
  const answers = body.answers;
1132
- if (answers === null || typeof answers !== "object") return degrade("missing-answers", { endpoint });
1197
+ if (answers === null || typeof answers !== "object") {
1198
+ return degrade("missing-answers", { endpoint: usedEndpoint });
1199
+ }
1133
1200
 
1134
1201
  const usage = typeof body.usage === "object" && body.usage !== null ? body.usage : {};
1135
1202
  const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : undefined;
@@ -1148,8 +1215,6 @@ class StartluxClient {
1148
1215
  counter.latencyMax = Math.max(counter.latencyMax, result.latencyMs);
1149
1216
  if (typeof inputTokens === "number") {
1150
1217
  counter.tokens += inputTokens;
1151
- // 记进自身预算:这是「省钱插件」自己的花销。
1152
- this.budget.spend(inputTokens);
1153
1218
  }
1154
1219
 
1155
1220
  this.cache.set(exactKey, result);
@@ -1320,14 +1385,18 @@ export function apply(ctx, config) {
1320
1385
  /** 本次要写进 yml 的键值。 */
1321
1386
  const ymlPatch = { ...patch };
1322
1387
 
1323
- // provider → 它的显式 key 字段名与 refs 键名
1324
- const slots = [
1325
- { keyField: "systemoneApiKey", refName: current.systemoneApiKeyEnv || "TYPESAFE_API_KEY" },
1326
- { keyField: "bamboonodeApiKey", refName: current.bamboonodeApiKeyEnv || "BAMBOO_API_KEY" },
1327
- { keyField: "customApiKey", refName: current.customApiKeyEnv || "DECISION_ENGINE_CUSTOM_KEY" },
1328
- // 历史共享字段:老前端只认 apiKey,按 bamboonode 处理
1329
- { keyField: "apiKey", refName: current.bamboonodeApiKeyEnv || "BAMBOO_API_KEY" },
1330
- ];
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
+ });
1331
1400
 
1332
1401
  for (const slot of slots) {
1333
1402
  if (!Object.hasOwn(patch, slot.keyField)) continue;
@@ -1499,7 +1568,7 @@ export function apply(ctx, config) {
1499
1568
  // ---- 其余八个决策点 ----------------------------------------------------
1500
1569
  //
1501
1570
  // 与工具授权不同,这八个点共用一条 execution 链路(engine.js):模式判断、
1502
- // 预算、冷却、答案校验、遥测都由 runner.ask() 统一处理,适配器只负责
1571
+ // 冷却、答案校验、遥测都由 runner.ask() 统一处理,适配器只负责
1503
1572
  // 「给出 state」与「落地副作用」。这样任何一处降级逻辑都不会漏。
1504
1573
  const runner = createDecisionRunner({
1505
1574
  getConfig,
@@ -1592,43 +1661,73 @@ export function apply(ctx, config) {
1592
1661
  ok: false,
1593
1662
  text:
1594
1663
  `未找到 API key(provider: ${provider.id}):\n` +
1595
- `请设置 config.apiKey${provider.id === "custom" ? " / customApiKey" : ""}` +
1596
- (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 ?? "密钥变量名"}`),
1597
1671
  };
1598
1672
  }
1599
1673
  const started = Date.now();
1600
1674
  try {
1601
- const response = await fetch(endpoint, {
1602
- method: "POST",
1603
- headers: buildHeaders(provider, key),
1604
- // 与 classify 保持一致:同一个 buildRequestBody,探测不可能与实际
1605
- // 请求形状不同步(这正是「测试通过但守卫不工作」的经典来源)。
1606
- body: JSON.stringify(
1607
- buildRequestBody(provider, {
1608
- state: { probe: true, cwd: process.cwd() },
1609
- questions: { alive: { type: "noul", instructions: "Is this request well formed?" } },
1610
- thinkMode: cfg.thinkMode,
1611
- }),
1612
- ),
1613
- signal: AbortSignal.timeout(Math.max(5000, cfg.timeoutMs)),
1614
- });
1675
+ // 探测与实际决策走**同一套**候选顺序与 404 回退:否则会出现
1676
+ // 「测试通过但守卫不工作」或反之。实测 SiliconFlow 只通 /systemone,
1677
+ // 而 /v1/decisions 回 404——只试一条路径必然误报。
1678
+ const bodyText = JSON.stringify(
1679
+ buildRequestBody(provider, {
1680
+ state: { probe: true, cwd: process.cwd() },
1681
+ questions: { alive: { type: "noul", instructions: "Is this request well formed?" } },
1682
+ thinkMode: cfg.thinkMode,
1683
+ }),
1684
+ );
1685
+ const candidates = client.endpointMemory.order(provider.baseUrl, provider.candidates ?? [endpoint]);
1686
+ const result = await sendWithEndpointFallback(candidates, (target) =>
1687
+ fetch(target, {
1688
+ method: "POST",
1689
+ headers: buildHeaders(provider, key),
1690
+ // 与 classify 保持一致:同一个 buildRequestBody,探测不可能与实际
1691
+ // 请求形状不同步(这正是「测试通过但守卫不工作」的经典来源)。
1692
+ body: bodyText,
1693
+ signal: AbortSignal.timeout(Math.max(5000, cfg.timeoutMs)),
1694
+ }),
1695
+ );
1696
+ const response = result.response;
1615
1697
  const elapsed = Date.now() - started;
1616
- const requestId = response.headers.get("x-oneapi-request-id") ?? "(无)";
1617
- let bodyText = "";
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
+ ?? "(无)";
1705
+ let bodyTextOut = "";
1618
1706
  try {
1619
- bodyText = (await response.text()).slice(0, 300);
1707
+ bodyTextOut = (await response.text()).slice(0, 300);
1620
1708
  } catch {
1621
1709
  /* 忽略 */
1622
1710
  }
1711
+ // 探测成功即记住胜出的路径:下次决策直接打它。
1712
+ if (response?.ok === true) {
1713
+ const style = styleOfEndpoint(result.endpoint);
1714
+ if (style !== undefined) client.endpointMemory.record(provider.baseUrl, result.endpoint, style);
1715
+ }
1716
+ // 换过路径就要说出来:用户需要知道「实际打的是哪条」。
1717
+ const triedText =
1718
+ result.attempts.length > 1
1719
+ ? `尝试过的路径: ${result.attempts.map((item) => `${item.endpoint} → ${item.status}`).join(",")}\n`
1720
+ : "";
1623
1721
  return {
1624
- ok: response.ok,
1722
+ ok: response?.ok === true,
1625
1723
  text:
1626
1724
  `provider: ${provider.label}(${provider.id})\n` +
1627
- `POST ${endpoint}\n` +
1725
+ `POST ${result.endpoint}\n` +
1628
1726
  `model: ${(provider.model ?? "").trim().length > 0 ? provider.model : "自动(服务端默认)"}\n` +
1629
- `HTTP ${response.status} 耗时 ${elapsed}ms\n` +
1727
+ `HTTP ${response?.status ?? "(无响应)"} 耗时 ${elapsed}ms\n` +
1630
1728
  `x-oneapi-request-id: ${requestId}\n` +
1631
- `响应: ${bodyText}`,
1729
+ (triedText.length > 0 ? `${triedText}` : "") +
1730
+ `响应: ${bodyTextOut}`,
1632
1731
  };
1633
1732
  } catch (error) {
1634
1733
  return {
@@ -1750,9 +1849,36 @@ export function apply(ctx, config) {
1750
1849
  defaultModel: provider.defaultModel,
1751
1850
  rawModel: provider.model,
1752
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
+ ),
1753
1874
  // custom 的字段原样回显(除 key 明文):用户保存后再打开必须看到自己填的值。
1754
1875
  customBaseUrl: cfg.customBaseUrl ?? "",
1755
1876
  customEndpointStyle: cfg.customEndpointStyle ?? "auto",
1877
+ // 实际用得通的那条路径(探测出来的)。路径后缀由服务商决定,
1878
+ // 实测 SiliconFlow 只通 /systemone;界面必须显示「实际打哪」,
1879
+ // 否则用户看到 404 完全猜不到是路径选错了。
1880
+ detectedEndpoint: client.endpointMemory.get(provider.baseUrl)?.endpoint ?? "",
1881
+ detectedEndpointStyle: client.endpointMemory.get(provider.baseUrl)?.style ?? "",
1756
1882
  customModel: cfg.customModel ?? "",
1757
1883
  customHeaders: cfg.customHeaders ?? "",
1758
1884
  customApiKeyEnv: cfg.customApiKeyEnv ?? "",
@@ -1779,9 +1905,8 @@ export function apply(ctx, config) {
1779
1905
  keyEnvName: provider.apiKeyEnv,
1780
1906
  configPath: ownConfigPath,
1781
1907
  stats: client.stats,
1782
- // 决策点与自身开销:配置页靠这三项显示「哪个点在花多少钱」。
1908
+ // 决策点与缓存:配置页靠这两项显示「哪个点在花多少钱」。
1783
1909
  points: effectivePointModes(cfg),
1784
- budget: client.budget.report,
1785
1910
  cache: client.cache.report,
1786
1911
  };
1787
1912
  };
@@ -1804,27 +1929,27 @@ export function apply(ctx, config) {
1804
1929
  try {
1805
1930
  const body = JSON.parse(await request.text());
1806
1931
  // 只允许改这些键,避免前端误写任意字段。
1932
+ //
1933
+ // 各 provider 自己的三格(模型 / 显式 key / 密钥变量名)由预设表生成,
1934
+ // 不逐个写死:漏掉一个 provider,界面上「粘贴 key / 选模型」会 200
1935
+ // 成功但什么都没写——正是最难自查的「保存了没生效」。
1807
1936
  const allowed = [
1808
1937
  "enabled",
1809
1938
  "mode",
1810
1939
  "apiKey",
1811
1940
  "baseUrl",
1812
- "model",
1813
- "systemoneModel",
1814
1941
  "thinkMode",
1815
1942
  "timeoutMs",
1816
1943
  "provider",
1817
1944
  "customBaseUrl",
1818
1945
  "customEndpointStyle",
1819
- "customApiKey",
1820
- "customApiKeyEnv",
1821
- "customModel",
1822
1946
  "customHeaders",
1823
- "bamboonodeApiKeyEnv",
1824
- "systemoneApiKeyEnv",
1825
- "systemoneApiKey",
1826
- "bamboonodeApiKey",
1827
1947
  "keyStorage",
1948
+ ...Object.values(PROVIDER_PRESETS).flatMap((preset) => [
1949
+ preset.modelField,
1950
+ preset.keyField,
1951
+ preset.keyEnvField,
1952
+ ]),
1828
1953
  ];
1829
1954
  const patch = {};
1830
1955
  for (const key of allowed) {
@@ -1877,7 +2002,7 @@ export function apply(ctx, config) {
1877
2002
  * 决策点总览与切换。
1878
2003
  *
1879
2004
  * GET 返回注册表 + 当前模式 + 分账实测数据。
1880
- * PUT 改模式与自身预算。
2005
+ * PUT 改模式。
1881
2006
  *
1882
2007
  * 合成一个注册项按 method 分支,而不是同路径注册两次:
1883
2008
  * DSH 的 fetch 注册表以 path 为键,重复注册会互相覆盖(实测踩过)。
@@ -1921,7 +2046,6 @@ export function apply(ctx, config) {
1921
2046
  };
1922
2047
  }),
1923
2048
  modes: POINT_MODES,
1924
- budget: client.budget.report,
1925
2049
  cache: client.cache.report,
1926
2050
  };
1927
2051
  };
@@ -1961,9 +2085,6 @@ export function apply(ctx, config) {
1961
2085
  patch[point.key] = mode;
1962
2086
  }
1963
2087
  }
1964
- if (Number.isFinite(body.budgetTokens)) {
1965
- patch.budgetTokens = Math.max(0, Math.floor(body.budgetTokens));
1966
- }
1967
2088
  if (Object.keys(patch).length === 0) return json({ error: "no updatable fields" }, 400);
1968
2089
  await persistConfig(patch);
1969
2090
  // 立即回一份新的总览,前端不必再发一次 GET。
@@ -2013,7 +2134,7 @@ export function apply(ctx, config) {
2013
2134
  const response = await fetch(`${origin}/v1/models`, {
2014
2135
  headers: buildHeaders(provider, key),
2015
2136
  // 列表接口比决策接口慢(实测数秒),且只在用户打开配置页时调用一次,
2016
- // 用独立预算,不占用决策路径的 timeoutMs。
2137
+ // 用独立超时,不占用决策路径的 timeoutMs。
2017
2138
  signal: AbortSignal.timeout(20000),
2018
2139
  });
2019
2140
  if (!response.ok) {
@@ -2031,10 +2152,21 @@ export function apply(ctx, config) {
2031
2152
  // 放进下拉会让用户选了就失败,因此排除。
2032
2153
  const decisionTypes = ["StartLux Decision", "StartLux SystemOne"];
2033
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 ?? [];
2034
2161
  const models = [];
2035
2162
  for (const row of rows) {
2036
2163
  const id = typeof row?.id === "string" ? row.id : "";
2037
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
+ }
2038
2170
  const types = Array.isArray(row?.supported_endpoint_types) ? row.supported_endpoint_types : [];
2039
2171
  const isDecision = types.some((type) =>
2040
2172
  decisionTypes.some((needle) => String(type).toLowerCase() === needle.toLowerCase()),
@@ -2053,7 +2185,7 @@ export function apply(ctx, config) {
2053
2185
  current: provider.model,
2054
2186
  defaultHint: provider.defaultModel || DEFAULT_MODEL,
2055
2187
  provider: provider.id,
2056
- filtered: typedOnly,
2188
+ filtered: typedOnly || knownModels.length > 0,
2057
2189
  });
2058
2190
  } catch (error) {
2059
2191
  return json({
@@ -2131,6 +2263,24 @@ function stripUndefined(source) {
2131
2263
  return out;
2132
2264
  }
2133
2265
 
2266
+ /**
2267
+ * 从完整端点里取出它的路径风格(`decisions` / `systemone`)。
2268
+ *
2269
+ * 端点一定来自 buildEndpoint,理论上必然是合法 URL;但这里仍然做保护——
2270
+ * 它在**请求失败后的降级路径**上被调用,一个解析异常不该把整次决策变成
2271
+ * 未捕获异常(那会让守卫从「降级放行」变成「抛错」,行为倒退)。
2272
+ *
2273
+ * @param endpoint - 完整端点。
2274
+ * @returns 路径风格;无法识别时返回 undefined。
2275
+ */
2276
+ function styleOfEndpoint(endpoint) {
2277
+ try {
2278
+ return endpointStyleOfPath(new URL(endpoint).pathname);
2279
+ } catch {
2280
+ return undefined;
2281
+ }
2282
+ }
2283
+
2134
2284
  /**
2135
2285
  * 供测试与运维脚本使用的导出。
2136
2286
  *