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.
@@ -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/engine.js CHANGED
@@ -2,18 +2,18 @@
2
2
  * 决策点执行引擎。
3
3
  *
4
4
  * 「工具授权」那一个点是手写的,因为它要处理 ask/deny 这类改变行为的返回。
5
- * 另外八个点如果各写一遍「查模式 → 判预算 → 发请求 → 校验答案 → 写遥测」,
5
+ * 另外八个点如果各写一遍「查模式 → 发请求 → 校验答案 → 写遥测」,
6
6
  * 八份重复代码里迟早有一份漏掉降级分支——而漏掉降级分支的后果是工具调用被卡住。
7
7
  *
8
8
  * 所以这里把那条链路收成一个 `ask()`:
9
9
  *
10
10
  * 模式 off → 不发请求,返回 { mode: 'off' }
11
- * 预算/冷却/no-key → client 已降级为 null,这里记一条 degraded 遥测
11
+ * 冷却/no-key → client 已降级为 null,这里记一条 degraded 遥测
12
12
  * 答案形状不符 → 记 answer-shape-mismatch,按「不改变行为」处理
13
13
  * 有裁决 → 记一条 action 遥测,返回 action 交给适配器执行
14
14
  *
15
15
  * 适配器只负责两件事:给出 state/questions(spec.build),以及把 action 变成
16
- * 真实的副作用。所有决策点共用同一套超时、预算、缓存与降级语义。
16
+ * 真实的副作用。所有决策点共用同一套超时、缓存与降级语义。
17
17
  *
18
18
  * @module dsh-decision-engine/engine
19
19
  */