dsh-decision-engine 0.6.0 → 0.6.1

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/README.md CHANGED
@@ -159,7 +159,7 @@ content-type: application/json
159
159
  | 字段 | 必填 | 说明 |
160
160
  |---|---|---|
161
161
  | `Base URL` | **是** | 必须是 `http://` 或 `https://` 地址。不支持其它协议(`ftp:` 等会被拒绝),不带 scheme 也会被拒绝——自定义地址必须显式,避免猜错协议。不能含空格、换行或查询串。 |
162
- | `端点风格` | 否 | `auto`(默认)/ `decisions` / `systemone`。`auto` 在地址已带 `/decisions` 或 `/systemone` 时原样使用。 |
162
+ | `端点风格` | 否 | `auto`(默认)/ `decisions` / `systemone`。这是**首选**路径,不是唯一路径:首选返回 404 时插件会自动改用另一条再试一次(见下)。 |
163
163
  | `附加请求头` | 否 | 格式 `名称: 值`,多项用分号分隔,如 `X-Tenant: team-a; X-Trace: dsh`。`authorization` / `content-type` / `host` 由插件固定,写了也会被忽略。 |
164
164
  | `密钥变量名` | 否 | 填了就从该环境变量或 `~/.dsh/.credentials.yaml` 的 `refs:` 段取 key;也可以直接在 API Key 输入框粘贴。 |
165
165
  | `模型` | 否 | 留空 = 由服务端选默认;实测留空比发空串安全(发 `"model": ""` 会被 400)。 |
@@ -177,6 +177,31 @@ content-type: application/json
177
177
 
178
178
  拼接是幂等的:对结果再拼一次不会变成 `/v1/v1/decisions` 或 `//decisions`。
179
179
 
180
+ ### 端点路径由服务商决定,不由模型决定
181
+
182
+ 这是本插件最容易踩空的一处,因此**实测**过(2026-10-25,真实 key 打真实端点):
183
+
184
+ | 服务商 | 模型 | `/v1/decisions` | `/v1/systemone` |
185
+ |---|---|---|---|
186
+ | SiliconFlow | `Kev-4B` | **404 Not Found** | **200 ✅** |
187
+ | bamboonode | `startlux-decision-27b-autothink` | 200 ✅ | 200 ✅ |
188
+
189
+ 也就是说,`Kev-4B` 并不是「只能用 `/systemone` 的模型」——**路径由服务商决定**。
190
+ 同一个 `Kev-4B` 换一个服务商,路径就得跟着换。
191
+
192
+ **因此 `端点风格` 只是「首选」**:
193
+
194
+ 1. 先打首选路径;
195
+ 2. 收到 **404**(= 路径不存在)时,自动改用另一条再试一次;
196
+ 3. 试通的那条会被**记住**(按 base URL 区分),下次直接打它,不再多一次往返;
197
+ 4. 两条都 404 时,遥测与「测试端点」会**列出试过的两个地址**并提示改端点风格。
198
+
199
+ 只有 404 会触发换路径:400(模型/内容不合法)、401(key 不对)、5xx 都与路径无关,
200
+ 换路径只是多烧一次请求。配置页的「生效端点」下方会显示「已验证可用:…」。
201
+
202
+ > 旧版本没有回退:路径踩空就是 404,而守卫是 fail-open 的——**静默失效**。
203
+ > 现场证据:`telemetry.jsonl` 里 2026-10-25 13:16–13:41 有 8 次 `http-404` 降级。
204
+
180
205
  ### 持久化与切换行为
181
206
 
182
207
  - 所有 provider 的配置**并列**存在 `config.yml` 里,互不覆盖:
@@ -71,10 +71,17 @@ systemoneModel: ""
71
71
  # 允许省略 scheme,custom 不允许——自定义地址必须显式,避免猜错协议)。
72
72
  # customBaseUrl: https://api.example.com/v1
73
73
  #
74
- # 端点风格:
75
- # auto 地址已带 /decisions 或 /systemone 就原样用,否则按 decisions 拼
76
- # decisions POST <base>/decisions
77
- # systemone POST <base>/systemone
74
+ # 端点风格(**首选**路径,不是唯一路径):
75
+ # auto 地址已带 /decisions 或 /systemone 就原样用,否则先试 decisions
76
+ # decisions 先试 POST <base>/decisions
77
+ # systemone 先试 POST <base>/systemone
78
+ #
79
+ # 路径后缀由**服务商**决定,不由模型决定。实测(2026-10-25,真实端点):
80
+ # SiliconFlow 的 Kev-4B /v1/decisions → 404,/v1/systemone → 200
81
+ # bamboonode 的决策模型 两条路径都 200
82
+ #
83
+ # 所以首选路径返回 404 时,插件会自动改用另一条再试一次,并记住试通的那条。
84
+ # 只有 404 会触发换路径:400(模型/内容不合法)与 401(key 不对)都跟路径无关。
78
85
  customEndpointStyle: auto
79
86
  #
80
87
  # custom 自己的模型(留空 = 服务端默认)。与内置预设的 model 分开存,
package/lib/client.js CHANGED
@@ -521,6 +521,8 @@ window.__ModuleLoader__.load({
521
521
  providerError: "",
522
522
  providerCatalog: [],
523
523
  baseUrl: "",
524
+ // 探测出的可用路径:status 失败时留空(界面不显示那一行)。
525
+ detectedEndpoint: "",
524
526
  model: "",
525
527
  timeoutMs: 20000,
526
528
  keyConfigured: false,
@@ -924,11 +926,24 @@ window.__ModuleLoader__.load({
924
926
  ? "由上方 base URL + 端点风格拼接;已带 /decisions 或 /systemone 时原样使用"
925
927
  : "内置预设的默认地址(可用 config.yml 的 baseUrl 覆盖 bamboonode)",
926
928
  h(
927
- "span",
928
- { style: { ...S.v, fontSize: "11px", opacity: 0.75, maxWidth: "300px" } },
929
- view.providerValid === false
930
- ? "配置不可用"
931
- : view.baseUrl || "(服务端未返回)",
929
+ "div",
930
+ { style: { display: "flex", flexDirection: "column", gap: "3px", maxWidth: "320px" } },
931
+ h(
932
+ "span",
933
+ { style: { ...S.v, fontSize: "11px", opacity: 0.75, wordBreak: "break-all" } },
934
+ view.providerValid === false
935
+ ? "配置不可用"
936
+ : view.baseUrl || "(服务端未返回)",
937
+ ),
938
+ // 探测出的实际可用路径:路径后缀由服务商决定(实测 SiliconFlow
939
+ // 只通 /systemone),必须让用户看见「最后打的是哪条」。
940
+ typeof view.detectedEndpoint === "string" && view.detectedEndpoint.length > 0
941
+ ? h(
942
+ "span",
943
+ { style: { ...S.v, fontSize: "11px", opacity: 0.85, color: "#7dd3a0" } },
944
+ `已验证可用:${view.detectedEndpoint}`,
945
+ )
946
+ : null,
932
947
  ),
933
948
  "endpoint",
934
949
  ),
@@ -1126,7 +1141,8 @@ window.__ModuleLoader__.load({
1126
1141
  ),
1127
1142
  row(
1128
1143
  "端点风格",
1129
- "auto:地址已带 /decisions 或 /systemone 就原样用,否则按 /decisions 拼;也可强制指定",
1144
+ "路径后缀由服务商决定(实测 SiliconFlow 只通 /systemone,bamboonode 两条都通)。" +
1145
+ "这里是「首选」:若首选路径返回 404,插件会自动改用另一条再试一次",
1130
1146
  h(
1131
1147
  "select",
1132
1148
  {
@@ -1139,7 +1155,7 @@ window.__ModuleLoader__.load({
1139
1155
  scheduleSave({ providerDraft: next }, true);
1140
1156
  },
1141
1157
  },
1142
- h("option", { value: "auto" }, "auto(自动)"),
1158
+ h("option", { value: "auto" }, "auto(先 /decisions)"),
1143
1159
  h("option", { value: "decisions" }, "decisions"),
1144
1160
  h("option", { value: "systemone" }, "systemone"),
1145
1161
  ),
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 端点回退:404 表示「这条路径不存在」,换另一条再试。
3
+ *
4
+ * 为什么需要它(2026-10-25 真实端点实测):
5
+ *
6
+ * 路径后缀(`/decisions` 还是 `/systemone`)由**服务商**决定,**不由模型决定**:
7
+ *
8
+ * SiliconFlow Kev-4B /v1/decisions → 404 /v1/systemone → 200 ✅
9
+ * bamboonode startlux-decision-27b-… 两者都 200
10
+ *
11
+ * 旧实现只有一条路径:`customEndpointStyle: auto` 的真实含义是「按
12
+ * decisions 拼」。配到只认 `/systemone` 的服务上就得到 404,而守卫是
13
+ * fail-open 的——于是它**静默地什么也没做**(现场证据:telemetry.jsonl
14
+ * 里 2026-10-25 13:16–13:41 有 8 次 `http-404` 降级)。
15
+ *
16
+ * 404 与 400 必须区分:实测 404 = 路径不存在(换路径有用),400 = 请求
17
+ * 内容或模型不合法(换路径没用)。因此**只有 404 触发回退**。
18
+ *
19
+ * 本模块**零依赖**:只做「依次尝试候选端点」这一件事,因此可以在裸 Node
20
+ * 下直接单测(见 test/endpoint-fallback.test.js)。
21
+ *
22
+ * @module dsh-decision-engine/endpoint-fallback
23
+ */
24
+
25
+ /**
26
+ * 「路径不存在」的状态码。
27
+ *
28
+ * 实测两类网关一致:SiliconFlow 与 bamboonode 对不存在的路径都回 404
29
+ * (SiliconFlow 甚至回纯文本 `Not Found` 而非 JSON 错误体),而路径存在
30
+ * 但内容/模型不合法时回 400。
31
+ */
32
+ export const PATH_MISS_STATUS = 404;
33
+
34
+ /**
35
+ * 依次尝试候选端点,直到出现一个「不是路径不存在」的响应。
36
+ *
37
+ * 只有 404 会触发换下一条:其它状态码(400 模型/内容不合法、401 key 不对、
38
+ * 5xx 服务端故障)都与路径无关,换路径只会多烧一次请求。
39
+ *
40
+ * 网络异常(fetch 抛错)**直接上抛**,不换路径重试:DNS/连接失败对所有候选
41
+ * 是一样的,重试只是把一次失败变成两次。调用方据此降级。
42
+ *
43
+ * @param candidates - 候选端点,按优先级排列。
44
+ * @param send - `(endpoint) => Promise<Response>`:发一次请求。
45
+ * @returns `{ endpoint, response, attempts }`:
46
+ * - `endpoint`:实际使用的端点(最后一次尝试的)。
47
+ * - `response`:该端点的响应(候选为空时为 undefined)。
48
+ * - `attempts`:每次尝试的 `{ endpoint, status }`,供遥测解释「换过路径」。
49
+ */
50
+ export async function sendWithEndpointFallback(candidates, send) {
51
+ const list = (Array.isArray(candidates) ? candidates : []).filter(
52
+ (item) => typeof item === "string" && item.length > 0,
53
+ );
54
+ const attempts = [];
55
+ if (list.length === 0) return { endpoint: "", response: undefined, attempts };
56
+ for (let index = 0; index < list.length; index += 1) {
57
+ const endpoint = list[index];
58
+ // 网络异常在此上抛:调用方按「network-error / timeout」降级。
59
+ const response = await send(endpoint);
60
+ const status = typeof response?.status === "number" ? response.status : 0;
61
+ attempts.push({ endpoint, status });
62
+ const hasNext = index + 1 < list.length;
63
+ if (status === PATH_MISS_STATUS && hasNext) {
64
+ // 换下一条前先丢弃这次的响应体:不消费它会让连接被挂住,
65
+ // 高频决策下会逐渐耗尽连接池。
66
+ try {
67
+ await response?.body?.cancel?.();
68
+ } catch {
69
+ /* 取消失败无所谓:响应体最终会被 GC */
70
+ }
71
+ continue;
72
+ }
73
+ return { endpoint, response, attempts };
74
+ }
75
+ // 理论上到不了这里(最后一轮必定 return),但保持返回结构完整。
76
+ return { endpoint: list[list.length - 1], response: undefined, attempts };
77
+ }
78
+
79
+ /**
80
+ * 记住「哪条路径真的能用」。
81
+ *
82
+ * 为什么要记:auto 风格下每次都先打一条错路径、再打对路径,等于每次请求
83
+ * 多一次往返(决策热路径上这是实打实的延迟)。记住之后,下次直接打对的那条。
84
+ *
85
+ * 作用域限**单个 base URL**:换了地址就重新探测——否则「A 服务商的结论」
86
+ * 会被套用到 B 服务商上,那正是本插件最忌讳的「配置漂移」。
87
+ */
88
+ export class EndpointMemory {
89
+ constructor() {
90
+ /** @type {Map<string, { endpoint: string, style: string }>} */
91
+ this.entries = new Map();
92
+ }
93
+
94
+ /**
95
+ * 记下某条路径可用。
96
+ *
97
+ * @param baseUrl - base URL(探测的适用范围)。
98
+ * @param endpoint - 实际可用的端点。
99
+ * @param style - 该端点的路径风格(`decisions` / `systemone`)。
100
+ */
101
+ record(baseUrl, endpoint, style) {
102
+ if (typeof baseUrl !== "string" || baseUrl.length === 0) return;
103
+ if (typeof endpoint !== "string" || endpoint.length === 0) return;
104
+ this.entries.set(baseUrl, { endpoint, style });
105
+ }
106
+
107
+ /**
108
+ * 取某个 base URL 记住的端点。
109
+ *
110
+ * @param baseUrl - base URL。
111
+ * @returns `{ endpoint, style }` 或 undefined。
112
+ */
113
+ get(baseUrl) {
114
+ if (typeof baseUrl !== "string" || baseUrl.length === 0) return undefined;
115
+ return this.entries.get(baseUrl);
116
+ }
117
+
118
+ /**
119
+ * 把记住的端点提到候选列表最前面。
120
+ *
121
+ * 只在它**确实还在候选里**时才提升:候选由配置决定,配置改了(比如换端点
122
+ * 风格)就说明用户有了新决定,此时旧的探测结果不该再插队。
123
+ *
124
+ * @param baseUrl - base URL。
125
+ * @param candidates - 候选端点列表。
126
+ * @returns 调整顺序后的新数组(不改原数组)。
127
+ */
128
+ order(baseUrl, candidates) {
129
+ const list = [...(Array.isArray(candidates) ? candidates : [])];
130
+ const remembered = this.get(baseUrl);
131
+ if (remembered === undefined) return list;
132
+ const index = list.indexOf(remembered.endpoint);
133
+ if (index <= 0) return list;
134
+ list.splice(index, 1);
135
+ list.unshift(remembered.endpoint);
136
+ return list;
137
+ }
138
+
139
+ /** 清空记忆(配置变了、或探测结果不再适用时)。 */
140
+ clear() {
141
+ this.entries.clear();
142
+ }
143
+ }
package/lib/index.js CHANGED
@@ -37,6 +37,7 @@ import { mountRoutePoint } from "./adapters/route.js";
37
37
  import { mountAgentPoints } from "./adapters/agent.js";
38
38
  import { mountCotTranslatePoint } from "./adapters/translate.js";
39
39
  import { applyConfigUpdates, parseConfigText } from "./config-file.js";
40
+ import { EndpointMemory, sendWithEndpointFallback } from "./endpoint-fallback.js";
40
41
  import { resolveDshHome } from "./home-paths.js";
41
42
  import { applyRefUpdates, listRefNames, pickRefValue } from "./credentials.js";
42
43
  import {
@@ -49,6 +50,7 @@ import {
49
50
  buildEndpoint,
50
51
  buildHeaders,
51
52
  buildRequestBody,
53
+ endpointStyleOfPath,
52
54
  normalizeBaseUrl,
53
55
  normalizeThinkMode,
54
56
  parseCustomHeaders,
@@ -950,6 +952,14 @@ class StartluxClient {
950
952
  this.stats = { requests: 0, cached: 0, degraded: 0, ok: 0, lastStatus: undefined };
951
953
  /** 按决策点分账:不分开统计就无法判断哪个点值得留。 */
952
954
  this.byPoint = new Map();
955
+ /**
956
+ * 记住「哪条路径真的能用」。
957
+ *
958
+ * 路径后缀由服务商决定(实测 SiliconFlow 只通 /systemone),而守卫是
959
+ * fail-open 的——路径踩空等于静默失效。404 时换另一条再试,并记住胜出的
960
+ * 那条,下次直接打它,省掉热路径上多余的一次往返。
961
+ */
962
+ this.endpointMemory = new EndpointMemory();
953
963
  }
954
964
 
955
965
  /** 取(或建)某个决策点的计数器。 */
@@ -1061,31 +1071,51 @@ class StartluxClient {
1061
1071
 
1062
1072
  this.stats.requests += 1;
1063
1073
  counter.requests += 1;
1074
+ // 请求体只构造一次:换路径重试时内容必须完全相同,否则第二次请求
1075
+ // 与第一次不可比(缓存键也随之失配)。
1076
+ const bodyText = JSON.stringify(
1077
+ buildRequestBody(provider, {
1078
+ state: call.state,
1079
+ questions: call.questions,
1080
+ thinkMode: cfg.thinkMode,
1081
+ }),
1082
+ );
1083
+ // 候选端点:主路径优先,404 时换另一条(实测 SiliconFlow 只通
1084
+ // /systemone、bamboonode 两条都通)。记忆里验证过的路径会被提到最前,
1085
+ // 因此只有第一次探测会多一次往返。
1086
+ const candidates = this.endpointMemory.order(provider.baseUrl, provider.candidates ?? [endpoint]);
1064
1087
  let response;
1088
+ let usedEndpoint = endpoint;
1089
+ let attempts = [];
1065
1090
  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,
1091
+ const result = await sendWithEndpointFallback(candidates, async (target) => {
1092
+ usedEndpoint = target;
1093
+ return this.fetchFn(target, {
1094
+ method: "POST",
1095
+ // 鉴权与请求头同样由 provider 构造:custom 可以带额外头,
1096
+ // 但 authorization / content-type 永远由本插件决定。
1097
+ headers: buildHeaders(provider, key),
1098
+ body: bodyText,
1099
+ signal,
1100
+ });
1079
1101
  });
1102
+ response = result.response;
1103
+ usedEndpoint = result.endpoint;
1104
+ attempts = result.attempts;
1105
+ // 探测到可用路径:记住它,下次直接打(省掉一次多余往返)。
1106
+ if (response?.ok === true) {
1107
+ const style = styleOfEndpoint(usedEndpoint);
1108
+ if (style !== undefined) this.endpointMemory.record(provider.baseUrl, usedEndpoint, style);
1109
+ }
1080
1110
  } catch (error) {
1081
1111
  if (call.signal?.aborted) return degrade("caller-aborted");
1082
1112
  if (timer.aborted || error?.name === "AbortError") {
1083
1113
  this.noteFailure();
1084
- return degrade("timeout", { budgetMs: budget, endpoint });
1114
+ return degrade("timeout", { budgetMs: budget, endpoint: usedEndpoint });
1085
1115
  }
1086
1116
  this.noteFailure();
1087
1117
  return degrade("network-error", {
1088
- endpoint,
1118
+ endpoint: usedEndpoint,
1089
1119
  message: error instanceof Error ? error.message : String(error),
1090
1120
  });
1091
1121
  }
@@ -1106,14 +1136,31 @@ class StartluxClient {
1106
1136
  if (response.status === 401 || response.status === 403) {
1107
1137
  return degrade("auth-failed", {
1108
1138
  provider: provider.id,
1109
- endpoint,
1139
+ endpoint: usedEndpoint,
1110
1140
  status: response.status,
1111
1141
  keyEnv: provider.apiKeyEnv,
1112
1142
  body: bodyText,
1113
1143
  hint: `鉴权被拒(HTTP ${response.status}):检查 ${provider.id} 的 API key 是否有效、是否与地址匹配`,
1114
1144
  });
1115
1145
  }
1116
- return degrade(`http-${response.status}`, { provider: provider.id, endpoint, model, body: bodyText });
1146
+ // 404 且两条路径都试过:路径风格不对是最常见的原因,必须说清楚
1147
+ // 试过哪两条——否则用户只看到「HTTP 404」,完全猜不到是路径选错。
1148
+ const tried = attempts.map((item) => item.endpoint);
1149
+ const allMissing = response.status === 404 && attempts.length > 1;
1150
+ return degrade(`http-${response.status}`, {
1151
+ provider: provider.id,
1152
+ endpoint: usedEndpoint,
1153
+ model,
1154
+ body: bodyText,
1155
+ ...(tried.length > 1 ? { tried } : {}),
1156
+ ...(allMissing
1157
+ ? {
1158
+ hint:
1159
+ `路径不存在(HTTP 404):已试过 ${tried.join(" 与 ")}。` +
1160
+ `若该服务用的是另一条路径,请在配置页把「端点风格」改成对应的那条。`,
1161
+ }
1162
+ : {}),
1163
+ });
1117
1164
  }
1118
1165
 
1119
1166
  let body;
@@ -1121,15 +1168,17 @@ class StartluxClient {
1121
1168
  body = await response.json();
1122
1169
  } catch {
1123
1170
  this.noteFailure();
1124
- return degrade("invalid-json", { endpoint });
1171
+ return degrade("invalid-json", { endpoint: usedEndpoint });
1125
1172
  }
1126
1173
  if (body === null || typeof body !== "object") {
1127
1174
  this.noteFailure();
1128
- return degrade("invalid-response", { endpoint });
1175
+ return degrade("invalid-response", { endpoint: usedEndpoint });
1129
1176
  }
1130
1177
 
1131
1178
  const answers = body.answers;
1132
- if (answers === null || typeof answers !== "object") return degrade("missing-answers", { endpoint });
1179
+ if (answers === null || typeof answers !== "object") {
1180
+ return degrade("missing-answers", { endpoint: usedEndpoint });
1181
+ }
1133
1182
 
1134
1183
  const usage = typeof body.usage === "object" && body.usage !== null ? body.usage : {};
1135
1184
  const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : undefined;
@@ -1598,37 +1647,56 @@ export function apply(ctx, config) {
1598
1647
  }
1599
1648
  const started = Date.now();
1600
1649
  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
- });
1650
+ // 探测与实际决策走**同一套**候选顺序与 404 回退:否则会出现
1651
+ // 「测试通过但守卫不工作」或反之。实测 SiliconFlow 只通 /systemone,
1652
+ // 而 /v1/decisions 回 404——只试一条路径必然误报。
1653
+ const bodyText = JSON.stringify(
1654
+ buildRequestBody(provider, {
1655
+ state: { probe: true, cwd: process.cwd() },
1656
+ questions: { alive: { type: "noul", instructions: "Is this request well formed?" } },
1657
+ thinkMode: cfg.thinkMode,
1658
+ }),
1659
+ );
1660
+ const candidates = client.endpointMemory.order(provider.baseUrl, provider.candidates ?? [endpoint]);
1661
+ const result = await sendWithEndpointFallback(candidates, (target) =>
1662
+ fetch(target, {
1663
+ method: "POST",
1664
+ headers: buildHeaders(provider, key),
1665
+ // 与 classify 保持一致:同一个 buildRequestBody,探测不可能与实际
1666
+ // 请求形状不同步(这正是「测试通过但守卫不工作」的经典来源)。
1667
+ body: bodyText,
1668
+ signal: AbortSignal.timeout(Math.max(5000, cfg.timeoutMs)),
1669
+ }),
1670
+ );
1671
+ const response = result.response;
1615
1672
  const elapsed = Date.now() - started;
1616
- const requestId = response.headers.get("x-oneapi-request-id") ?? "(无)";
1617
- let bodyText = "";
1673
+ const requestId = response?.headers?.get?.("x-oneapi-request-id") ?? "(无)";
1674
+ let bodyTextOut = "";
1618
1675
  try {
1619
- bodyText = (await response.text()).slice(0, 300);
1676
+ bodyTextOut = (await response.text()).slice(0, 300);
1620
1677
  } catch {
1621
1678
  /* 忽略 */
1622
1679
  }
1680
+ // 探测成功即记住胜出的路径:下次决策直接打它。
1681
+ if (response?.ok === true) {
1682
+ const style = styleOfEndpoint(result.endpoint);
1683
+ if (style !== undefined) client.endpointMemory.record(provider.baseUrl, result.endpoint, style);
1684
+ }
1685
+ // 换过路径就要说出来:用户需要知道「实际打的是哪条」。
1686
+ const triedText =
1687
+ result.attempts.length > 1
1688
+ ? `尝试过的路径: ${result.attempts.map((item) => `${item.endpoint} → ${item.status}`).join(",")}\n`
1689
+ : "";
1623
1690
  return {
1624
- ok: response.ok,
1691
+ ok: response?.ok === true,
1625
1692
  text:
1626
1693
  `provider: ${provider.label}(${provider.id})\n` +
1627
- `POST ${endpoint}\n` +
1694
+ `POST ${result.endpoint}\n` +
1628
1695
  `model: ${(provider.model ?? "").trim().length > 0 ? provider.model : "自动(服务端默认)"}\n` +
1629
- `HTTP ${response.status} 耗时 ${elapsed}ms\n` +
1696
+ `HTTP ${response?.status ?? "(无响应)"} 耗时 ${elapsed}ms\n` +
1630
1697
  `x-oneapi-request-id: ${requestId}\n` +
1631
- `响应: ${bodyText}`,
1698
+ (triedText.length > 0 ? `${triedText}` : "") +
1699
+ `响应: ${bodyTextOut}`,
1632
1700
  };
1633
1701
  } catch (error) {
1634
1702
  return {
@@ -1753,6 +1821,11 @@ export function apply(ctx, config) {
1753
1821
  // custom 的字段原样回显(除 key 明文):用户保存后再打开必须看到自己填的值。
1754
1822
  customBaseUrl: cfg.customBaseUrl ?? "",
1755
1823
  customEndpointStyle: cfg.customEndpointStyle ?? "auto",
1824
+ // 实际用得通的那条路径(探测出来的)。路径后缀由服务商决定,
1825
+ // 实测 SiliconFlow 只通 /systemone;界面必须显示「实际打哪」,
1826
+ // 否则用户看到 404 完全猜不到是路径选错了。
1827
+ detectedEndpoint: client.endpointMemory.get(provider.baseUrl)?.endpoint ?? "",
1828
+ detectedEndpointStyle: client.endpointMemory.get(provider.baseUrl)?.style ?? "",
1756
1829
  customModel: cfg.customModel ?? "",
1757
1830
  customHeaders: cfg.customHeaders ?? "",
1758
1831
  customApiKeyEnv: cfg.customApiKeyEnv ?? "",
@@ -2131,6 +2204,24 @@ function stripUndefined(source) {
2131
2204
  return out;
2132
2205
  }
2133
2206
 
2207
+ /**
2208
+ * 从完整端点里取出它的路径风格(`decisions` / `systemone`)。
2209
+ *
2210
+ * 端点一定来自 buildEndpoint,理论上必然是合法 URL;但这里仍然做保护——
2211
+ * 它在**请求失败后的降级路径**上被调用,一个解析异常不该把整次决策变成
2212
+ * 未捕获异常(那会让守卫从「降级放行」变成「抛错」,行为倒退)。
2213
+ *
2214
+ * @param endpoint - 完整端点。
2215
+ * @returns 路径风格;无法识别时返回 undefined。
2216
+ */
2217
+ function styleOfEndpoint(endpoint) {
2218
+ try {
2219
+ return endpointStyleOfPath(new URL(endpoint).pathname);
2220
+ } catch {
2221
+ return undefined;
2222
+ }
2223
+ }
2224
+
2134
2225
  /**
2135
2226
  * 供测试与运维脚本使用的导出。
2136
2227
  *
package/lib/providers.js CHANGED
@@ -44,15 +44,26 @@ export const DEFAULT_PROVIDER = "bamboonode";
44
44
  /**
45
45
  * 自定义 base URL 的端点风格。
46
46
  *
47
- * auto 已指向 /decisions 或 /systemone 就原样用,否则按 decisions 补
47
+ * auto 已指向 /decisions 或 /systemone 就原样用,否则先试 decisions
48
48
  * decisions POST <base>/decisions
49
49
  * systemone POST <base>/systemone
50
+ *
51
+ * 注意:这是**首选**路径,不是唯一路径。路径后缀由服务商决定(实测
52
+ * SiliconFlow 的 Kev-4B 只通 /v1/systemone,bamboonode 两条都通),
53
+ * 因此 404 时会换另一条再试——见 endpointCandidates()。
50
54
  */
51
55
  export const ENDPOINT_STYLES = ["auto", "decisions", "systemone"];
52
56
 
53
57
  /** 端点风格 → 追加的路径段。 */
54
58
  const STYLE_SEGMENT = { decisions: "decisions", systemone: "systemone" };
55
59
 
60
+ /**
61
+ * 两条路径风格互为「另一条」。
62
+ *
63
+ * 用于 404 回退:路径不存在时换另一条再试一次。
64
+ */
65
+ const ALTERNATE_STYLE = { decisions: "systemone", systemone: "decisions" };
66
+
56
67
  /**
57
68
  * 合法的思考模式(与 startlux 服务的 `think_mode` 取值一致)。
58
69
  *
@@ -259,6 +270,61 @@ export function buildEndpoint(baseUrl, style = "decisions") {
259
270
  return url.toString();
260
271
  }
261
272
 
273
+ /**
274
+ * 列出「同一个 base URL 下所有值得一试的端点」,按优先级排列。
275
+ *
276
+ * 背景(2026-10-25 实测):路径后缀由**服务商**决定,不由模型决定——
277
+ * SiliconFlow 的 Kev-4B 只认 `/v1/systemone`(`/v1/decisions` 回 404),
278
+ * 而 bamboonode 的决策模型两条路径都能用。因此「只拼一条路径」在换服务商
279
+ * 时必然踩空,而守卫是 fail-open 的,踩空表现为**静默失效**。
280
+ *
281
+ * 候选规则:
282
+ *
283
+ * - 地址**已自带**端点段(`.../v1/decisions` 或 `.../v1/systemone`)→
284
+ * 只返回它自己。用户填了完整端点就是明确决定,不该被自动改写。
285
+ * - `auto` → 先试 `decisions`(与历史行为一致),再试 `systemone`。
286
+ * - 显式 `decisions` / `systemone` → 先试它自己,**再试另一条**。
287
+ *
288
+ * 最后一条是关键:显式指定只是「首选」,不是「唯一」。显式指定错路径时
289
+ * 同样会 404,届时仍应回退到另一条——否则「选错了就只能手动改」这种
290
+ * 死胡同会被用户当成插件的 bug。
291
+ *
292
+ * @param baseUrl - base URL。
293
+ * @param style - `decisions` / `systemone` / `auto`。
294
+ * @returns 候选端点数组(去重,优先级从高到低)。
295
+ */
296
+ export function endpointCandidates(baseUrl, style = "auto") {
297
+ const normalized = normalizeBaseUrl(baseUrl);
298
+ if (normalized.length === 0) return [];
299
+ // 用户填了完整端点:原样用,不追加任何候选。
300
+ let path = "";
301
+ try {
302
+ path = new URL(normalized).pathname.replace(/\/+$/, "");
303
+ } catch {
304
+ // 非法 URL:交给调用方去报错,这里不构造候选。
305
+ return [];
306
+ }
307
+ if (endpointStyleOfPath(path) !== undefined) {
308
+ return [buildEndpoint(normalized, style)];
309
+ }
310
+ const primary = style === "auto" ? "decisions" : style === "systemone" ? "systemone" : "decisions";
311
+ const secondary = ALTERNATE_STYLE[primary];
312
+ const out = [buildEndpoint(normalized, primary)];
313
+ const other = buildEndpoint(normalized, secondary);
314
+ if (!out.includes(other)) out.push(other);
315
+ return out;
316
+ }
317
+
318
+ /**
319
+ * 端点风格 → 它对应的路径段(用于回显「当前用的是哪条路径」)。
320
+ *
321
+ * @param style - 风格取值。
322
+ * @returns `decisions` / `systemone`;无法识别时返回 "decisions"。
323
+ */
324
+ export function styleSegment(style) {
325
+ return STYLE_SEGMENT[style] ?? "decisions";
326
+ }
327
+
262
328
  /**
263
329
  * 解析 `customHeaders` 配置。
264
330
  *
@@ -324,6 +390,8 @@ export function resolveProvider(cfg = {}) {
324
390
  source: "custom",
325
391
  baseUrl,
326
392
  endpoint: checked.ok ? buildEndpoint(baseUrl, style) : "",
393
+ // 候选端点(按优先级):404 时按顺序换下一条再试。
394
+ candidates: checked.ok ? endpointCandidates(baseUrl, style) : [],
327
395
  endpointStyle: style,
328
396
  model,
329
397
  defaultModel: "",
@@ -371,6 +439,9 @@ export function resolveProvider(cfg = {}) {
371
439
  source: legacyBase.length > 0 ? "legacy" : "preset",
372
440
  baseUrl: checked.ok ? checked.value : baseUrl,
373
441
  endpoint: checked.ok ? buildEndpoint(baseUrl, preset.endpointStyle) : "",
442
+ // 候选端点(按优先级):预设自己的路径优先,404 时换另一条再试。
443
+ // 实测 bamboonode 两条路径都通,SiliconFlow 只通 /systemone。
444
+ candidates: checked.ok ? endpointCandidates(baseUrl, preset.endpointStyle) : [],
374
445
  endpointStyle: preset.endpointStyle,
375
446
  model,
376
447
  defaultModel: preset.defaultModel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-engine",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
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
5
  "icon": "./icon.svg",
6
6
  "type": "module",