pi-models-discovery 1.4.0 → 1.6.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-models-discovery
2
2
 
3
- Generic model discovery extension: reads providers marked with `"discoverModels": true` in `~/.pi/agent/models.json`, requests `GET {baseUrl}/models`, and registers the discovered models automatically — no handwritten `models` array required. After the first successful discovery the model list is persisted to a local cache, so subsequent startups read the cache and **perform no network requests**.
3
+ Generic model discovery extension: reads providers marked with `"discoverModels": true` in `~/.pi/agent/models.json`, requests `GET {baseUrl}/models` (or a configured `modelsUrl`), and registers the discovered models automatically — no handwritten `models` array required. After the first successful discovery the model list is persisted to a local cache, so subsequent startups read the cache and **perform no network requests**.
4
4
 
5
5
  Suitable for local/self-hosted LLM proxies (one gateway exposing many models), Ollama, vLLM, and other OpenAI-compatible services.
6
6
 
@@ -18,7 +18,7 @@ pi install npm:pi-models-discovery
18
18
  /config:model-discovery
19
19
  ```
20
20
 
21
- Interactively add / remove / rediscover providers: enter id, baseUrl, api type, apiKey (optional), display name (optional). Changes are written to models.json and take effect **immediately** (no /reload required). A backup is written to `models.json.discovery-bak` before each write.
21
+ Interactively add / remove / rediscover providers. The command opens a settings panel that lists every provider marked with `discoverModels`; selecting one opens its fields (enable/disable discovery, baseUrl, api type, apiKey, display name) plus actions to rediscover or remove it. Adding a provider asks for id, baseUrl, api type, apiKey (optional) and display name (optional). Changes are written to models.json and take effect **immediately** (no /reload required). A backup is written to `models.json.discovery-bak` before each write.
22
22
 
23
23
  Note: comments and custom formatting in models.json are not preserved on write (the file is reformatted with 4-space indentation).
24
24
 
@@ -40,7 +40,25 @@ Add `"discoverModels": true` to a provider in `~/.pi/agent/models.json`:
40
40
  }
41
41
  ```
42
42
 
43
- `baseUrl` and `api` are required; `apiKey` is optional (omit for unauthenticated services). Multiple providers sharing the same `baseUrl+apiKey` reuse a single `/models` request.
43
+ `baseUrl` and `api` are required; `apiKey` is optional (omit for unauthenticated services). Multiple providers sharing the same model-list URL, `apiKey`, `headers`, and `compat` reuse a single discovery request.
44
+
45
+ If the model catalog uses a different endpoint, set the optional `modelsUrl` in models.json to its **complete URL** (including the path and any query parameters):
46
+
47
+ ```json
48
+ {
49
+ "providers": {
50
+ "foo": {
51
+ "baseUrl": "https://api.example.com/v1",
52
+ "modelsUrl": "https://catalog.example.com/v1/models",
53
+ "apiKey": "$FOO_API_KEY",
54
+ "api": "openai-completions",
55
+ "discoverModels": true
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ `modelsUrl` is used as-is for discovery and refresh only; chat requests still use `baseUrl`. When omitted or empty, discovery uses `{baseUrl}/models`. Discovery sends the provider's existing `apiKey` and `headers` to this URL, so use a trusted endpoint. The response format remains unchanged.
44
62
 
45
63
  Hand edits require `/reload` to take effect; changes made through `/config:model-discovery` apply immediately.
46
64
 
@@ -54,7 +72,7 @@ Forces a rediscovery of every discovery provider and updates the local cache, no
54
72
 
55
73
  ## Behavior
56
74
 
57
- - **Startup (cache-first)**: when the cache hits, models are registered directly from the persisted list with zero network requests. The cache lives at `~/.pi/agent/extensions/pi-models-discovery/cache.json`. The cache is invalidated automatically when the provider configuration fingerprint (baseUrl+api+apiKey+headers+compat) changes, triggering a fresh network discovery.
75
+ - **Startup (cache-first)**: when the cache hits, models are registered directly from the persisted list with zero network requests. The cache lives at `~/.pi/agent/extensions/pi-models-discovery/cache.json`. The cache is invalidated automatically when the provider configuration fingerprint (baseUrl+api+apiKey+headers+compat+modelsUrl) changes, triggering a fresh network discovery.
58
76
  - **Online refresh**: `/config:model-discovery-refresh`, or the `refreshModels` hook triggered when opening `/model`, rediscovers online and updates the cache.
59
77
  - **Offline / fetch failure**: handwritten `models` in models.json (if any) are kept as a fallback, and an explicit warning is surfaced via in-session notify — never a silent degradation. One provider failing does not affect the others.
60
78
  - **apiKey resolution** (discovery request only): supports literals and `$ENV_VAR` / `${ENV_VAR}` interpolation; `!command` values skip discovery with an explicit warning (chat requests are still resolved by pi itself and are unaffected).
package/README.zh-CN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-models-discovery
2
2
 
3
- 通用模型发现插件:读取 `~/.pi/agent/models.json` 中带 `"discoverModels": true` 的 provider,请求 `GET {baseUrl}/models` 自动发现模型并注册,无需手写 `models` 数组。首次发现成功后模型列表持久化到本地缓存,之后每次启动直接读缓存,**不再请求网络**。
3
+ 通用模型发现插件:读取 `~/.pi/agent/models.json` 中带 `"discoverModels": true` 的 provider,请求 `GET {baseUrl}/models`(或配置的 `modelsUrl`)自动发现模型并注册,无需手写 `models` 数组。首次发现成功后模型列表持久化到本地缓存,之后每次启动直接读缓存,**不再请求网络**。
4
4
 
5
5
  适合本地/自建 LLM 代理(一个网关暴露多个模型)、Ollama、vLLM 等 OpenAI 兼容服务。
6
6
 
@@ -18,7 +18,7 @@ pi install npm:pi-models-discovery
18
18
  /config:model-discovery
19
19
  ```
20
20
 
21
- 交互式添加 / 删除 / 重新发现 provider:输入 id、baseUrl、api 类型、apiKey(可留空)、显示名(可留空),确认后写入 models.json 并**立即生效**(无需 /reload)。写入前自动备份到 `models.json.discovery-bak`。
21
+ 命令打开配置面板,列出所有标记了 `discoverModels` 的 provider;选中某个 provider 打开它的字段页(发现开关、baseUrl、api 类型、apiKey、显示名),并可立即重新发现或删除它。添加 provider 时依次问 id、baseUrl、api 类型、apiKey(可留空)、显示名(可留空),写入 models.json 后**立即生效**(无需 /reload)。写入前自动备份到 `models.json.discovery-bak`。
22
22
 
23
23
  注意:写回时 models.json 中的注释与自定义排版不被保留(会格式化为 4 空格缩进)。
24
24
 
@@ -40,7 +40,25 @@ pi install npm:pi-models-discovery
40
40
  }
41
41
  ```
42
42
 
43
- `baseUrl` 和 `api` 必填;`apiKey` 可选(无鉴权服务可省略)。同一 `baseUrl+apiKey` 的多个 provider 共享一次 `/models` 请求。
43
+ `baseUrl` 和 `api` 必填;`apiKey` 可选(无鉴权服务可省略)。模型列表 URL、`apiKey`、`headers` 和 `compat` 相同的多个 provider 共享一次发现请求。
44
+
45
+ 如果模型列表使用不同的端点,可在 models.json 中设置可选的 `modelsUrl`,值为模型列表的**完整 URL**(包含路径和所需查询参数):
46
+
47
+ ```json
48
+ {
49
+ "providers": {
50
+ "foo": {
51
+ "baseUrl": "https://api.example.com/v1",
52
+ "modelsUrl": "https://catalog.example.com/v1/models",
53
+ "apiKey": "$FOO_API_KEY",
54
+ "api": "openai-completions",
55
+ "discoverModels": true
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ `modelsUrl` 仅用于模型发现和刷新,按原值请求;聊天请求仍使用 `baseUrl`。未配置或为空时,沿用 `{baseUrl}/models`。发现请求会将 provider 现有的 `apiKey` 和 `headers` 发送到该 URL,请使用可信端点。响应格式保持不变。
44
62
 
45
63
  手编方式修改配置后需 `/reload` 生效;`/config:model-discovery` 命令的修改立即生效。
46
64
 
@@ -54,7 +72,7 @@ pi install npm:pi-models-discovery
54
72
 
55
73
  ## 行为
56
74
 
57
- - **启动(缓存优先)**:缓存命中时直接用持久化的模型列表注册,零网络请求;缓存文件位于 `~/.pi/agent/extensions/pi-models-discovery/cache.json`。provider 配置指纹(baseUrl+api+apiKey+headers+compat)变化时缓存自动失效,重新走网络发现。
75
+ - **启动(缓存优先)**:缓存命中时直接用持久化的模型列表注册,零网络请求;缓存文件位于 `~/.pi/agent/extensions/pi-models-discovery/cache.json`。provider 配置指纹(baseUrl+api+apiKey+headers+compat+modelsUrl)变化时缓存自动失效,重新走网络发现。
58
76
  - **在线刷新**:`/config:model-discovery-refresh` 或 `/model` 打开时触发的 `refreshModels` 在线重新发现,并同步更新缓存。
59
77
  - **离线 / 拉取失败**:保留 models.json 里手写的 `models`(如有,作为回退),并通过会话内 notify 显式警告,不静默降级;单个 provider 失败不影响其他 provider。
60
78
  - **apiKey 解析**(仅发现请求):支持字面量与 `$ENV_VAR` / `${ENV_VAR}` 插值;`!command` 形式跳过发现并显式警告(聊天请求仍由 pi 自身解析执行,不受影响)。
package/SKILL.md CHANGED
@@ -13,7 +13,7 @@ description: "配置与排查 models.json 的动态模型发现、缓存刷新
13
13
  - `extensions/pi-models-discovery/cache.json`:启动缓存;
14
14
  - `models.json.discovery-bak`:交互配置写回前的备份。
15
15
 
16
- 只处理带 `discoverModels: true` 的 provider。确认 `baseUrl`、`api`、可选 `apiKey`、headers/compat 以及服务的 `GET {baseUrl}/models` 响应。
16
+ 只处理带 `discoverModels: true` 的 provider。确认 `baseUrl`、`api`、可选 `modelsUrl` / `apiKey`、headers/compat 以及服务的模型列表响应。发现请求使用 `GET modelsUrl`(完整 URL,不追加 `/models`);未配置或为空时使用 `GET {baseUrl}/models`。`modelsUrl` 仅影响发现和刷新,不改变聊天请求的 `baseUrl`;它会接收现有鉴权信息,须使用可信端点。
17
17
 
18
18
  ## 修改
19
19
 
@@ -182,5 +182,109 @@
182
182
  "removed": {
183
183
  "zh-CN": "已删除 {id}(备份:{backup})",
184
184
  "en-US": "Removed {id} (backup: {backup})"
185
+ },
186
+ "panelTitle": {
187
+ "zh-CN": "模型发现 provider(共 {count} 个)",
188
+ "en-US": "Model-discovery providers ({count})"
189
+ },
190
+ "panelProviderTitle": {
191
+ "zh-CN": "provider:{id}",
192
+ "en-US": "Provider: {id}"
193
+ },
194
+ "panelLabelAdd": {
195
+ "zh-CN": "添加 provider",
196
+ "en-US": "Add provider"
197
+ },
198
+ "panelDescAdd": {
199
+ "zh-CN": "填入 id、baseUrl、api 后写入 models.json 并立即发现模型。",
200
+ "en-US": "Enter an id, baseUrl and api; the entry is written to models.json and models are discovered immediately."
201
+ },
202
+ "panelLabelEnabled": {
203
+ "zh-CN": "模型发现",
204
+ "en-US": "Model discovery"
205
+ },
206
+ "panelDescEnabled": {
207
+ "zh-CN": "关闭后去掉该 provider 的 discoverModels 标记,模型条目不参与发现(provider 本身保留)。",
208
+ "en-US": "Turning this off removes the discoverModels marker so this provider is no longer discovered; the provider entry itself is kept."
209
+ },
210
+ "panelLabelName": {
211
+ "zh-CN": "显示名",
212
+ "en-US": "Display name"
213
+ },
214
+ "panelDescName": {
215
+ "zh-CN": "模型列表里显示的名字;留空回落到 provider id。",
216
+ "en-US": "Name shown in the model list; empty falls back to the provider id."
217
+ },
218
+ "panelLabelBaseUrl": {
219
+ "zh-CN": "baseUrl",
220
+ "en-US": "baseUrl"
221
+ },
222
+ "panelDescBaseUrl": {
223
+ "zh-CN": "发现请求打向 {baseUrl}/models;必须以 http:// 或 https:// 开头。",
224
+ "en-US": "Discovery requests go to {baseUrl}/models; it must start with http:// or https://."
225
+ },
226
+ "panelLabelApi": {
227
+ "zh-CN": "api 类型",
228
+ "en-US": "API type"
229
+ },
230
+ "panelDescApi": {
231
+ "zh-CN": "Pi 用哪种协议与这个 provider 对话。",
232
+ "en-US": "Which protocol Pi uses to talk to this provider."
233
+ },
234
+ "panelLabelApiKey": {
235
+ "zh-CN": "apiKey",
236
+ "en-US": "apiKey"
237
+ },
238
+ "panelDescApiKey": {
239
+ "zh-CN": "可留空;支持 $ENV_VAR 插值。",
240
+ "en-US": "Optional; supports $ENV_VAR interpolation."
241
+ },
242
+ "panelLabelRediscover": {
243
+ "zh-CN": "立即重新发现模型",
244
+ "en-US": "Rediscover models now"
245
+ },
246
+ "panelDescRediscover": {
247
+ "zh-CN": "立刻请求 {baseUrl}/models 刷新模型列表并更新缓存。",
248
+ "en-US": "Requests {baseUrl}/models now, refreshes the model list and updates the cache."
249
+ },
250
+ "panelLabelRemove": {
251
+ "zh-CN": "删除此 provider",
252
+ "en-US": "Remove this provider"
253
+ },
254
+ "panelDescRemove": {
255
+ "zh-CN": "从 models.json 移除该 provider 并注销其模型,会先让你确认。",
256
+ "en-US": "Removes the provider and unregisters its models from models.json; you are asked to confirm first."
257
+ },
258
+ "panelActionHint": {
259
+ "zh-CN": "回车执行",
260
+ "en-US": "Press Enter to run"
261
+ },
262
+ "panelValueUnset": {
263
+ "zh-CN": "(未设置)",
264
+ "en-US": "(unset)"
265
+ },
266
+ "panelOn": {
267
+ "zh-CN": "开",
268
+ "en-US": "On"
269
+ },
270
+ "panelOff": {
271
+ "zh-CN": "关",
272
+ "en-US": "Off"
273
+ },
274
+ "panelInputPrompt": {
275
+ "zh-CN": "值",
276
+ "en-US": "Value"
277
+ },
278
+ "panelConfirmRemove": {
279
+ "zh-CN": "删除 provider「{id}」?",
280
+ "en-US": "Remove provider \"{id}\"?"
281
+ },
282
+ "panelRemoveMessage": {
283
+ "zh-CN": "将从 models.json 移除该 provider 并注销其模型。此操作可重新添加恢复。",
284
+ "en-US": "This removes the provider and unregisters its models from models.json. It can be added again later."
285
+ },
286
+ "panelProviderMissing": {
287
+ "zh-CN": "models.json 中找不到 provider「{id}」的条目,无法写回。",
288
+ "en-US": "models.json has no entry for provider \"{id}\"; the change cannot be written back."
185
289
  }
186
290
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-models-discovery",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Pi extension: discover models from {baseUrl}/models for providers marked with discoverModels in models.json",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -16,7 +16,7 @@
16
16
  "SKILL.md"
17
17
  ],
18
18
  "scripts": {
19
- "test": "tsx --test tests/catalog.test.ts",
19
+ "test": "tsx --test tests/catalog.test.ts tests/config-panel.test.ts tests/config-panel-wiring.test.ts",
20
20
  "typecheck": "tsc --noEmit --pretty false",
21
21
  "build": "npm run typecheck",
22
22
  "check": "npm run typecheck && npm test && npm pack --dry-run --json > /dev/null"
@@ -54,14 +54,16 @@
54
54
  ],
55
55
  "peerDependencies": {
56
56
  "@earendil-works/pi-ai": ">=0.80.0",
57
- "@earendil-works/pi-coding-agent": ">=0.80.0"
57
+ "@earendil-works/pi-coding-agent": ">=0.80.0",
58
+ "@earendil-works/pi-tui": ">=0.80.0"
58
59
  },
59
60
  "dependencies": {
60
- "pi-extensions-i18n": "^0.8.0"
61
+ "pi-extensions-i18n": "^0.9.0"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@earendil-works/pi-ai": "0.85.1",
64
65
  "@earendil-works/pi-coding-agent": "0.85.1",
66
+ "@earendil-works/pi-tui": "0.85.1",
65
67
  "@types/node": "24.12.4",
66
68
  "tsx": "4.23.1",
67
69
  "typescript": "5.9.3"
@@ -0,0 +1,553 @@
1
+ /**
2
+ * 模型发现 provider 的 TUI 配置面板。
3
+ *
4
+ * 原来用 `ctx.ui.select` 摆一串「id — baseUrl(api)」文本,看不出哪行是状态、按下去会发生什么。
5
+ * 这里改用 Pi 自带 `SettingsList`:左边字段名、右边当前值、选中项下方一行说明。
6
+ * 面板分两层:第一层列出所有带 discoverModels 标记的 provider,回车进入某个 provider 的第二层,
7
+ * 第二层就是这个 provider 的字段(发现开关、baseUrl、api、apiKey、显示名)和动作(立即发现、删除)。
8
+ *
9
+ * 枚举项回车开二级列表,自由文本回车开预填当前值的 Input 子菜单;改一项先写回内存配置,
10
+ * 由调用方负责落盘与运行期生效,不需要 /reload。
11
+ */
12
+
13
+ import {
14
+ getSelectListTheme,
15
+ getSettingsListTheme,
16
+ type ExtensionCommandContext,
17
+ } from "@earendil-works/pi-coding-agent";
18
+ import {
19
+ Container,
20
+ type Component,
21
+ Input,
22
+ SelectList,
23
+ SettingsList,
24
+ type SelectItem,
25
+ type SettingItem,
26
+ Text,
27
+ } from "@earendil-works/pi-tui";
28
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
29
+
30
+ const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
31
+
32
+ /** `models.json` 里 api 类型的候选值。 */
33
+ export const API_CHOICES = [
34
+ "openai-completions",
35
+ "anthropic-messages",
36
+ "openai-responses",
37
+ "google-generative-ai",
38
+ ] as const;
39
+
40
+ /** 某个 provider 在面板上的可编辑字段名。 */
41
+ export const PROVIDER_FIELD = {
42
+ /** 是否参与模型发现;关闭时只去掉 discoverModels 标记,provider 本身保留。 */
43
+ enabled: "enabled",
44
+ /** 显示名。 */
45
+ name: "name",
46
+ /** API 地址。 */
47
+ baseUrl: "baseUrl",
48
+ /** api 类型。 */
49
+ api: "api",
50
+ /** API Key;支持 $ENV_VAR 插值。 */
51
+ apiKey: "apiKey",
52
+ /** 动作:立刻请求 {baseUrl}/models 刷新模型。 */
53
+ rediscover: "rediscover",
54
+ /** 动作:从 models.json 删除该 provider。 */
55
+ remove: "remove",
56
+ } as const;
57
+
58
+ /** provider 面板字段名联合类型。 */
59
+ export type ProviderFieldId = (typeof PROVIDER_FIELD)[keyof typeof PROVIDER_FIELD];
60
+
61
+ /** 面板顶层:新增 provider 的入口字段名。 */
62
+ export const ROOT_FIELD = {
63
+ /** 添加 provider。 */
64
+ add: "add",
65
+ } as const;
66
+
67
+ /**
68
+ * 面板覆盖的全部字段名(顶层 + provider 层),测试用它核对没有漏掉可编辑字段。
69
+ * provider 层字段名带 `provider.` 前缀,与顶层区分开。
70
+ */
71
+ export const PANEL_FIELD_IDS: readonly string[] = [
72
+ ROOT_FIELD.add,
73
+ ...Object.values(PROVIDER_FIELD).map((field) => `provider.${field}`),
74
+ ];
75
+
76
+ /** 面板里一个 provider 的读形态。 */
77
+ export interface PanelProvider {
78
+ /** provider id,也是 models.json 里的键。 */
79
+ id: string;
80
+ /** baseUrl。 */
81
+ baseUrl: string;
82
+ /** api 类型。 */
83
+ api: string;
84
+ /** apiKey;空串表示未设置。 */
85
+ apiKey: string;
86
+ /** 显示名;空串表示未设置(回落为 id)。 */
87
+ name: string;
88
+ /** 是否带 discoverModels 标记。 */
89
+ enabled: boolean;
90
+ }
91
+
92
+ /** 开关在面板上显示的两个文案。 */
93
+ function toggleLabels(): { on: string; off: string } {
94
+ return { on: i18n.t("panelOn"), off: i18n.t("panelOff") };
95
+ }
96
+
97
+ /** 下拉列表的一项:label 是展示文本,value 是写回配置的值。 */
98
+ interface PanelOption {
99
+ /** 展示文本。 */
100
+ label: string;
101
+ /** 写回配置的值。 */
102
+ value: string;
103
+ }
104
+
105
+ /** 二级列表最多同时显示几行;超出的部分由 SelectList 滚动。 */
106
+ const CHOICE_MENU_MAX_VISIBLE = 10;
107
+
108
+ /** provider 面板的行顺序:开关在最前,动作用特殊后缀区分。 */
109
+ const PROVIDER_ROWS: readonly {
110
+ /** 字段名。 */
111
+ id: ProviderFieldId;
112
+ /** 取值方式:开关原地切换,枚举开列表,文本开输入框,动作直接执行。 */
113
+ kind: "toggle" | "choice" | "text" | "action";
114
+ /** 标题文案 key。 */
115
+ labelKey: string;
116
+ /** 说明文案 key。 */
117
+ descriptionKey: string;
118
+ }[] = [
119
+ { id: PROVIDER_FIELD.enabled, kind: "toggle", labelKey: "panelLabelEnabled", descriptionKey: "panelDescEnabled" },
120
+ { id: PROVIDER_FIELD.name, kind: "text", labelKey: "panelLabelName", descriptionKey: "panelDescName" },
121
+ { id: PROVIDER_FIELD.baseUrl, kind: "text", labelKey: "panelLabelBaseUrl", descriptionKey: "panelDescBaseUrl" },
122
+ { id: PROVIDER_FIELD.api, kind: "choice", labelKey: "panelLabelApi", descriptionKey: "panelDescApi" },
123
+ { id: PROVIDER_FIELD.apiKey, kind: "text", labelKey: "panelLabelApiKey", descriptionKey: "panelDescApiKey" },
124
+ { id: PROVIDER_FIELD.rediscover, kind: "action", labelKey: "panelLabelRediscover", descriptionKey: "panelDescRediscover" },
125
+ { id: PROVIDER_FIELD.remove, kind: "action", labelKey: "panelLabelRemove", descriptionKey: "panelDescRemove" },
126
+ ];
127
+
128
+ /** 按配置值取展示文本;不在候选表里就直接显示原值。 */
129
+ export function optionLabelForValue(options: readonly PanelOption[], value: string): string {
130
+ return options.find((option) => option.value === value)?.label ?? value;
131
+ }
132
+
133
+ /** 按展示文本反查写回值;找不到返回 undefined,由调用方保持不变。 */
134
+ export function optionValueFromLabel(
135
+ options: readonly PanelOption[],
136
+ label: string,
137
+ ): string | undefined {
138
+ return options.find((option) => option.label === label)?.value;
139
+ }
140
+
141
+ /** 开关候选项。 */
142
+ function toggleOptions(): PanelOption[] {
143
+ const labels = toggleLabels();
144
+ return [
145
+ { label: labels.on, value: "on" },
146
+ { label: labels.off, value: "off" },
147
+ ];
148
+ }
149
+
150
+ /** api 类型候选项。 */
151
+ function apiOptions(): PanelOption[] {
152
+ return API_CHOICES.map((value) => ({ label: value, value }));
153
+ }
154
+
155
+ /** 某个字段的候选表;文本和动作字段没有候选项。 */
156
+ function fieldOptions(id: ProviderFieldId): PanelOption[] {
157
+ if (id === PROVIDER_FIELD.enabled) return toggleOptions();
158
+ if (id === PROVIDER_FIELD.api) return apiOptions();
159
+ return [];
160
+ }
161
+
162
+ /** 读取一个字段当前值的机器形态。 */
163
+ function readProviderField(provider: PanelProvider, id: ProviderFieldId): string {
164
+ switch (id) {
165
+ case PROVIDER_FIELD.enabled:
166
+ return provider.enabled ? "on" : "off";
167
+ case PROVIDER_FIELD.name:
168
+ return provider.name;
169
+ case PROVIDER_FIELD.baseUrl:
170
+ return provider.baseUrl;
171
+ case PROVIDER_FIELD.api:
172
+ return provider.api;
173
+ case PROVIDER_FIELD.apiKey:
174
+ return provider.apiKey;
175
+ default:
176
+ return "";
177
+ }
178
+ }
179
+
180
+ /** 面板上显示的当前值;文本字段留空时显示「未设置」。 */
181
+ function displayProviderValue(provider: PanelProvider, id: ProviderFieldId): string {
182
+ const stored = readProviderField(provider, id);
183
+ if (id === PROVIDER_FIELD.enabled) {
184
+ const labels = toggleLabels();
185
+ return stored === "on" ? labels.on : labels.off;
186
+ }
187
+ if (id === PROVIDER_FIELD.api) return optionLabelForValue(apiOptions(), stored);
188
+ if (id === PROVIDER_FIELD.apiKey || id === PROVIDER_FIELD.name) {
189
+ return stored || i18n.t("panelValueUnset");
190
+ }
191
+ return stored;
192
+ }
193
+
194
+ /**
195
+ * 把面板回传的展示文本写回 provider。
196
+ *
197
+ * 返回 undefined 表示这一项不该由面板改(未知字段,或候选表里没有这个标签),
198
+ * 调用方保持原值。动作字段(rediscover/remove)不走这里,由面板单独处理。
199
+ */
200
+ export function applyProviderChange(
201
+ provider: PanelProvider,
202
+ id: string,
203
+ value: string,
204
+ ): PanelProvider | undefined {
205
+ const labels = toggleLabels();
206
+ switch (id) {
207
+ case PROVIDER_FIELD.enabled: {
208
+ if (value !== labels.on && value !== labels.off) return undefined;
209
+ return { ...provider, enabled: value === labels.on };
210
+ }
211
+ case PROVIDER_FIELD.name:
212
+ return { ...provider, name: value.trim() };
213
+ case PROVIDER_FIELD.baseUrl:
214
+ return { ...provider, baseUrl: value.trim() };
215
+ case PROVIDER_FIELD.api: {
216
+ const api = optionValueFromLabel(apiOptions(), value);
217
+ return api === undefined ? undefined : { ...provider, api };
218
+ }
219
+ case PROVIDER_FIELD.apiKey:
220
+ return { ...provider, apiKey: value.trim() };
221
+ default:
222
+ return undefined;
223
+ }
224
+ }
225
+
226
+ /** 自由文本子菜单:一个预填当前值的输入框。 */
227
+ function createTextSubmenu(
228
+ value: string,
229
+ placeholder: string,
230
+ done: (value?: string) => void,
231
+ ): Component {
232
+ const input = new Input({ prompt: `${i18n.t("panelInputPrompt")} `, placeholder });
233
+ input.setValue(value);
234
+ input.focused = true;
235
+ input.onSubmit = (submitted) => done(submitted);
236
+ input.onEscape = () => done(undefined);
237
+ return {
238
+ /** 一行可编辑文本。 */
239
+ render: (width) => input.render(width),
240
+ /** 无本地缓存。 */
241
+ invalidate: () => input.invalidate(),
242
+ /** 按键全部交给输入框。 */
243
+ handleInput: (data) => input.handleInput(data),
244
+ };
245
+ }
246
+
247
+ /** 二级选择列表:Enter 选定并回传展示文本,Esc 不改动直接返回。 */
248
+ function createChoiceSubmenu(
249
+ options: readonly PanelOption[],
250
+ currentLabel: string,
251
+ done: (selectedLabel?: string) => void,
252
+ ): Component {
253
+ const items: SelectItem[] = options.map((option) => ({ value: option.label, label: option.label }));
254
+ const list = new SelectList(
255
+ items,
256
+ Math.max(1, Math.min(items.length, CHOICE_MENU_MAX_VISIBLE)),
257
+ getSelectListTheme(),
258
+ );
259
+ const currentIndex = options.findIndex((option) => option.label === currentLabel);
260
+ if (currentIndex >= 0) list.setSelectedIndex(currentIndex);
261
+ list.onSelect = (item) => done(item.value);
262
+ list.onCancel = () => done(undefined);
263
+ return {
264
+ /** 直接渲染候选列表。 */
265
+ render: (width) => list.render(width),
266
+ /** 无本地缓存。 */
267
+ invalidate: () => list.invalidate(),
268
+ /** 按键全部交给列表。 */
269
+ handleInput: (data) => list.handleInput(data),
270
+ };
271
+ }
272
+
273
+ /** provider 面板行的动作钩子:由入口注入,负责真正执行发现或删除。 */
274
+ export interface ProviderRowActions {
275
+ /** 立刻重新发现该 provider 的模型。 */
276
+ rediscover(provider: PanelProvider): Promise<void>;
277
+ /** 删除该 provider;返回 true 表示已删除,面板回到顶层。 */
278
+ remove(provider: PanelProvider): Promise<boolean>;
279
+ }
280
+
281
+ /**
282
+ * 把一行变成「Enter 就触发」的动作行。
283
+ *
284
+ * SettingsList 只在有 submenu 或 values 时才会触发 onChange,所以动作行给一个只有单值的
285
+ * values:Enter/空格 会把同一个值再报一次,等于「按下执行」,而不会改变任何配置。
286
+ */
287
+ function actionRow(item: SettingItem): SettingItem {
288
+ return { ...item, values: [item.currentValue] };
289
+ }
290
+
291
+ /** 构造 provider 层的设置行。 */
292
+ export function buildProviderItems(provider: PanelProvider): SettingItem[] {
293
+ return PROVIDER_ROWS.map((row) => {
294
+ const label = i18n.t(row.labelKey);
295
+ const description = i18n.t(row.descriptionKey);
296
+ const currentValue = displayProviderValue(provider, row.id);
297
+ if (row.kind === "toggle") {
298
+ return {
299
+ id: row.id,
300
+ label,
301
+ description,
302
+ currentValue,
303
+ values: toggleOptions().map((option) => option.label),
304
+ };
305
+ }
306
+ if (row.kind === "choice") {
307
+ return {
308
+ id: row.id,
309
+ label,
310
+ description,
311
+ currentValue,
312
+ // 二级列表接管 Enter,避免在少量候选之间反复循环。
313
+ submenu: (current, done) =>
314
+ createChoiceSubmenu(apiOptions(), current, done),
315
+ };
316
+ }
317
+ if (row.kind === "text") {
318
+ return {
319
+ id: row.id,
320
+ label,
321
+ description,
322
+ currentValue,
323
+ submenu: (_current, done) =>
324
+ createTextSubmenu(readProviderField(provider, row.id), description, done),
325
+ };
326
+ }
327
+ return actionRow({ id: row.id, label, description, currentValue: i18n.t("panelActionHint") });
328
+ });
329
+ }
330
+
331
+ /** 构造顶层的设置行:一个 provider 一行,外加「添加 provider」。 */
332
+ export function buildRootItems(providers: readonly PanelProvider[]): SettingItem[] {
333
+ const items: SettingItem[] = providers.map((provider) => actionRow({
334
+ id: `provider:${provider.id}`,
335
+ label: `${provider.enabled ? "●" : "○"} ${provider.id}`,
336
+ description: `${provider.baseUrl || "?"} · ${provider.api || "?"}`,
337
+ currentValue: provider.name || provider.id,
338
+ }));
339
+ items.push(actionRow({
340
+ id: ROOT_FIELD.add,
341
+ label: i18n.t("panelLabelAdd"),
342
+ description: i18n.t("panelDescAdd"),
343
+ currentValue: i18n.t("panelActionHint"),
344
+ }));
345
+ return items;
346
+ }
347
+
348
+ /** 面板与宿主之间的接口;provider 列表、写回动作都由入口提供。 */
349
+ export interface PanelState {
350
+ /** 当前所有带发现标记的 provider;每次现读,因为增删会改变列表。 */
351
+ getProviders(): PanelProvider[];
352
+ /** provider 配置被改动后调用;由调用方负责落盘与注册。 */
353
+ onProviderChange(provider: PanelProvider): void;
354
+ /** provider 被删除后调用。 */
355
+ onProviderRemoved(id: string): void;
356
+ /** 新增 provider;由调用方负责落盘与首次发现。 */
357
+ onProviderAdd(provider: PanelProvider): void;
358
+ /** 行动作。 */
359
+ actions: ProviderRowActions;
360
+ }
361
+
362
+ /** 顶层列表:选中 provider 进第二层,选中「添加」走新增流程。 */
363
+ async function openProviderPanel(
364
+ ctx: ExtensionCommandContext,
365
+ state: PanelState,
366
+ provider: PanelProvider,
367
+ ): Promise<"back" | "removed"> {
368
+ /** 当前这一层的 provider 副本;改动即时写回内存。 */
369
+ let current: PanelProvider = { ...provider };
370
+ /** 用户在子菜单里选择的动作结果。 */
371
+ let pending: { kind: "rediscover" } | { kind: "remove" } | undefined;
372
+
373
+ const settingsList = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
374
+ const container = new Container();
375
+ container.addChild(
376
+ new Text(theme.fg("accent", theme.bold(i18n.t("panelProviderTitle", { id: current.id }))), 1, 1),
377
+ );
378
+ const list = new SettingsList(
379
+ buildProviderItems(current),
380
+ PROVIDER_ROWS.length,
381
+ getSettingsListTheme(),
382
+ (id, newValue) => {
383
+ if (id === PROVIDER_FIELD.rediscover) {
384
+ pending = { kind: "rediscover" };
385
+ done(undefined);
386
+ return;
387
+ }
388
+ if (id === PROVIDER_FIELD.remove) {
389
+ pending = { kind: "remove" };
390
+ done(undefined);
391
+ return;
392
+ }
393
+ const next = applyProviderChange(current, id, newValue);
394
+ if (!next) return;
395
+ current = next;
396
+ state.onProviderChange(current);
397
+ list.updateValue(id, displayProviderValue(current, id as ProviderFieldId));
398
+ },
399
+ () => done(undefined),
400
+ );
401
+ container.addChild(list);
402
+ return {
403
+ /** 布局交给 Container。 */
404
+ render: (width) => container.render(width),
405
+ /** 无本地缓存。 */
406
+ invalidate: () => container.invalidate(),
407
+ /** 按键交给列表,并触发一次重绘。 */
408
+ handleInput: (data) => {
409
+ list.handleInput(data);
410
+ tui.requestRender();
411
+ },
412
+ };
413
+ });
414
+
415
+ if (pending?.kind === "rediscover") {
416
+ await state.actions.rediscover(current);
417
+ return openProviderPanel(ctx, state, current);
418
+ }
419
+ if (pending?.kind === "remove") {
420
+ const confirmed = await ctx.ui.confirm(
421
+ i18n.t("panelConfirmRemove", { id: current.id }),
422
+ i18n.t("panelRemoveMessage"),
423
+ );
424
+ if (confirmed && (await state.actions.remove(current))) {
425
+ state.onProviderRemoved(current.id);
426
+ return "removed";
427
+ }
428
+ return openProviderPanel(ctx, state, current);
429
+ }
430
+ return "back";
431
+ }
432
+
433
+ /** 新增 provider 的表单结果。 */
434
+ interface NewProviderInput {
435
+ /** provider id。 */
436
+ id: string;
437
+ /** baseUrl。 */
438
+ baseUrl: string;
439
+ /** api 类型。 */
440
+ api: string;
441
+ /** apiKey。 */
442
+ apiKey: string;
443
+ /** 显示名。 */
444
+ name: string;
445
+ }
446
+
447
+ /** 提示并校验一个值;返回 undefined 表示用户取消或校验失败(失败已提示)。 */
448
+ async function askValidated(
449
+ ctx: ExtensionCommandContext,
450
+ options: {
451
+ /** 输入框标题。 */
452
+ title: string;
453
+ /** 预填值。 */
454
+ initial?: string;
455
+ /** 校验函数;通过返回 undefined,失败返回错误文案 key 的参数。 */
456
+ validate(value: string): { key: string; params?: Record<string, string> } | undefined;
457
+ },
458
+ ): Promise<string | undefined> {
459
+ const value = (await ctx.ui.input(options.title, options.initial))?.trim();
460
+ if (value === undefined) return undefined;
461
+ if (!value) return undefined;
462
+ const failure = options.validate(value);
463
+ if (failure) {
464
+ ctx.ui.notify(i18n.t(failure.key, failure.params), "error");
465
+ return undefined;
466
+ }
467
+ return value;
468
+ }
469
+
470
+ /** 逐项问出新增 provider 需要的字段;任一项取消就整体放弃。 */
471
+ async function collectNewProvider(
472
+ ctx: ExtensionCommandContext,
473
+ existingIds: ReadonlySet<string>,
474
+ ): Promise<NewProviderInput | undefined> {
475
+ const id = await askValidated(ctx, {
476
+ title: i18n.t("providerId"),
477
+ validate: (value) => {
478
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(value)) return { key: "invalidId" };
479
+ if (existingIds.has(value)) return { key: "exists", params: { id: value } };
480
+ return undefined;
481
+ },
482
+ });
483
+ if (!id) return undefined;
484
+ const baseUrl = await askValidated(ctx, {
485
+ title: i18n.t("baseUrl"),
486
+ validate: (value) => (/^https?:\/\//.test(value) ? undefined : { key: "invalidUrl" }),
487
+ });
488
+ if (!baseUrl) return undefined;
489
+ const api = await ctx.ui.select(i18n.t("api"), [...API_CHOICES]);
490
+ if (!api) return undefined;
491
+ const apiKey = (await ctx.ui.input(i18n.t("apiKey")))?.trim() ?? "";
492
+ const name = (await ctx.ui.input(i18n.t("displayName", { id })))?.trim() ?? "";
493
+ return { id, baseUrl, api, apiKey, name };
494
+ }
495
+
496
+ /** 打开 /config:model-discovery 面板;Esc 关闭,改动已经即时写回。 */
497
+ export async function openDiscoveryPanel(
498
+ ctx: ExtensionCommandContext,
499
+ state: PanelState,
500
+ ): Promise<void> {
501
+ while (true) {
502
+ const providers = state.getProviders();
503
+ const selection = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
504
+ const container = new Container();
505
+ container.addChild(
506
+ new Text(theme.fg("accent", theme.bold(i18n.t("panelTitle", { count: providers.length }))), 1, 1),
507
+ );
508
+ const items = buildRootItems(providers);
509
+ const list = new SettingsList(
510
+ items,
511
+ Math.max(1, items.length),
512
+ getSettingsListTheme(),
513
+ (id) => done(id),
514
+ () => done(undefined),
515
+ );
516
+ container.addChild(list);
517
+ return {
518
+ /** 布局交给 Container。 */
519
+ render: (width) => container.render(width),
520
+ /** 无本地缓存。 */
521
+ invalidate: () => container.invalidate(),
522
+ /** 按键交给列表,并触发一次重绘。 */
523
+ handleInput: (data) => {
524
+ list.handleInput(data);
525
+ tui.requestRender();
526
+ },
527
+ };
528
+ });
529
+
530
+ if (!selection) return;
531
+ if (selection === ROOT_FIELD.add) {
532
+ const existingIds = new Set(state.getProviders().map((provider) => provider.id));
533
+ const input = await collectNewProvider(ctx, existingIds);
534
+ if (!input) continue;
535
+ const provider: PanelProvider = {
536
+ id: input.id,
537
+ baseUrl: input.baseUrl,
538
+ api: input.api,
539
+ apiKey: input.apiKey,
540
+ name: input.name,
541
+ enabled: true,
542
+ };
543
+ state.onProviderAdd(provider);
544
+ continue;
545
+ }
546
+ if (!selection.startsWith("provider:")) continue;
547
+ const id = selection.slice("provider:".length);
548
+ const provider = state.getProviders().find((candidate) => candidate.id === id);
549
+ if (!provider) continue;
550
+ const result = await openProviderPanel(ctx, state, provider);
551
+ if (result === "removed") continue;
552
+ }
553
+ }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * 通用模型发现插件
3
3
  *
4
4
  * 读取 models.json 中带 "discoverModels": true 的 provider,请求
5
- * GET {baseUrl}/models 自动发现模型并注册,无需手写 models 数组。
5
+ * GET modelsUrl(缺省为 {baseUrl}/models)自动发现模型并注册,无需手写 models 数组。
6
6
  *
7
7
  * 配置方式(二选一):
8
8
  * 1. pi 终端内执行 /config:model-discovery,交互式添加/删除/重新发现 provider(推荐);
@@ -22,14 +22,14 @@
22
22
  * 行为约定:
23
23
  * - 首次发现成功后,模型列表持久化到 ~/.pi/agent/extensions/pi-models-discovery/cache.json;
24
24
  * 之后每次启动直接读缓存注册,不请求网络。配置指纹
25
- * (baseUrl+api+apiKey+headers+compat)变化时缓存自动失效,重新走网络发现。
25
+ * (baseUrl+api+apiKey+headers+compat+modelsUrl)变化时缓存自动失效,重新走网络发现。
26
26
  * - /config:model-discovery-refresh 强制重新拉取所有发现 provider 并更新缓存;
27
27
  * /model 打开时触发的在线 refreshModels 同样走网络并同步更新缓存。
28
28
  * - baseUrl / api 由扩展显式转发(pi 的 extension 组合层要求),
29
29
  * apiKey / name / headers / compat 不写回注册配置,由 pi 的 models.json 层回落生效。
30
30
  * - provider 级 compat 会被合并进每个发现的模型(pi 的 models.json provider 级 compat
31
31
  * 不作用于 extension 注册的模型,故在此转发)。
32
- * - 同一 baseUrl+apiKey+headers 的多个 provider 共享一次 /models 请求。
32
+ * - 同一模型列表 URL+apiKey+headers+compat 的多个 provider 共享一次发现请求。
33
33
  * - 发现失败:该 provider 保留 models.json 手写 models(如有,作为离线回退),
34
34
  * 并通过 notify 显式警告,不静默降级;单个 provider 失败不影响其他 provider 注册。
35
35
  * - 注册 refreshModels:打开 /model 触发在线刷新时重新发现;
@@ -52,6 +52,7 @@ import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-codin
52
52
  import { mkdir, readFile, writeFile } from "node:fs/promises";
53
53
  import { dirname, join } from "node:path";
54
54
  import { NOTICE_TAG_COLOR, createTranslator, installNoticeRenderer, loadCatalog, notifyWithSource, type NoticeColor, type NoticeSource } from "pi-extensions-i18n";
55
+ import { openDiscoveryPanel, type PanelProvider } from "./config-panel.ts";
55
56
 
56
57
  const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
57
58
 
@@ -64,7 +65,7 @@ const NOTICE_TAG = "models";
64
65
  const NOTICE_COLOR: NoticeColor = NOTICE_TAG_COLOR;
65
66
  /** 本扩展的提示来源。 */
66
67
  const NOTICE_SOURCE: NoticeSource = { tag: NOTICE_TAG, color: NOTICE_COLOR };
67
- const API_CHOICES = ["openai-completions", "anthropic-messages", "openai-responses", "google-generative-ai"] as const;
68
+
68
69
 
69
70
  /** 用户可见消息(级别与 ctx.ui.notify 的 type 对齐);供通知中继与无 ctx 的收集队列共用 */
70
71
  interface Notice {
@@ -80,10 +81,13 @@ interface DiscoveryProviderEntry {
80
81
  id: string;
81
82
  name?: string;
82
83
  baseUrl?: string;
84
+ modelsUrl?: string;
83
85
  apiKey?: string;
84
86
  api?: string;
85
87
  headers?: Record<string, string>;
86
88
  compat?: Record<string, unknown>;
89
+ /** 发现标记;面板关闭发现时写 false(实际会删掉这个键)。 */
90
+ discoverModels?: boolean;
87
91
  }
88
92
 
89
93
  interface ModelsResponse {
@@ -132,6 +136,8 @@ function providerFingerprint(entry: DiscoveryProviderEntry): string {
132
136
  entry.apiKey ?? "",
133
137
  entry.headers ?? {},
134
138
  entry.compat ?? {},
139
+ // 未配置 modelsUrl 时保留已有指纹,避免让旧缓存失效。
140
+ ...(entry.modelsUrl ? [entry.modelsUrl] : []),
135
141
  ]);
136
142
  }
137
143
 
@@ -247,6 +253,7 @@ function pickDiscoveryProviders(data: Record<string, unknown>): DiscoveryProvide
247
253
  id,
248
254
  name: typeof value.name === "string" ? value.name : undefined,
249
255
  baseUrl: typeof value.baseUrl === "string" ? value.baseUrl : undefined,
256
+ modelsUrl: typeof value.modelsUrl === "string" ? value.modelsUrl : undefined,
250
257
  apiKey: typeof value.apiKey === "string" ? value.apiKey : undefined,
251
258
  api: typeof value.api === "string" ? value.api : undefined,
252
259
  headers:
@@ -316,7 +323,12 @@ export function buildModel(
316
323
  };
317
324
  }
318
325
 
319
- /** 请求 {baseUrl}/models 并解析为模型配置;失败抛错(错误信息带原因) */
326
+ /** 模型列表的完整 URL;未配置时沿用 {baseUrl}/models。 */
327
+ function getModelsUrl(entry: DiscoveryProviderEntry): string {
328
+ return entry.modelsUrl || `${(entry.baseUrl ?? "").replace(/\/+$/, "")}/models`;
329
+ }
330
+
331
+ /** 请求模型列表并解析为模型配置;失败抛错(错误信息带原因) */
320
332
  async function fetchModels(
321
333
  entry: DiscoveryProviderEntry,
322
334
  notices: Notice[],
@@ -345,7 +357,7 @@ async function fetchModels(
345
357
  headers.Authorization = `Bearer ${resolved.value}`;
346
358
  }
347
359
  }
348
- const url = `${entry.baseUrl.replace(/\/+$/, "")}/models`;
360
+ const url = getModelsUrl(entry);
349
361
  let response: Response;
350
362
  try {
351
363
  response = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
@@ -374,11 +386,11 @@ async function fetchModels(
374
386
  return models;
375
387
  }
376
388
 
377
- /** 共享 /models 请求的缓存:同一 baseUrl+apiKey+headers 只拉一次 */
389
+ /** 缓存的是含 compat 的模型配置,只有 URL+apiKey+headers+compat 相同才可复用。 */
378
390
  type FetchCache = Map<string, Promise<ProviderModelConfig[]>>;
379
391
 
380
392
  function fetchWithCache(cache: FetchCache, entry: DiscoveryProviderEntry, notices: Notice[]): Promise<ProviderModelConfig[]> {
381
- const cacheKey = `${entry.baseUrl}\n${entry.apiKey ?? ""}\n${JSON.stringify(entry.headers ?? {})}`;
393
+ const cacheKey = JSON.stringify([getModelsUrl(entry), entry.apiKey ?? "", entry.headers ?? {}, entry.compat ?? {}]);
382
394
  let pending = cache.get(cacheKey);
383
395
  if (!pending) {
384
396
  pending = fetchModels(entry, notices);
@@ -484,41 +496,220 @@ function registerFromCache(
484
496
  });
485
497
  }
486
498
 
499
+ /**
500
+ * 把带 discoverModels 标记的 provider 转成面板读的形态。
501
+ * 面板不直接碰磁盘,改完由 persistProvider 写回 models.json。
502
+ */
503
+ function toPanelProvider(entry: DiscoveryProviderEntry): PanelProvider {
504
+ return {
505
+ id: entry.id,
506
+ baseUrl: entry.baseUrl ?? "",
507
+ api: entry.api ?? "",
508
+ apiKey: entry.apiKey ?? "",
509
+ name: entry.name ?? "",
510
+ enabled: true,
511
+ };
512
+ }
513
+
514
+ /** 读取当前 models.json 里所有带发现标记的 provider,供面板渲染。 */
515
+ async function readPanelProviders(): Promise<{ providers: PanelProvider[]; error: string | null }> {
516
+ const { data, error } = await readModelsFile();
517
+ if (error) return { providers: [], error };
518
+ return { providers: pickDiscoveryProviders(data).map(toPanelProvider), error: null };
519
+ }
520
+
521
+ /**
522
+ * 面板改一个 provider 的字段后写回 models.json。
523
+ * 沿用原条目的其余字段(headers/compat 等面板不管的键),只覆盖面板能改的四项。
524
+ */
525
+ async function persistProvider(provider: PanelProvider): Promise<{ backup: string }> {
526
+ const { data, error } = await readModelsFile();
527
+ if (error) throw new Error(error);
528
+ const providers = (data.providers ?? {}) as Record<string, Record<string, unknown>>;
529
+ const existing = providers[provider.id];
530
+ if (!existing) throw new Error(i18n.t("panelProviderMissing", { id: provider.id }));
531
+ const entry: Record<string, unknown> = { ...existing, baseUrl: provider.baseUrl, api: provider.api };
532
+ if (provider.enabled) entry[DISCOVERY_MARKER] = true;
533
+ else delete entry[DISCOVERY_MARKER];
534
+ if (provider.apiKey) entry.apiKey = provider.apiKey;
535
+ else delete entry.apiKey;
536
+ if (provider.name) entry.name = provider.name;
537
+ else delete entry.name;
538
+ providers[provider.id] = entry;
539
+ data.providers = providers;
540
+ return writeModelsFile(data);
541
+ }
542
+
543
+ /** 新增 provider 时写入 models.json 的条目。 */
544
+ async function persistNewProvider(provider: PanelProvider): Promise<{ backup: string }> {
545
+ const { data, error } = await readModelsFile();
546
+ if (error) throw new Error(error);
547
+ const providers = (data.providers ?? {}) as Record<string, Record<string, unknown>>;
548
+ const entry: Record<string, unknown> = {
549
+ baseUrl: provider.baseUrl,
550
+ api: provider.api,
551
+ [DISCOVERY_MARKER]: true,
552
+ };
553
+ if (provider.apiKey) entry.apiKey = provider.apiKey;
554
+ if (provider.name) entry.name = provider.name;
555
+ providers[provider.id] = entry;
556
+ data.providers = providers;
557
+ return writeModelsFile(data);
558
+ }
559
+
560
+ /** 从 models.json 删掉一个 provider 条目。 */
561
+ async function deleteProvider(id: string): Promise<{ backup: string }> {
562
+ const { data, error } = await readModelsFile();
563
+ if (error) throw new Error(error);
564
+ const providers = (data.providers ?? {}) as Record<string, Record<string, unknown>>;
565
+ delete providers[id];
566
+ data.providers = providers;
567
+ return writeModelsFile(data);
568
+ }
569
+
570
+ /**
571
+ * 用 models.json 当前内容重新注册一个 provider,行为与启动期一致:
572
+ * 指纹命中缓存就直接注册缓存里的模型,不请求网络;否则走在线发现。
573
+ */
574
+ async function reloadProvider(options: {
575
+ /** 扩展 API,用来注册或注销 provider。 */
576
+ pi: ExtensionAPI;
577
+ /** models.json 中读到的 provider 条目。 */
578
+ entry: DiscoveryProviderEntry;
579
+ /** 本次会话共享的 /models 请求缓存。 */
580
+ fetchCache: FetchCache;
581
+ /** 收集过程中产生的提示,由调用方决定发给谁。 */
582
+ notices: Notice[];
583
+ }): Promise<void> {
584
+ const { pi, entry, fetchCache, notices } = options;
585
+ if (entry.discoverModels === false) {
586
+ pi.unregisterProvider(entry.id);
587
+ return;
588
+ }
589
+ const cache = await readCache(notices);
590
+ const cached = cache.providers[entry.id];
591
+ if (
592
+ cached &&
593
+ cached.fingerprint === providerFingerprint(entry) &&
594
+ Array.isArray(cached.models) &&
595
+ cached.models.length > 0
596
+ ) {
597
+ registerFromCache(pi, entry, cached.models, notices);
598
+ return;
599
+ }
600
+ await discoverAndRegister(pi, entry, fetchCache, notices);
601
+ }
602
+
603
+ /** 按 id 从 models.json 现读一个 provider 条目;没有发现标记时返回 undefined。 */
604
+ async function readProviderEntry(id: string): Promise<DiscoveryProviderEntry | undefined> {
605
+ const { data } = await readModelsFile();
606
+ return pickDiscoveryProviders(data).find((entry) => entry.id === id);
607
+ }
608
+
487
609
  /** /config:model-discovery 交互式配置命令;旧名称保留为兼容别名。 */
488
610
  function registerDiscoveryCommand(pi: ExtensionAPI, fetchCache: FetchCache, sink: NoticeSink) {
611
+ /** 面板读 provider 列表、写回 models.json,并把改动即时注册到运行期。 */
612
+ const openPanel = async (ctx: CommandCtx): Promise<void> => {
613
+ const first = await readPanelProviders();
614
+ if (first.error) {
615
+ sink({ level: "error", message: `${LOG_PREFIX} ${first.error}` }, ctx);
616
+ return;
617
+ }
618
+ let providers = first.providers;
619
+ /**
620
+ * 变更队列。
621
+ *
622
+ * SettingsList 的 onChange 是同步回调,无法 await,所以写盘+重新注册按顺序排成一条链;
623
+ * 面板关闭后等这条链清空,保证最后一次改动已落盘并已注册(不静默丢改动)。
624
+ */
625
+ let pendingChange: Promise<void> = Promise.resolve();
626
+ /** 把一个变更追加到队尾,保证先后顺序与面板操作一致。 */
627
+ const enqueue = (change: () => Promise<void>): void => {
628
+ pendingChange = pendingChange.then(change);
629
+ };
630
+ await openDiscoveryPanel(ctx, {
631
+ getProviders: () => providers,
632
+ onProviderChange: (provider) => {
633
+ // 面板下一次读到的必须是刚改过的值,所以内存副本同步更新,落盘排队跟上。
634
+ providers = providers.map((current) => (current.id === provider.id ? provider : current));
635
+ enqueue(async () => {
636
+ const notices: Notice[] = [];
637
+ try {
638
+ await persistProvider(provider);
639
+ } catch (err) {
640
+ flushNotices(ctx, [{
641
+ level: "error",
642
+ message: `${LOG_PREFIX} ${err instanceof Error ? err.message : String(err)}`,
643
+ }]);
644
+ return;
645
+ }
646
+ const entry = await readProviderEntry(provider.id);
647
+ if (!entry) {
648
+ pi.unregisterProvider(provider.id);
649
+ } else {
650
+ await reloadProvider({ pi, entry, fetchCache, notices });
651
+ }
652
+ // 面板行上的当前值来自本地副本,网络提示照常发出来,不静默降级。
653
+ flushNotices(ctx, notices);
654
+ });
655
+ },
656
+ onProviderRemoved: (id) => {
657
+ providers = providers.filter((provider) => provider.id !== id);
658
+ },
659
+ onProviderAdd: (provider) => {
660
+ providers = [...providers, provider];
661
+ enqueue(() => addProviderFromPanel({ pi, ctx, provider, fetchCache, sink }));
662
+ },
663
+ actions: {
664
+ rediscover: (provider) => {
665
+ // 也走同一条队列:保证“改字段”与“立即重新发现”不会交叠写盘。
666
+ enqueue(async () => {
667
+ const entry = await readProviderEntry(provider.id) ?? {
668
+ id: provider.id,
669
+ baseUrl: provider.baseUrl,
670
+ api: provider.api,
671
+ apiKey: provider.apiKey || undefined,
672
+ name: provider.name || undefined,
673
+ };
674
+ const notices: Notice[] = [];
675
+ const result = await discoverAndRegister(pi, entry, fetchCache, notices);
676
+ flushNotices(ctx, notices);
677
+ if (result) {
678
+ sink({ level: "info", message: i18n.t("rediscovered", { id: provider.id, count: result.count }) }, ctx);
679
+ }
680
+ });
681
+ return pendingChange;
682
+ },
683
+ remove: async (provider) => {
684
+ /** 删除结果;由队列里的那段代码写入。 */
685
+ let removed = false;
686
+ enqueue(async () => {
687
+ try {
688
+ const { backup } = await deleteProvider(provider.id);
689
+ pi.unregisterProvider(provider.id);
690
+ const notices: Notice[] = [];
691
+ await removeCachedModels(provider.id, notices);
692
+ flushNotices(ctx, notices);
693
+ sink({ level: "info", message: i18n.t("removed", { id: provider.id, backup }) }, ctx);
694
+ removed = true;
695
+ } catch (err) {
696
+ sink({ level: "error", message: `${LOG_PREFIX} ${err instanceof Error ? err.message : String(err)}` }, ctx);
697
+ }
698
+ });
699
+ await pendingChange;
700
+ return removed;
701
+ },
702
+ },
703
+ });
704
+ // 面板已关,但队列里可能还有写盘与重新注册;等它们完成再返回。
705
+ await pendingChange;
706
+ };
707
+
489
708
  const command = {
490
709
  description: i18n.t("commandDescription"),
491
710
  handler: async (_args, ctx) => {
492
711
  if (!ctx.hasUI) return;
493
- while (true) {
494
- const { data, error } = await readModelsFile();
495
- if (error) {
496
- sink({ level: "error", message: `${LOG_PREFIX} ${error}` }, ctx);
497
- return;
498
- }
499
- const providers = pickDiscoveryProviders(data);
500
- const ADD = i18n.t("add");
501
- const EXIT = i18n.t("exit");
502
- const choices = [
503
- ...providers.map((p) => `${p.id} — ${p.baseUrl ?? "?"}(${p.api ?? "?"})`),
504
- ADD,
505
- EXIT,
506
- ];
507
- const choice = await ctx.ui.select(
508
- providers.length > 0 ? i18n.t("title", { count: providers.length }) : i18n.t("emptyTitle"),
509
- choices,
510
- );
511
- if (choice === undefined || choice === EXIT) return;
512
-
513
- if (choice === ADD) {
514
- await addProviderFlow(pi, ctx, data, fetchCache, sink);
515
- continue;
516
- }
517
- const selected = providers[choices.indexOf(choice)];
518
- if (selected) {
519
- await manageProviderFlow(pi, ctx, data, selected, fetchCache, sink);
520
- }
521
- }
712
+ await openPanel(ctx);
522
713
  },
523
714
  };
524
715
  for (const name of ["config:model-discovery", "model-discovery", "pi-model-discovery"] as const) {
@@ -542,7 +733,7 @@ function registerRefreshCommand(pi: ExtensionAPI, sink: NoticeSink) {
542
733
  sink({ level: "info", message: i18n.t("refreshEmpty") }, ctx);
543
734
  return;
544
735
  }
545
- // 每次刷新用新的请求级缓存:同 baseUrl 的 provider 仍共享一次请求,但不复用启动期结果
736
+ // 每次刷新用新的请求级缓存:同模型列表 URL 的 provider 仍共享请求,但不复用启动期结果
546
737
  const fetchCache: FetchCache = new Map();
547
738
  let ok = 0;
548
739
  for (const entry of providers) {
@@ -581,104 +772,45 @@ function flushNotices(ctx: CommandCtx, notices: Notice[]): void {
581
772
  for (const notice of notices) notifyWithSource({ ctx, source: NOTICE_SOURCE, level: notice.level, message: notice.message });
582
773
  }
583
774
 
584
- /** /config:model-discovery 添加 provider 交互流程 */
585
- async function addProviderFlow(pi: ExtensionAPI, ctx: CommandCtx, data: Record<string, unknown>, fetchCache: FetchCache, sink: NoticeSink) {
586
- const existingIds = new Set(Object.keys((data.providers ?? {}) as Record<string, unknown>));
587
- const id = (await ctx.ui.input(i18n.t("providerId")))?.trim();
588
- if (!id) return;
589
- if (!/^[a-z0-9][a-z0-9-]*$/i.test(id)) {
590
- sink({ level: "error", message: i18n.t("invalidId") }, ctx);
591
- return;
592
- }
593
- if (existingIds.has(id)) {
594
- sink({ level: "error", message: i18n.t("exists", { id }) }, ctx);
595
- return;
596
- }
597
- const baseUrl = (await ctx.ui.input(i18n.t("baseUrl")))?.trim();
598
- if (!baseUrl) return;
599
- if (!/^https?:\/\//.test(baseUrl)) {
600
- sink({ level: "error", message: i18n.t("invalidUrl") }, ctx);
601
- return;
602
- }
603
- const api = await ctx.ui.select(i18n.t("api"), [...API_CHOICES]);
604
- if (!api) return;
605
- const apiKey = (await ctx.ui.input(i18n.t("apiKey")))?.trim();
606
- const name = (await ctx.ui.input(i18n.t("displayName", { id })))?.trim();
607
-
608
- const entry: Record<string, unknown> = { baseUrl, api, [DISCOVERY_MARKER]: true };
609
- if (name) entry.name = name;
610
- if (apiKey) entry.apiKey = apiKey;
611
- const summary = i18n.t("summary", { id, baseUrl, api, apiKey: apiKey || i18n.t("noKey"), name: name || id });
612
- if (!(await ctx.ui.confirm(i18n.t("confirmAdd"), summary))) return;
613
-
614
- const providers = (data.providers ?? {}) as Record<string, unknown>;
615
- providers[id] = entry;
616
- data.providers = providers;
775
+ /** /config:model-discovery 新增 provider:写回 models.json 后立刻做首次发现。 */
776
+ async function addProviderFromPanel(options: {
777
+ /** 扩展 API。 */
778
+ pi: ExtensionAPI;
779
+ /** 命令上下文,用来发提示。 */
780
+ ctx: CommandCtx;
781
+ /** 面板里填好的新 provider。 */
782
+ provider: PanelProvider;
783
+ /** 本次会话共享的 /models 请求缓存。 */
784
+ fetchCache: FetchCache;
785
+ /** 提示出口。 */
786
+ sink: NoticeSink;
787
+ }): Promise<void> {
788
+ const { pi, ctx, provider, fetchCache, sink } = options;
617
789
  try {
618
- const { backup } = await writeModelsFile(data);
790
+ const { backup } = await persistNewProvider(provider);
619
791
  sink({ level: "info", message: i18n.t("written", { backup }) }, ctx);
620
792
  } catch (err) {
621
793
  sink({ level: "error", message: `${LOG_PREFIX} ${err instanceof Error ? err.message : err}` }, ctx);
622
794
  return;
623
795
  }
624
-
625
796
  const notices: Notice[] = [];
626
797
  const result = await discoverAndRegister(
627
798
  pi,
628
- { id, name: name || undefined, baseUrl, apiKey: apiKey || undefined, api },
799
+ {
800
+ id: provider.id,
801
+ name: provider.name || undefined,
802
+ baseUrl: provider.baseUrl,
803
+ apiKey: provider.apiKey || undefined,
804
+ api: provider.api,
805
+ },
629
806
  fetchCache,
630
807
  notices,
631
808
  );
632
809
  flushNotices(ctx, notices);
633
810
  if (result) {
634
- sink({ level: "info", message: i18n.t("discovered", { id, count: result.count }) }, ctx);
811
+ sink({ level: "info", message: i18n.t("discovered", { id: provider.id, count: result.count }) }, ctx);
635
812
  } else {
636
- sink({ level: "warning", message: i18n.t("firstFailed", { id }) }, ctx);
637
- }
638
- }
639
-
640
- /** /config:model-discovery 管理 provider(重新发现/删除)交互流程 */
641
- async function manageProviderFlow(
642
- pi: ExtensionAPI,
643
- ctx: CommandCtx,
644
- data: Record<string, unknown>,
645
- entry: DiscoveryProviderEntry,
646
- fetchCache: FetchCache,
647
- sink: NoticeSink,
648
- ) {
649
- const REDISCOVER = i18n.t("rediscover");
650
- const REMOVE = i18n.t("remove");
651
- const BACK = i18n.t("back");
652
- const action = await ctx.ui.select(
653
- i18n.t("manageTitle", { id: entry.id, baseUrl: entry.baseUrl ?? "?", api: entry.api ?? "?" }),
654
- [REDISCOVER, REMOVE, BACK],
655
- );
656
- if (action === undefined || action === BACK) return;
657
-
658
- if (action === REDISCOVER) {
659
- const notices: Notice[] = [];
660
- const result = await discoverAndRegister(pi, entry, fetchCache, notices);
661
- flushNotices(ctx, notices);
662
- if (result) {
663
- sink({ level: "info", message: i18n.t("rediscovered", { id: entry.id, count: result.count }) }, ctx);
664
- }
665
- return;
666
- }
667
-
668
- // REMOVE
669
- if (!(await ctx.ui.confirm(i18n.t("confirmRemove", { id: entry.id }), i18n.t("removeMessage")))) return;
670
- const providers = (data.providers ?? {}) as Record<string, unknown>;
671
- delete providers[entry.id];
672
- data.providers = providers;
673
- try {
674
- const { backup } = await writeModelsFile(data);
675
- pi.unregisterProvider(entry.id);
676
- const notices: Notice[] = [];
677
- await removeCachedModels(entry.id, notices);
678
- flushNotices(ctx, notices);
679
- sink({ level: "info", message: i18n.t("removed", { id: entry.id, backup }) }, ctx);
680
- } catch (err) {
681
- sink({ level: "error", message: `${LOG_PREFIX} ${err instanceof Error ? err.message : err}` }, ctx);
813
+ sink({ level: "warning", message: i18n.t("firstFailed", { id: provider.id }) }, ctx);
682
814
  }
683
815
  }
684
816