pi-web-ui 0.94.1 → 0.96.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 (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -16,8 +16,12 @@
16
16
  import { spawn } from "node:child_process";
17
17
  import { readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
- import { bilingual } from "./i18n.js";
20
19
  const PROTOCOL_VERSION = "2025-03-26"; // 广泛支持的工具版本
20
+ /** 单行(一条 JSON-RPC 消息)的长度上限(1MB):失控服务器一行永不换行会顶爆内存。 */
21
+ const MCP_MAX_LINE_CHARS = 1024 * 1024;
22
+ /** 未换行数据的总缓冲上限(4MB):服务器只吐垃圾不吐换行时按协议错误杀进程。
23
+ * 上限按 JS 字符串长度计(UTF-16 码元 ≤ UTF-8 字节),内存放大有界。 */
24
+ const MCP_MAX_BUFFER_CHARS = 4 * 1024 * 1024;
21
25
  let rpcSeq = 0;
22
26
  /**
23
27
  * 单个 MCP 服务器的客户端:管理子进程、请求/响应按 id 关联、握手与工具调用。
@@ -245,12 +249,22 @@ export class McpClient {
245
249
  }
246
250
  onData(chunk) {
247
251
  this.buffer += chunk;
252
+ // 总缓冲上限:服务器一直输出却不换行(或极慢地换行)时,缓冲会无限涨,
253
+ // 宿主内存被一个失控子进程吃光。超限按协议错误处理:杀进程 + 拒绝在途请求。
254
+ if (this.buffer.length > MCP_MAX_BUFFER_CHARS) {
255
+ this.killForProtocolError(new Error(`[mcp:${this.name}] stdout 缓冲超过 ${Math.round(MCP_MAX_BUFFER_CHARS / 1024 / 1024)}MB 上限,按协议错误关闭`));
256
+ return;
257
+ }
248
258
  let nl;
249
259
  while ((nl = this.buffer.indexOf("\n")) >= 0) {
250
260
  const line = this.buffer.slice(0, nl).trim();
251
261
  this.buffer = this.buffer.slice(nl + 1);
252
262
  if (!line)
253
263
  continue;
264
+ if (line.length > MCP_MAX_LINE_CHARS) {
265
+ this.killForProtocolError(new Error(`[mcp:${this.name}] 单行超过 ${Math.round(MCP_MAX_LINE_CHARS / 1024 / 1024)}MB 上限,按协议错误关闭`));
266
+ return;
267
+ }
254
268
  let msg;
255
269
  try {
256
270
  msg = JSON.parse(line);
@@ -262,6 +276,24 @@ export class McpClient {
262
276
  this.handleMessage(msg);
263
277
  }
264
278
  }
279
+ /** 协议错误(stdout 缓冲超限):杀掉子进程、拒绝全部在途请求、清空缓冲。
280
+ * 与进程自然退出共用自愈语义 —— 下一次工具调用会重新拉起并重新握手
281
+ * (失控服务器重启后再次超限就再次杀,不会永久占用宿主内存)。 */
282
+ killForProtocolError(err) {
283
+ this.log(err.message);
284
+ this.buffer = "";
285
+ const child = this.child;
286
+ this.child = null;
287
+ this.rejectAll(err);
288
+ if (child) {
289
+ try {
290
+ child.kill();
291
+ }
292
+ catch {
293
+ /* 已退出 */
294
+ }
295
+ }
296
+ }
265
297
  handleMessage(msg) {
266
298
  if (msg.id !== undefined) {
267
299
  const pending = this.pending.get(String(msg.id));
@@ -360,8 +392,7 @@ function adaptMcpTool(serverName, mcpTool, client) {
360
392
  return {
361
393
  name,
362
394
  label: `${serverName} · ${mcpTool.name}`,
363
- description: mcpTool.description ??
364
- bilingual(`Tool ${mcpTool.name} provided by MCP server "${serverName}"`, `从 MCP 服务器「${serverName}」提供的工具 ${mcpTool.name}`),
395
+ description: mcpTool.description ?? `Tool ${mcpTool.name} provided by MCP server "${serverName}"`,
365
396
  parameters: mcpTool.inputSchema ?? {},
366
397
  execute: async (_toolCallId, params, _signal) => {
367
398
  return client.call(mcpTool.name, params ?? {});
@@ -380,6 +411,8 @@ export class McpBridge {
380
411
  opts;
381
412
  clients = [];
382
413
  tools = [];
414
+ /** reload 串行化链:重入的 reload 排在前一个之后,杜绝交叠留下的孤儿进程。 */
415
+ reloadChain = Promise.resolve();
383
416
  constructor(dataDir, log = () => { }, opts = {}) {
384
417
  this.dataDir = dataDir;
385
418
  this.log = log;
@@ -411,6 +444,12 @@ export class McpBridge {
411
444
  }
412
445
  /**
413
446
  * 按磁盘上的最新配置**整体换入**服务器集合(`mcp.json` 热加载用),可重复调用。
447
+ *
448
+ * 并发互斥:watch + 轮询 + 手动 apply 可能同时触发 reload,两个 reload 交叠跑
449
+ * 会各自 startOne/close —— 同一服务器起两个子进程、或把别人刚换入的实例当
450
+ * stale 关掉(孤儿/误杀)。这里用一条 promise 链把重入排成队,每个 reload
451
+ * 看到的是前一个完成后的最新状态;热加载的防抖窗口之外再兜一道。
452
+ *
414
453
  * 三步的顺序都有讲究:
415
454
  * 1. 规格没变的服务器**沿用原实例** —— 改一个服务器不该连带重启其它服务器(子进程、
416
455
  * 浏览器会话、在途调用全都不动);
@@ -419,6 +458,11 @@ export class McpBridge {
419
458
  * 3. 最后才 close 掉被移除/被替换的旧实例,并按新集合重建工具表。
420
459
  */
421
460
  async reload() {
461
+ const run = this.reloadChain.then(() => this.reloadOnce(), () => this.reloadOnce());
462
+ this.reloadChain = run.then(() => undefined, () => undefined);
463
+ return run;
464
+ }
465
+ async reloadOnce() {
422
466
  const cfg = optsOverrideOrRead(this.opts.specOverride, this.dataDir);
423
467
  const next = new Map();
424
468
  let kept = 0;
@@ -11,7 +11,7 @@
11
11
  * 经 ModelAdminHost 与 ClientSession 解耦(同 settings/goal/slash 服务模式)。
12
12
  * UI 文案直接中文(服务端 notice 约定)。apiKey/headers 绝不下发浏览器。
13
13
  */
14
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
15
  import { dirname, join } from "node:path";
16
16
  import { enrichBatch } from "./model-enrich.js";
17
17
  import { pick } from "./i18n.js";
@@ -65,6 +65,13 @@ export function graduateOverlayModels(providers, official) {
65
65
  }
66
66
  return graduated;
67
67
  }
68
+ /** 原子写 JSON(tmp + rename):进程崩溃不留半截文件——裸 writeFileSync 中途被
69
+ * 杀会把整份配置截断,下次读盘即空表。tmp 名带 pid,避免同机多进程互踩。 */
70
+ function atomicWriteJson(path, data) {
71
+ const tmp = `${path}.${process.pid}.tmp`;
72
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
73
+ renameSync(tmp, path);
74
+ }
68
75
  /** Strip // and /* *\/ comments without touching string literals (URLs contain //). */
69
76
  function stripJsonComments(src) {
70
77
  let out = "";
@@ -111,7 +118,7 @@ function stripJsonComments(src) {
111
118
  /** Merge a UI-submitted provider config into the existing models.json entry.
112
119
  *
113
120
  * 表单(`UiProviderConfig`)只承载 UI 认识的字段:provider 级 name/api/baseUrl/
114
- * apiKey/authHeader,模型级 id/name/reasoning/input/contextWindow/maxTokens。
121
+ * hasApiKey/authHeader,模型级 id/name/reasoning/input/contextWindow/maxTokens。
115
122
  * models.json 里还可能有 UI 不认识的字段——provider 级 headers(浏览器拿不到,
116
123
  * 见 listModelsConfig)、模型级 api/baseUrl/cost/compat/thinkingLevelMap(手写或
117
124
  * 脚本写入,pi-ai 靠它们决定请求地址与推理格式)。按表单整体重建条目会把这些字段
@@ -120,6 +127,10 @@ function stripJsonComments(src) {
120
127
  *
121
128
  * 所以这里以已有条目为底、表单字段覆盖:表单没提到的字段原样保留,表单清空的可选
122
129
  * 字段才真正删除;`models` 仍是「表单即全集」——表单里删掉的 id 会被移除。
130
+ *
131
+ * apiKey 例外:浏览器不再持有明文(只见 hasApiKey),**缺字段 = 保留旧值**;显式
132
+ * 空串才清除(协议级"空=清除"语义保留,但 Web 表单留空时整字段不传)。这样
133
+ * refresh_provider_models 等不带 apiKey 的内部保存路径也绝不会把密钥抹掉。
123
134
  */
124
135
  export function mergeProviderConfigEntry(prevEntry, config, models) {
125
136
  const prevModels = new Map();
@@ -134,7 +145,7 @@ export function mergeProviderConfigEntry(prevEntry, config, models) {
134
145
  }
135
146
  const mergedModels = models.map((model) => {
136
147
  // 旧条目同 id 的字段(api/baseUrl/cost/compat/…)先铺底,表单字段覆盖。
137
- const merged = { ...(prevModels.get(model.id) ?? {}), id: model.id };
148
+ const merged = { ...prevModels.get(model.id), id: model.id };
138
149
  if (model.name?.trim())
139
150
  merged.name = model.name.trim();
140
151
  else
@@ -167,7 +178,12 @@ export function mergeProviderConfigEntry(prevEntry, config, models) {
167
178
  apply("name", config.name?.trim() || undefined);
168
179
  apply("api", config.api?.trim() || undefined);
169
180
  apply("baseUrl", config.baseUrl?.trim() || undefined);
170
- apply("apiKey", config.apiKey?.trim() || undefined);
181
+ if (config.apiKey === undefined) {
182
+ // 表单没带 apiKey(留空/内部路径)→ 保留旧值,绝不清除。
183
+ }
184
+ else {
185
+ apply("apiKey", config.apiKey.trim() || undefined);
186
+ }
171
187
  apply("authHeader", config.authHeader ? true : undefined);
172
188
  mergedEntry.models = mergedModels;
173
189
  return mergedEntry;
@@ -331,29 +347,55 @@ export class ModelAdminService {
331
347
  }
332
348
  /** Read + parse provider-keys.json. */
333
349
  readProviderKeys() {
350
+ let raw;
334
351
  try {
335
- const parsed = JSON.parse(readFileSync(this.providerKeysPath(), "utf8"));
336
- const out = {};
337
- for (const [pid, entry] of Object.entries(parsed)) {
338
- const keys = Array.isArray(entry?.keys) ? entry.keys.filter((k) => k?.name && k?.apiKey) : [];
339
- if (!pid || keys.length === 0)
340
- continue;
341
- const activeKeyName = entry.activeKeyName === null
342
- ? null
343
- : entry.activeKeyName && keys.some((k) => k.name === entry.activeKeyName)
344
- ? entry.activeKeyName
345
- : keys[0].name;
346
- out[pid] = { activeKeyName, keys };
347
- }
348
- return out;
352
+ raw = readFileSync(this.providerKeysPath(), "utf8");
349
353
  }
350
354
  catch {
355
+ return {}; // 文件不存在 = 首次引导
356
+ }
357
+ let parsed;
358
+ try {
359
+ parsed = JSON.parse(raw);
360
+ }
361
+ catch (err) {
362
+ // 坏文件改名留存:里面可能有全部备用密钥,绝不能被后续种子/空表整份覆盖。
363
+ this.quarantineCorruptFile(this.providerKeysPath(), err);
351
364
  return {};
352
365
  }
366
+ const out = {};
367
+ for (const [pid, entry] of Object.entries(parsed)) {
368
+ const keys = Array.isArray(entry?.keys) ? entry.keys.filter((k) => k?.name && k?.apiKey) : [];
369
+ if (!pid || keys.length === 0)
370
+ continue;
371
+ const activeKeyName = entry.activeKeyName === null
372
+ ? null
373
+ : entry.activeKeyName && keys.some((k) => k.name === entry.activeKeyName)
374
+ ? entry.activeKeyName
375
+ : keys[0].name;
376
+ out[pid] = { activeKeyName, keys };
377
+ }
378
+ return out;
353
379
  }
354
380
  writeProviderKeys(data) {
355
381
  mkdirSync(this.host.agentDir, { recursive: true });
356
- writeFileSync(this.providerKeysPath(), JSON.stringify(data, null, 2) + "\n");
382
+ // 原子写(tmp+rename):崩溃不留半截 JSON;0o600 限权防本机其他用户读密钥
383
+ //(Windows 忽略 mode 无害,POSIX 生效)。
384
+ const path = this.providerKeysPath();
385
+ const tmp = `${path}.${process.pid}.tmp`;
386
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
387
+ renameSync(tmp, path);
388
+ }
389
+ /** 解析失败的配置文件改名留存为 <name>.corrupt-<timestamp>。改名失败(占用/
390
+ * 跨盘等)也不抛——留存是尽力而为,绝不能阻塞主流程。 */
391
+ quarantineCorruptFile(path, err) {
392
+ try {
393
+ renameSync(path, `${path}.corrupt-${Date.now()}`);
394
+ console.warn(`[model-admin] 配置文件解析失败,已改名留存:${path}(${err.message})`);
395
+ }
396
+ catch {
397
+ console.warn(`[model-admin] 配置文件解析失败且无法留存:${path}(${err.message})`);
398
+ }
357
399
  }
358
400
  /** Default name "密钥 N" for a provider's Nth key. */
359
401
  defaultKeyName(keys) {
@@ -421,12 +463,17 @@ export class ModelAdminService {
421
463
  }
422
464
  }
423
465
  /** Push the masked provider-keys map to the client. Seeds the store from any
424
- * auth.json credentials so legacy single-key setups show up immediately. */
466
+ * auth.json credentials so legacy single-key setups show up immediately.
467
+ * 只在文件不存在(首次引导)时才落盘种子;文件存在(含空表)绝不整份写回
468
+ * ——写回的来源永远是读盘结果,避免任何竞态把用户密钥覆盖掉。 */
425
469
  listProviderKeys() {
470
+ const path = this.providerKeysPath();
426
471
  const data = this.readProviderKeys();
427
- for (const pid of this.builtinProviderIds())
428
- this.seedProviderKeysFromAuth(pid, data);
429
- this.writeProviderKeys(data);
472
+ if (!existsSync(path)) {
473
+ for (const pid of this.builtinProviderIds())
474
+ this.seedProviderKeysFromAuth(pid, data);
475
+ this.writeProviderKeys(data);
476
+ }
430
477
  this.host.emit({ type: "provider_keys", ...this.providerKeysInfo(data) });
431
478
  this.host.flushSnapshot();
432
479
  }
@@ -458,7 +505,7 @@ export class ModelAdminService {
458
505
  // no file yet / unparsable — start fresh
459
506
  }
460
507
  data[pid] = { type: "api_key", key: apiKey };
461
- writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n");
508
+ atomicWriteJson(authPath, data);
462
509
  const mr = this.host.modelRuntime();
463
510
  await mr.setRuntimeApiKey(pid, apiKey);
464
511
  await mr.refresh({ allowNetwork: true, providers: [pid] });
@@ -689,7 +736,7 @@ export class ModelAdminService {
689
736
  // no file yet — nothing to clean
690
737
  }
691
738
  delete auth[pid];
692
- writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n");
739
+ atomicWriteJson(authPath, auth);
693
740
  const mr = this.host.modelRuntime();
694
741
  await mr.removeRuntimeApiKey(pid);
695
742
  await mr.refresh({ providers: [pid] });
@@ -780,7 +827,7 @@ export class ModelAdminService {
780
827
  return;
781
828
  }
782
829
  delete data[pid];
783
- writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n");
830
+ atomicWriteJson(authPath, data);
784
831
  // Clear every stored key so the provider returns to unconfigured.
785
832
  delete keyData[pid];
786
833
  this.writeProviderKeys(keyData);
@@ -1131,7 +1178,8 @@ export class ModelAdminService {
1131
1178
  name: p.name,
1132
1179
  api: p.api,
1133
1180
  baseUrl: p.baseUrl,
1134
- apiKey: p.apiKey,
1181
+ // apiKey 明文绝不下发浏览器,只报有无;表单留空 = 保持不变。
1182
+ hasApiKey: typeof p.apiKey === "string" && p.apiKey.trim() !== "",
1135
1183
  authHeader: p.authHeader,
1136
1184
  // headers are intentionally NOT sent to the browser — they may
1137
1185
  // contain Authorization / API-key values; kept server-side only.
@@ -1232,18 +1280,50 @@ export class ModelAdminService {
1232
1280
  * because the baseUrl is often a LAN/loopback host the browser can't reach
1233
1281
  * cross-origin) and return the advertised models. reqId is echoed back
1234
1282
  * in fetch_models_result so the UI can match concurrent requests. */
1283
+ /** 编辑表单的探测(fetch_models / test_model_connection)没带明文 apiKey 时,
1284
+ * 按 providerId 回落到 models.json 里已保存的密钥——编辑存量服务商时表单
1285
+ * 一律留空(明文不再回传浏览器),没有这个回落「测试连接」就会 401。 */
1286
+ savedProviderApiKey(providerId) {
1287
+ const pid = providerId?.trim();
1288
+ if (!pid)
1289
+ return undefined;
1290
+ const saved = this.readModelsConfig().providers[pid];
1291
+ return typeof saved?.apiKey === "string" && saved.apiKey.trim() ? saved.apiKey : undefined;
1292
+ }
1235
1293
  async fetchModelsList(reqId, baseUrl, apiKey, authHeader, api,
1236
1294
  /** 探测抛错文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
1237
- lang) {
1295
+ lang, providerId) {
1238
1296
  const emitError = (error) => this.host.emit({ type: "fetch_models_result", reqId, ok: false, error });
1239
1297
  try {
1240
- const models = await ModelAdminService.probeModelsEndpoint(baseUrl, apiKey, authHeader, api, undefined, lang);
1298
+ const key = apiKey?.trim() || this.savedProviderApiKey(providerId);
1299
+ const models = await ModelAdminService.probeModelsEndpoint(baseUrl, key, authHeader, api, undefined, lang);
1241
1300
  this.host.emit({ type: "fetch_models_result", reqId, ok: true, models });
1242
1301
  }
1243
1302
  catch (err) {
1244
1303
  emitError(err.message);
1245
1304
  }
1246
1305
  }
1306
+ /**
1307
+ * Lightweight connectivity and auth probe for a provider endpoint.
1308
+ * Measures latency in ms and returns ok/error.
1309
+ */
1310
+ async testConnection(reqId, baseUrl, apiKey, authHeader, api, lang, providerId) {
1311
+ const start = Date.now();
1312
+ try {
1313
+ const key = apiKey?.trim() || this.savedProviderApiKey(providerId);
1314
+ await ModelAdminService.probeModelsEndpoint(baseUrl, key, authHeader, api, undefined, lang);
1315
+ const latencyMs = Date.now() - start;
1316
+ this.host.emit({ type: "test_model_connection_result", reqId, ok: true, latencyMs });
1317
+ }
1318
+ catch (err) {
1319
+ this.host.emit({
1320
+ type: "test_model_connection_result",
1321
+ reqId,
1322
+ ok: false,
1323
+ error: err.message,
1324
+ });
1325
+ }
1326
+ }
1247
1327
  /**
1248
1328
  * Probe a custom provider's model-list endpoint (OpenAI-compatible /models
1249
1329
  * with a /v1 retry; Google {models:[…]} shape supported). Throws Error with
@@ -1525,7 +1605,7 @@ export class ModelAdminService {
1525
1605
  if (graduated.length === 0)
1526
1606
  return graduated;
1527
1607
  try {
1528
- writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
1608
+ atomicWriteJson(this.modelsConfigPath(), { providers });
1529
1609
  }
1530
1610
  catch {
1531
1611
  return [];
@@ -1562,7 +1642,7 @@ export class ModelAdminService {
1562
1642
  * config list + the picker. Callers emit their own notice. */
1563
1643
  async writeModelsConfigAndReload(providers, pid) {
1564
1644
  mkdirSync(this.host.agentDir, { recursive: true });
1565
- writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
1645
+ atomicWriteJson(this.modelsConfigPath(), { providers });
1566
1646
  // Allow a models.json entry to reuse the provider credential already
1567
1647
  // stored in auth.json. Seed the shared runtime too, because older pi-ai
1568
1648
  // versions did not always fall back to stored credentials for a
@@ -1719,7 +1799,7 @@ export class ModelAdminService {
1719
1799
  return;
1720
1800
  }
1721
1801
  delete providers[providerId];
1722
- writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
1802
+ atomicWriteJson(this.modelsConfigPath(), { providers });
1723
1803
  await this.host.modelRuntime().refresh();
1724
1804
  this.host.invalidatePiConfig();
1725
1805
  await this.listModelsConfig();