ppxans-harness 2.4.0 → 3.2.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.
Files changed (186) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +218 -265
  3. package/bin/ppx-channels.js +2 -2
  4. package/bin/ppx-serve.js +5 -5
  5. package/bin/ppx-setup.js +124 -0
  6. package/bin/ppx-web.js +140 -0
  7. package/bin/ppx.js +2 -2
  8. package/config/identity.md +6 -6
  9. package/config/ishiki.md +16 -16
  10. package/config/ppx.json +15 -151
  11. package/config/ppx.json.example +143 -0
  12. package/package.json +17 -10
  13. package/skills/.usage.json +6 -0
  14. package/skills/agent-professional-training/SKILL.md +94 -0
  15. package/skills/brainstorm/SKILL.md +24 -0
  16. package/skills/cupid-lover-comms/SKILL.md +37 -0
  17. package/skills/debug/SKILL.md +26 -0
  18. package/skills/plan/SKILL.md +25 -0
  19. package/skills/ponytail/SKILL.md +25 -0
  20. package/skills/ppx-memory/SKILL.md +91 -0
  21. package/skills/ppx-memory/scripts/cli.js +192 -0
  22. package/skills/ppx-memory/scripts/experience.js +133 -0
  23. package/skills/ppx-memory/scripts/fact-store.js +842 -0
  24. package/skills/ppx-memory/scripts/l0.js +52 -0
  25. package/skills/ppx-memory/scripts/l2.js +146 -0
  26. package/skills/ppx-memory/scripts/l3.js +112 -0
  27. package/skills/ppx-memory/scripts/memory-ticker.js +238 -0
  28. package/skills/ppx-memory/scripts/pii.js +42 -0
  29. package/skills/ppx-memory/scripts/schema.js +80 -0
  30. package/skills/ppx-memory/scripts/session.js +398 -0
  31. package/skills/ppx-memory/scripts/similarity.js +43 -0
  32. package/skills/ppx-memory/scripts/store.js +116 -0
  33. package/skills/ppx-memory/scripts/wal.js +38 -0
  34. package/skills/ppx-selfheal/SKILL.md +24 -0
  35. package/skills/ppx-selfheal/scripts/cli.js +80 -0
  36. package/skills/ppx-selfheal/scripts/healer.js +184 -0
  37. package/skills/ppx-selfheal/scripts/logger.js +17 -0
  38. package/skills/ppx-selfheal/scripts/store.js +116 -0
  39. package/skills/prompt-depth-kit/SKILL.md +28 -0
  40. package/skills/session-naming/SKILL.md +36 -0
  41. package/skills/verify/SKILL.md +25 -0
  42. package/src/agent/index.js +1340 -717
  43. package/src/agent/prompts.js +47 -3
  44. package/src/aml-server.js +197 -151
  45. package/src/ans/eviction.js +123 -143
  46. package/src/ans/guard.js +159 -120
  47. package/src/ans/lifecycle.js +96 -93
  48. package/src/ans/proactive.js +112 -129
  49. package/src/ans/reward.js +95 -111
  50. package/src/ans/values.js +15 -15
  51. package/src/audit/audit-chain.js +43 -7
  52. package/src/audit/verifier.js +157 -120
  53. package/src/bus/circuit-breaker.js +9 -1
  54. package/src/bus/runtime-bus.js +107 -93
  55. package/src/channels/base.js +57 -34
  56. package/src/channels/feishu.js +118 -126
  57. package/src/channels/http.js +1090 -592
  58. package/src/channels/index.js +111 -110
  59. package/src/channels/log.js +29 -29
  60. package/src/channels/wechat-crypto.js +73 -74
  61. package/src/channels/wechat.js +191 -197
  62. package/src/channels/workspace.js +94 -0
  63. package/src/channels-cli.js +126 -124
  64. package/src/cli.js +129 -120
  65. package/src/commands/index.js +142 -0
  66. package/src/config/channels.js +137 -170
  67. package/src/config/index.js +277 -224
  68. package/src/config/placeholder.js +31 -0
  69. package/src/config/providers.js +148 -188
  70. package/src/config/settings.js +156 -182
  71. package/src/core/policy.js +69 -18
  72. package/src/core/trace.js +8 -6
  73. package/src/edit/editblock.js +266 -0
  74. package/src/edit/snapshot.js +67 -0
  75. package/src/evidence/index.js +153 -0
  76. package/src/evolve/playbook.js +9 -10
  77. package/src/hooks/index.js +113 -0
  78. package/src/llm/client.js +188 -446
  79. package/src/llm/dsml.js +74 -74
  80. package/src/llm/embedder.js +41 -35
  81. package/src/llm/fence.js +52 -105
  82. package/src/llm/index.js +4 -4
  83. package/src/llm/local-embedder.js +94 -0
  84. package/src/llm/presets.js +113 -0
  85. package/src/llm/pricing.js +93 -0
  86. package/src/llm/retry.js +73 -73
  87. package/src/llm/router.js +92 -97
  88. package/src/mcp/admin.js +326 -0
  89. package/src/mcp/client.js +487 -375
  90. package/src/mcp/http.js +203 -0
  91. package/src/mcp/index.js +116 -116
  92. package/src/mcp/server.js +392 -0
  93. package/src/mcp/tasks.js +133 -0
  94. package/src/memory/asset-hub.js +6 -11
  95. package/src/memory/canvas.js +2 -5
  96. package/src/memory/compaction.js +28 -28
  97. package/src/memory/experience.js +133 -122
  98. package/src/memory/fact-store.js +914 -698
  99. package/src/memory/failure-episode.js +20 -11
  100. package/src/memory/fork.js +17 -8
  101. package/src/memory/index.js +8 -6
  102. package/src/memory/l0.js +53 -52
  103. package/src/memory/l2.js +145 -130
  104. package/src/memory/l3.js +111 -111
  105. package/src/memory/legion-board.js +71 -0
  106. package/src/memory/memory-ticker.js +239 -240
  107. package/src/memory/session.js +398 -397
  108. package/src/memory/sqlite-store.js +581 -0
  109. package/src/mode/blackboard.js +49 -49
  110. package/src/mode/graph.js +42 -41
  111. package/src/mode/index.js +64 -64
  112. package/src/mode/legion.js +54 -51
  113. package/src/mode/plan-exec.js +50 -50
  114. package/src/mode/router.js +28 -40
  115. package/src/orchestrator/agent-worker.js +69 -69
  116. package/src/orchestrator/dag.js +90 -83
  117. package/src/orchestrator/experts.js +76 -0
  118. package/src/orchestrator/index.js +1 -1
  119. package/src/orchestrator/legion.js +179 -187
  120. package/src/orchestrator/supervisor.js +6 -8
  121. package/src/permissions/index.js +378 -0
  122. package/src/persona/index.js +28 -29
  123. package/src/plugin/builtin.js +313 -212
  124. package/src/plugin/context.js +80 -79
  125. package/src/plugin/index.js +62 -62
  126. package/src/plugin/v3.js +73 -0
  127. package/src/protocol/index.js +148 -0
  128. package/src/repomap/index.js +309 -0
  129. package/src/review/index.js +393 -0
  130. package/src/seam/registry.js +3 -0
  131. package/src/seam/shell.js +55 -55
  132. package/src/security/injection.js +79 -0
  133. package/src/selfheal/evolve.js +67 -67
  134. package/src/selfheal/healer.js +184 -167
  135. package/src/selfheal/run.js +9 -9
  136. package/src/server.js +63 -60
  137. package/src/services/diagnose.js +180 -0
  138. package/src/services/learning-service.js +9 -0
  139. package/src/services/memory-health.js +34 -6
  140. package/src/services/memory-service.js +42 -9
  141. package/src/services/triage.js +138 -0
  142. package/src/session/parts.js +76 -0
  143. package/src/session/projection.js +73 -0
  144. package/src/session/rollout.js +54 -0
  145. package/src/session/turn.js +137 -0
  146. package/src/skills/lint.js +72 -0
  147. package/src/skills/loader.js +231 -150
  148. package/src/skills/search.js +58 -0
  149. package/src/skills/verify.js +95 -100
  150. package/src/tools/advanced.js +388 -352
  151. package/src/tools/builtin.js +384 -297
  152. package/src/tools/catalog.js +283 -159
  153. package/src/tools/command-guard.js +112 -112
  154. package/src/tools/custom.js +47 -47
  155. package/src/tools/delegate.js +383 -297
  156. package/src/tools/document.js +254 -253
  157. package/src/tools/git.js +151 -0
  158. package/src/tools/governance.js +47 -20
  159. package/src/tools/index.js +16 -11
  160. package/src/tools/methods.js +178 -178
  161. package/src/tools/ocr.js +59 -59
  162. package/src/tools/sandbox-worker.js +40 -0
  163. package/src/tools/sandbox.js +92 -0
  164. package/src/tools/seam.js +162 -125
  165. package/src/tools/selfmod.js +196 -176
  166. package/src/tools/v3.js +225 -0
  167. package/src/tools/vad.js +176 -0
  168. package/src/tools/voice.js +238 -0
  169. package/src/utils/async.js +14 -0
  170. package/src/utils/config-file.js +53 -0
  171. package/src/utils/crashguard.js +88 -0
  172. package/src/utils/http.js +53 -0
  173. package/src/utils/id.js +8 -0
  174. package/src/utils/json-state.js +33 -0
  175. package/src/utils/logger.js +17 -17
  176. package/src/utils/ndjson.js +25 -0
  177. package/src/utils/pii.js +42 -42
  178. package/src/utils/rate-limit.js +50 -0
  179. package/src/utils/schema.js +80 -0
  180. package/src/utils/similarity.js +43 -0
  181. package/src/utils/store.js +170 -108
  182. package/src/utils/text.js +15 -15
  183. package/src/utils/trace.js +153 -153
  184. package/src/utils/wal.js +39 -0
  185. package/src/utils/winutf8.js +16 -15
  186. package/src/wiki/index.js +170 -0
@@ -1,189 +1,149 @@
1
- // src/config/providers.js - LLM 提供方 CRUD (HTTP API 后端)
2
- // 设计:
3
- // - 唯一事实源 = config/ppx.json 的 providers 数组
4
- // - 写盘: 备份原文件 → 原子写 (.tmp + rename) → 防配置丢失
5
- // - 返回前端时: 抹掉 api_key 明文, 只暴露 api_key_env 与 api_key_set 标志
6
- // - 校验: id 命名规则 / 字段必填 / id 冲突检测
7
- // - 热重载: 调用方写盘后, 调 agent.reloadProviders() 重建内存中的 LLM 客户端列表
8
- import fs from "node:fs";
9
- import path from "node:path";
10
- import { withFileLock } from "../utils/store.js";
11
- import { info, warn, error } from "../utils/logger.js";
12
-
13
- // 提供方字段白名单 (写入磁盘时的过滤)
14
- const PROVIDER_KEYS = [
15
- "id", "backend", "base_url", "api_key", "api_key_env",
16
- "model", "models", "vision", "timeout_ms",
17
- "mjs", "session_key", "dsh_root", "dsml",
18
- ];
19
-
20
- function getProvidersPath(root) {
21
- return path.join(root, "config", "ppx.json");
22
- }
23
-
24
- // 读取整个 config, 返回 { providers, raw }
25
- export function readConfig(root) {
26
- const p = getProvidersPath(root);
27
- if (!fs.existsSync(p)) return { providers: [], raw: { providers: [] } };
28
- try {
29
- const raw = JSON.parse(fs.readFileSync(p, "utf8"));
30
- const providers = Array.isArray(raw.providers) ? raw.providers : [];
31
- return { providers, raw };
32
- } catch (e) {
33
- warn("config/ppx.json 读取失败:", e.message);
34
- return { providers: [], raw: { providers: [] } };
35
- }
36
- }
37
-
38
- // 写盘: 先备份, 再原子写 (tmp + rename), 防中途崩溃损坏
39
- function writeConfigAtomic(root, cfg) {
40
- const p = getProvidersPath(root);
41
- // 备份 (最多保留最近 3 个 .bak, 多则删)
42
- if (fs.existsSync(p)) {
43
- const bak = p + ".bak-" + new Date().toISOString().replace(/[:.]/g, "-");
44
- try { fs.copyFileSync(p, bak); } catch (e) { warn("备份失败:", e.message); }
45
- // 清理老备份 (保留最近 3 个)
46
- try {
47
- const dir = path.dirname(p);
48
- const base = path.basename(p);
49
- const baks = fs.readdirSync(dir)
50
- .filter((f) => f.startsWith(base + ".bak-"))
51
- .map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs }));
52
- baks.sort((a, b) => b.t - a.t);
53
- for (const old of baks.slice(3)) {
54
- try { fs.unlinkSync(path.join(dir, old.f)); } catch {}
55
- }
56
- } catch {}
57
- }
58
- // 原子写
59
- const tmp = p + ".tmp";
60
- fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2), "utf8");
61
- fs.renameSync(tmp, p);
62
- }
63
-
64
- // 校验一个提供方对象是否合法; 返回 null 或错误信息
65
- export function validateProvider(p) {
66
- if (!p || typeof p !== "object") return "提供方必须是对象";
67
- if (!p.id || typeof p.id !== "string") return "缺少 id";
68
- if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,29}$/.test(p.id)) return "id 必须以字母开头, 仅含字母/数字/横线/下划线, 2-30字符";
69
- // 必须至少有一类后端配置
70
- const isEngineBackend = p.backend === "openclaw" || p.backend === "deepseek";
71
- const hasEnginePath = !!p.mjs || !!p.dsh_root;
72
- const hasHttpField = !!p.base_url || !!p.api_key || !!p.api_key_env;
73
- if (isEngineBackend) {
74
- if (p.backend === "openclaw" && !hasEnginePath) return "openclaw 后端需 mjs 字段 (openclaw.mjs 路径)";
75
- if (p.backend === "deepseek" && !hasEnginePath) return "deepseek 后端需 dsh_root 字段";
76
- } else {
77
- if (!hasHttpField) return "http 后端至少需要 base_url 或 api_key 或 api_key_env 之一";
78
- if (!p.base_url) return "http 后端需 base_url";
79
- }
80
- if (p.vision != null && typeof p.vision !== "boolean") return "vision 必须是布尔";
81
- if (p.dsml != null && typeof p.dsml !== "boolean") return "dsml 必须是布尔";
82
- if (p.timeout_ms != null && (!Number.isFinite(p.timeout_ms) || p.timeout_ms < 1000)) return "timeout_ms 至少 1000ms";
83
- // 占位符校验: 模板残留 (如 REPLACE_WITH_YOUR_ENDPOINT) 视为未配置, 避免配了 key 却静默失败
84
- for (const field of ["model", "base_url", "api_key"]) {
85
- const v = p[field];
86
- if (typeof v === "string" && /REPLACE_WITH_|your_?endpoint|your[_-]?api[_-]?key/i.test(v)) {
87
- return `${field} 仍是占位符 (${v.slice(0, 40)}), 请替换为真实值`;
88
- }
89
- }
90
- return null;
91
- }
92
-
93
- // 抹掉 key 明文, 暴露给前端的安全视图
94
- export function sanitizeProvider(p) {
95
- const out = {};
96
- for (const k of PROVIDER_KEYS) {
97
- if (k === "api_key") continue; // 永远不回传
98
- if (p[k] !== undefined) out[k] = p[k];
99
- }
100
- out.api_key_set = !!p.api_key;
101
- out.api_key_env = p.api_key_env || "";
102
- return out;
103
- }
104
-
105
- export function listProviders(root) {
106
- const { providers } = readConfig(root);
107
- return providers.map((p) => sanitizeProvider(p));
108
- }
109
-
110
- export function getProvider(root, id) {
111
- const { providers } = readConfig(root);
112
- const p = providers.find((x) => x.id === id);
113
- return p ? sanitizeProvider(p) : null;
114
- }
115
-
116
- // 新增
117
- // v1.0.8: 写盘在文件锁内读-改-写 (防并发 API 请求丢更新)
118
- export function addProvider(root, raw) {
119
- return withFileLock(getProvidersPath(root), () => {
120
- const { providers, raw: cfg } = readConfig(root);
121
- const err = validateProvider(raw);
122
- if (err) throw new Error(err);
123
- if (providers.find((p) => p.id === raw.id)) throw new Error(`id 冲突: ${raw.id}`);
124
- // 仅保留白名单字段
125
- const norm = {};
126
- for (const k of PROVIDER_KEYS) if (raw[k] !== undefined) norm[k] = raw[k];
127
- if (!norm.id) norm.id = raw.id;
128
- providers.push(norm);
129
- cfg.providers = providers;
130
- writeConfigAtomic(root, cfg);
131
- return sanitizeProvider(norm);
132
- });
133
- }
134
-
135
- // 更新 (id 不可改, 其他字段覆盖)
136
- export function updateProvider(root, id, patch) {
137
- return withFileLock(getProvidersPath(root), () => {
138
- const { providers, raw: cfg } = readConfig(root);
139
- const idx = providers.findIndex((p) => p.id === id);
140
- if (idx < 0) throw new Error(`提供方不存在: ${id}`);
141
- const merged = { ...providers[idx], ...patch, id };
142
- // 抹掉 patch 里的 api_key 设了空字符串, 视为清空
143
- if (patch.api_key === "") delete merged.api_key;
144
- const err = validateProvider(merged);
145
- if (err) throw new Error(err);
146
- // 仅保留白名单
147
- const clean = {};
148
- for (const k of PROVIDER_KEYS) if (merged[k] !== undefined) clean[k] = merged[k];
149
- providers[idx] = clean;
150
- cfg.providers = providers;
151
- writeConfigAtomic(root, cfg);
152
- return sanitizeProvider(clean);
153
- });
154
- }
155
-
156
- // 删除
157
- export function removeProvider(root, id) {
158
- return withFileLock(getProvidersPath(root), () => {
159
- const { providers, raw: cfg } = readConfig(root);
160
- const idx = providers.findIndex((p) => p.id === id);
161
- if (idx < 0) throw new Error(`提供方不存在: ${id}`);
162
- const [removed] = providers.splice(idx, 1);
163
- cfg.providers = providers;
164
- writeConfigAtomic(root, cfg);
165
- return sanitizeProvider(removed);
166
- });
167
- }
168
-
169
- // 重排 (默认 = 数组第 0 个)
170
- export function reorderProviders(root, order) {
171
- return withFileLock(getProvidersPath(root), () => {
172
- const { providers, raw: cfg } = readConfig(root);
173
- if (!Array.isArray(order) || order.length !== providers.length) {
174
- throw new Error("order 必须是包含全部 id 的数组");
175
- }
176
- const byId = new Map(providers.map((p) => [p.id, p]));
177
- const next = [];
178
- for (const id of order) {
179
- const p = byId.get(id);
180
- if (!p) throw new Error(`未知 id: ${id}`);
181
- next.push(p);
182
- byId.delete(id);
183
- }
184
- if (byId.size) throw new Error("order 缺少部分 id");
185
- cfg.providers = next;
186
- writeConfigAtomic(root, cfg);
187
- return next.map((p) => sanitizeProvider(p));
188
- });
1
+ // src/config/providers.js - LLM 提供方 CRUD (HTTP API 后端)
2
+ // 设计:
3
+ // - 唯一事实源 = config/ppx.json 的 providers 数组
4
+ // - 写盘: 备份原文件 → 原子写 (.tmp + rename) → 防配置丢失
5
+ // - 返回前端时: 抹掉 api_key 明文, 只暴露 api_key_env 与 api_key_set 标志
6
+ // - 校验: id 命名规则 / 字段必填 / id 冲突检测
7
+ // - 热重载: 调用方写盘后, 调 agent.reloadProviders() 重建内存中的 LLM 客户端列表
8
+ import { withFileLock } from "../utils/store.js";
9
+ import { configFilePath, readPpxConfig, writeConfigAtomic } from "../utils/config-file.js";
10
+ import { isPlaceholder } from "./placeholder.js";
11
+
12
+ // 提供方字段白名单 (写入磁盘时的过滤)
13
+ const PROVIDER_KEYS = [
14
+ "id", "backend", "base_url", "api_key", "api_key_env",
15
+ "model", "models", "vision", "timeout_ms",
16
+ "dsml",
17
+ ];
18
+
19
+ const getProvidersPath = configFilePath;
20
+
21
+ // 读取整个 config, 返回 { providers, raw }
22
+ export function readConfig(root) {
23
+ const raw = readPpxConfig(root, null);
24
+ if (raw === null) return { providers: [], raw: { providers: [] } };
25
+ const providers = Array.isArray(raw.providers) ? raw.providers : [];
26
+ return { providers, raw };
27
+ }
28
+
29
+ // 校验一个提供方对象是否合法; 返回 null 或错误信息
30
+ export function validateProvider(p) {
31
+ if (!p || typeof p !== "object") return "提供方必须是对象";
32
+ if (!p.id || typeof p.id !== "string") return "缺少 id";
33
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,29}$/.test(p.id)) return "id 必须以字母开头, 仅含字母/数字/横线/下划线, 2-30字符";
34
+ // 自研底座: 仅支持 http 后端 (OpenAI 兼容 API 直连); 显式非 http 后端一律拒绝
35
+ if (p.backend && p.backend !== "http") {
36
+ return `后端 ${p.backend} 已不受支持 (v2.5.0 起仅保留自研 http 底座, 外部引擎底座已移除)`;
37
+ }
38
+ if (!p.base_url) return "http 后端需 base_url";
39
+ if (p.vision != null && typeof p.vision !== "boolean") return "vision 必须是布尔";
40
+ if (p.dsml != null && typeof p.dsml !== "boolean") return "dsml 必须是布尔";
41
+ if (p.timeout_ms != null && (!Number.isFinite(p.timeout_ms) || p.timeout_ms < 1000)) return "timeout_ms 至少 1000ms";
42
+ // 占位符校验: 模板残留 (如 REPLACE_WITH_YOUR_ENDPOINT / YOUR_LOCAL_MODEL_NAME) 视为未配置,
43
+ // 避免配了 key 却静默失败。v1.0.8 (P2-3): 判定收敛到 config/placeholder.js 唯一真相源。
44
+ for (const field of ["model", "base_url", "api_key"]) {
45
+ const v = p[field];
46
+ if (isPlaceholder(v)) {
47
+ return `${field} 仍是占位符 (${v.slice(0, 40)}), 请替换为真实值`;
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+
53
+ // 抹掉 key 明文, 暴露给前端的安全视图
54
+ export function sanitizeProvider(p) {
55
+ const out = {};
56
+ for (const k of PROVIDER_KEYS) {
57
+ if (k === "api_key") continue; // 永远不回传
58
+ if (p[k] !== undefined) out[k] = p[k];
59
+ }
60
+ out.api_key_set = !!p.api_key;
61
+ out.api_key_env = p.api_key_env || "";
62
+ return out;
63
+ }
64
+
65
+ export function listProviders(root) {
66
+ const { providers } = readConfig(root);
67
+ return providers.map((p) => sanitizeProvider(p));
68
+ }
69
+
70
+ export function getProvider(root, id) {
71
+ const { providers } = readConfig(root);
72
+ const p = providers.find((x) => x.id === id);
73
+ return p ? sanitizeProvider(p) : null;
74
+ }
75
+
76
+ // 新增
77
+ // v1.0.8: 写盘在文件锁内读-改-写 (防并发 API 请求丢更新)
78
+ export function addProvider(root, raw) {
79
+ return withFileLock(getProvidersPath(root), () => {
80
+ const { providers, raw: cfg } = readConfig(root);
81
+ const err = validateProvider(raw);
82
+ if (err) throw new Error(err);
83
+ if (providers.find((p) => p.id === raw.id)) throw new Error(`id 冲突: ${raw.id}`);
84
+ // 仅保留白名单字段
85
+ const norm = {};
86
+ for (const k of PROVIDER_KEYS) if (raw[k] !== undefined) norm[k] = raw[k];
87
+ if (!norm.id) norm.id = raw.id;
88
+ providers.push(norm);
89
+ cfg.providers = providers;
90
+ writeConfigAtomic(root, cfg);
91
+ return sanitizeProvider(norm);
92
+ });
93
+ }
94
+
95
+ // 更新 (id 不可改, 其他字段覆盖)
96
+ export function updateProvider(root, id, patch) {
97
+ return withFileLock(getProvidersPath(root), () => {
98
+ const { providers, raw: cfg } = readConfig(root);
99
+ const idx = providers.findIndex((p) => p.id === id);
100
+ if (idx < 0) throw new Error(`提供方不存在: ${id}`);
101
+ const merged = { ...providers[idx], ...patch, id };
102
+ // 抹掉 patch 里的 api_key 设了空字符串, 视为清空
103
+ if (patch.api_key === "") delete merged.api_key;
104
+ const err = validateProvider(merged);
105
+ if (err) throw new Error(err);
106
+ // 仅保留白名单
107
+ const clean = {};
108
+ for (const k of PROVIDER_KEYS) if (merged[k] !== undefined) clean[k] = merged[k];
109
+ providers[idx] = clean;
110
+ cfg.providers = providers;
111
+ writeConfigAtomic(root, cfg);
112
+ return sanitizeProvider(clean);
113
+ });
114
+ }
115
+
116
+ // 删除
117
+ export function removeProvider(root, id) {
118
+ return withFileLock(getProvidersPath(root), () => {
119
+ const { providers, raw: cfg } = readConfig(root);
120
+ const idx = providers.findIndex((p) => p.id === id);
121
+ if (idx < 0) throw new Error(`提供方不存在: ${id}`);
122
+ const [removed] = providers.splice(idx, 1);
123
+ cfg.providers = providers;
124
+ writeConfigAtomic(root, cfg);
125
+ return sanitizeProvider(removed);
126
+ });
127
+ }
128
+
129
+ // 重排 (默认 = 数组第 0 个)
130
+ export function reorderProviders(root, order) {
131
+ return withFileLock(getProvidersPath(root), () => {
132
+ const { providers, raw: cfg } = readConfig(root);
133
+ if (!Array.isArray(order) || order.length !== providers.length) {
134
+ throw new Error("order 必须是包含全部 id 的数组");
135
+ }
136
+ const byId = new Map(providers.map((p) => [p.id, p]));
137
+ const next = [];
138
+ for (const id of order) {
139
+ const p = byId.get(id);
140
+ if (!p) throw new Error(`未知 id: ${id}`);
141
+ next.push(p);
142
+ byId.delete(id);
143
+ }
144
+ if (byId.size) throw new Error("order 缺少部分 id");
145
+ cfg.providers = next;
146
+ writeConfigAtomic(root, cfg);
147
+ return next.map((p) => sanitizeProvider(p));
148
+ });
189
149
  }