pi-models-discovery 0.1.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 ADDED
@@ -0,0 +1,70 @@
1
+ # pi-models-discovery
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**.
4
+
5
+ Suitable for local/self-hosted LLM proxies (one gateway exposing many models), Ollama, vLLM, and other OpenAI-compatible services.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:pi-models-discovery
11
+ ```
12
+
13
+ ## Configuration
14
+
15
+ ### Option 1: interactive configuration inside pi (recommended)
16
+
17
+ ```
18
+ /model-discovery
19
+ ```
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.
22
+
23
+ Note: comments and custom formatting in models.json are not preserved on write (the file is reformatted with 4-space indentation).
24
+
25
+ ### Option 2: edit models.json by hand
26
+
27
+ Add `"discoverModels": true` to a provider in `~/.pi/agent/models.json`:
28
+
29
+ ```json
30
+ {
31
+ "providers": {
32
+ "llm-proxy": {
33
+ "name": "LLM Proxy",
34
+ "baseUrl": "http://127.0.0.1:9000/pi/v1",
35
+ "apiKey": "sk-1234",
36
+ "api": "openai-completions",
37
+ "discoverModels": true
38
+ }
39
+ }
40
+ }
41
+ ```
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.
44
+
45
+ Hand edits require `/reload` to take effect; changes made through `/model-discovery` apply immediately.
46
+
47
+ ## Refreshing the cache
48
+
49
+ ```
50
+ /model-discovery-refresh
51
+ ```
52
+
53
+ Forces a rediscovery of every discovery provider and updates the local cache, notifying the result for each provider.
54
+
55
+ ## Behavior
56
+
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.
58
+ - **Online refresh**: `/model-discovery-refresh`, or the `refreshModels` hook triggered when opening `/model`, rediscovers online and updates the cache.
59
+ - **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
+ - **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).
61
+ - Default parameters for discovered models: `reasoning: true`, `input: ["text", "image"]`, zero cost, `contextWindow` 1M, `maxTokens` 64K, `compat.supportsDeveloperRole: false`. Provider-level `compat` is merged into every discovered model.
62
+ - Model metadata may carry `name` / `context_window` (or `contextWindow`) / `max_tokens` (or `maxTokens`); defaults are used when absent.
63
+
64
+ ## Uninstall
65
+
66
+ ```bash
67
+ pi remove pi-models-discovery
68
+ ```
69
+
70
+ After removal, providers fall back to the static configuration in models.json (handwritten `models`, or none).
@@ -0,0 +1,70 @@
1
+ # pi-models-discovery
2
+
3
+ 通用模型发现插件:读取 `~/.pi/agent/models.json` 中带 `"discoverModels": true` 的 provider,请求 `GET {baseUrl}/models` 自动发现模型并注册,无需手写 `models` 数组。首次发现成功后模型列表持久化到本地缓存,之后每次启动直接读缓存,**不再请求网络**。
4
+
5
+ 适合本地/自建 LLM 代理(一个网关暴露多个模型)、Ollama、vLLM 等 OpenAI 兼容服务。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ pi install npm:pi-models-discovery
11
+ ```
12
+
13
+ ## 配置
14
+
15
+ ### 方式一:pi 终端内交互配置(推荐)
16
+
17
+ ```
18
+ /model-discovery
19
+ ```
20
+
21
+ 交互式添加 / 删除 / 重新发现 provider:输入 id、baseUrl、api 类型、apiKey(可留空)、显示名(可留空),确认后写入 models.json 并**立即生效**(无需 /reload)。写入前自动备份到 `models.json.discovery-bak`。
22
+
23
+ 注意:写回时 models.json 中的注释与自定义排版不被保留(会格式化为 4 空格缩进)。
24
+
25
+ ### 方式二:手编 models.json
26
+
27
+ 在 `~/.pi/agent/models.json` 的 provider 上加 `"discoverModels": true` 标记:
28
+
29
+ ```json
30
+ {
31
+ "providers": {
32
+ "llm-proxy": {
33
+ "name": "LLM Proxy",
34
+ "baseUrl": "http://127.0.0.1:9000/pi/v1",
35
+ "apiKey": "sk-1234",
36
+ "api": "openai-completions",
37
+ "discoverModels": true
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ `baseUrl` 和 `api` 必填;`apiKey` 可选(无鉴权服务可省略)。同一 `baseUrl+apiKey` 的多个 provider 共享一次 `/models` 请求。
44
+
45
+ 手编方式修改配置后需 `/reload` 生效;`/model-discovery` 命令的修改立即生效。
46
+
47
+ ## 刷新缓存
48
+
49
+ ```
50
+ /model-discovery-refresh
51
+ ```
52
+
53
+ 强制重新拉取所有发现 provider 的模型列表并更新本地缓存,逐个 notify 结果。
54
+
55
+ ## 行为
56
+
57
+ - **启动(缓存优先)**:缓存命中时直接用持久化的模型列表注册,零网络请求;缓存文件位于 `~/.pi/agent/extensions/pi-models-discovery/cache.json`。provider 配置指纹(baseUrl+api+apiKey+headers+compat)变化时缓存自动失效,重新走网络发现。
58
+ - **在线刷新**:`/model-discovery-refresh` 或 `/model` 打开时触发的 `refreshModels` 在线重新发现,并同步更新缓存。
59
+ - **离线 / 拉取失败**:保留 models.json 里手写的 `models`(如有,作为回退),并通过会话内 notify 显式警告,不静默降级;单个 provider 失败不影响其他 provider。
60
+ - **apiKey 解析**(仅发现请求):支持字面量与 `$ENV_VAR` / `${ENV_VAR}` 插值;`!command` 形式跳过发现并显式警告(聊天请求仍由 pi 自身解析执行,不受影响)。
61
+ - 发现的模型默认参数:`reasoning: true`、`input: ["text", "image"]`、cost 全 0、`contextWindow` 1M、`maxTokens` 64K、`compat.supportsDeveloperRole: false`;provider 级 `compat` 会合并进每个发现的模型。
62
+ - 模型元数据可携带 `name` / `context_window`(或 `contextWindow`)/ `max_tokens`(或 `maxTokens`),缺失时用默认值。
63
+
64
+ ## 卸载
65
+
66
+ ```bash
67
+ pi remove pi-models-discovery
68
+ ```
69
+
70
+ 卸载后 provider 回退为 models.json 中的静态配置(手写 `models` 或空)。
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.ts";
@@ -0,0 +1,186 @@
1
+ {
2
+ "commandDescription": {
3
+ "zh-CN": "交互式管理模型发现 provider(添加/删除/重新发现),写入 models.json 并立即生效",
4
+ "en-US": "Interactively manage model-discovery providers (add/remove/rediscover); changes are written to models.json and take effect immediately"
5
+ },
6
+ "refreshDescription": {
7
+ "zh-CN": "强制重新拉取所有模型发现 provider 的模型列表并更新本地缓存(启动时默认走缓存,不请求网络)",
8
+ "en-US": "Force rediscovery of all model-discovery providers and update the local cache (startup uses the cache and performs no network requests by default)"
9
+ },
10
+ "refreshEmpty": {
11
+ "zh-CN": "models.json 中没有标记 discoverModels 的 provider,可用 /model-discovery 添加",
12
+ "en-US": "No provider in models.json is marked with discoverModels; add one with /model-discovery"
13
+ },
14
+ "refreshDone": {
15
+ "zh-CN": "模型刷新完成:{ok}/{total} 个 provider 成功",
16
+ "en-US": "Model refresh finished: {ok}/{total} providers succeeded"
17
+ },
18
+ "readFailed": {
19
+ "zh-CN": "读取 models.json 失败:{reason}",
20
+ "en-US": "Failed to read models.json: {reason}"
21
+ },
22
+ "parseFailed": {
23
+ "zh-CN": "解析 models.json 失败:{reason}",
24
+ "en-US": "Failed to parse models.json: {reason}"
25
+ },
26
+ "backupFailed": {
27
+ "zh-CN": "备份 models.json 失败:{reason}",
28
+ "en-US": "Failed to back up models.json: {reason}"
29
+ },
30
+ "commandValueUnsupported": {
31
+ "zh-CN": "发现请求不支持 \"!command\" 形式的配置值",
32
+ "en-US": "Discovery requests do not support \"!command\" configuration values"
33
+ },
34
+ "envMissing": {
35
+ "zh-CN": "环境变量未设置:{names}",
36
+ "en-US": "Environment variables not set: {names}"
37
+ },
38
+ "missingBaseUrl": {
39
+ "zh-CN": "缺少 baseUrl",
40
+ "en-US": "Missing baseUrl"
41
+ },
42
+ "fetchFailed": {
43
+ "zh-CN": "请求 {url} 失败:{reason}",
44
+ "en-US": "Request to {url} failed: {reason}"
45
+ },
46
+ "fetchHttpError": {
47
+ "zh-CN": "请求 {url} 返回 HTTP {status}",
48
+ "en-US": "Request to {url} returned HTTP {status}"
49
+ },
50
+ "emptyModelList": {
51
+ "zh-CN": "{url} 返回的模型列表为空",
52
+ "en-US": "{url} returned an empty model list"
53
+ },
54
+ "headerSkipped": {
55
+ "zh-CN": "{id}: header \"{key}\" {reason},该 header 未随发现请求发送",
56
+ "en-US": "{id}: header \"{key}\" {reason}; this header was not sent with the discovery request"
57
+ },
58
+ "missingConfig": {
59
+ "zh-CN": "缺少 {field},无法注册模型发现",
60
+ "en-US": "Missing {field}; cannot register model discovery"
61
+ },
62
+ "discoveryFailed": {
63
+ "zh-CN": "{id}: 模型发现失败({reason}),该 provider 暂用 models.json 手写 models 或无可用模型",
64
+ "en-US": "{id}: model discovery failed ({reason}); falling back to handwritten models in models.json, or no models available"
65
+ },
66
+ "discoveredInfo": {
67
+ "zh-CN": "{id}: 发现 {count} 个模型({models})",
68
+ "en-US": "{id}: discovered {count} models ({models})"
69
+ },
70
+ "offlineNoCache": {
71
+ "zh-CN": "尚无已发现的模型(上次发现失败),离线初始化跳过",
72
+ "en-US": "No discovered models yet (last discovery failed); skipping offline initialization"
73
+ },
74
+ "cacheReadFailed": {
75
+ "zh-CN": "模型缓存读取失败(已忽略,将重新发现):{reason}",
76
+ "en-US": "Failed to read the model cache (ignored; will rediscover): {reason}"
77
+ },
78
+ "cacheWriteFailed": {
79
+ "zh-CN": "模型缓存写入失败:{reason}",
80
+ "en-US": "Failed to write the model cache: {reason}"
81
+ },
82
+ "add": {
83
+ "zh-CN": "➕ 添加 provider",
84
+ "en-US": "➕ Add provider"
85
+ },
86
+ "exit": {
87
+ "zh-CN": "退出",
88
+ "en-US": "Exit"
89
+ },
90
+ "title": {
91
+ "zh-CN": "模型发现 provider(共 {count} 个)",
92
+ "en-US": "Model-discovery providers ({count})"
93
+ },
94
+ "emptyTitle": {
95
+ "zh-CN": "模型发现 provider(暂无)",
96
+ "en-US": "Model-discovery providers (none)"
97
+ },
98
+ "providerId": {
99
+ "zh-CN": "provider id(小写字母/数字/中划线,如 my-proxy)",
100
+ "en-US": "Provider id (letters, numbers, hyphens; e.g. my-proxy)"
101
+ },
102
+ "invalidId": {
103
+ "zh-CN": "id 只能包含字母、数字、中划线,且不能以中划线开头",
104
+ "en-US": "The id may contain only letters, numbers, and hyphens, and cannot start with a hyphen"
105
+ },
106
+ "exists": {
107
+ "zh-CN": "provider \"{id}\" 已存在于 models.json",
108
+ "en-US": "Provider \"{id}\" already exists in models.json"
109
+ },
110
+ "baseUrl": {
111
+ "zh-CN": "baseUrl(如 http://127.0.0.1:9000/pi/v1)",
112
+ "en-US": "baseUrl (e.g. http://127.0.0.1:9000/pi/v1)"
113
+ },
114
+ "invalidUrl": {
115
+ "zh-CN": "baseUrl 必须以 http:// 或 https:// 开头",
116
+ "en-US": "baseUrl must start with http:// or https://"
117
+ },
118
+ "api": {
119
+ "zh-CN": "api 类型",
120
+ "en-US": "API type"
121
+ },
122
+ "apiKey": {
123
+ "zh-CN": "apiKey(可留空;支持 $ENV_VAR)",
124
+ "en-US": "apiKey (optional; supports $ENV_VAR)"
125
+ },
126
+ "displayName": {
127
+ "zh-CN": "显示名(可留空,默认 {id})",
128
+ "en-US": "Display name (optional; defaults to {id})"
129
+ },
130
+ "noKey": {
131
+ "zh-CN": "(无)",
132
+ "en-US": "(none)"
133
+ },
134
+ "confirmAdd": {
135
+ "zh-CN": "确认添加并写入 models.json?",
136
+ "en-US": "Add this provider and write it to models.json?"
137
+ },
138
+ "summary": {
139
+ "zh-CN": "id: {id}\nbaseUrl: {baseUrl}\napi: {api}\napiKey: {apiKey}\nname: {name}",
140
+ "en-US": "id: {id}\nbaseUrl: {baseUrl}\napi: {api}\napiKey: {apiKey}\nname: {name}"
141
+ },
142
+ "written": {
143
+ "zh-CN": "已写入 models.json(备份:{backup})",
144
+ "en-US": "Wrote models.json (backup: {backup})"
145
+ },
146
+ "discovered": {
147
+ "zh-CN": "{id}: 已发现并注册 {count} 个模型,/model 中即可选用",
148
+ "en-US": "{id}: discovered and registered {count} models; they are available in /model"
149
+ },
150
+ "firstFailed": {
151
+ "zh-CN": "{id}: 配置已保存,但首次发现失败;服务恢复后可在 /model-discovery 中重新发现",
152
+ "en-US": "{id}: configuration saved, but initial discovery failed; rediscover with /model-discovery after the service recovers"
153
+ },
154
+ "rediscover": {
155
+ "zh-CN": "🔄 重新发现模型",
156
+ "en-US": "🔄 Rediscover models"
157
+ },
158
+ "remove": {
159
+ "zh-CN": "🗑 删除该 provider",
160
+ "en-US": "🗑 Remove provider"
161
+ },
162
+ "back": {
163
+ "zh-CN": "返回",
164
+ "en-US": "Back"
165
+ },
166
+ "manageTitle": {
167
+ "zh-CN": "{id}\nbaseUrl: {baseUrl}\napi: {api}",
168
+ "en-US": "{id}\nbaseUrl: {baseUrl}\napi: {api}"
169
+ },
170
+ "rediscovered": {
171
+ "zh-CN": "{id}: 重新发现 {count} 个模型",
172
+ "en-US": "{id}: rediscovered {count} models"
173
+ },
174
+ "confirmRemove": {
175
+ "zh-CN": "删除 provider \"{id}\"?",
176
+ "en-US": "Remove provider \"{id}\"?"
177
+ },
178
+ "removeMessage": {
179
+ "zh-CN": "将从 models.json 移除该 provider 并注销其模型。此操作可重新添加恢复。",
180
+ "en-US": "This removes the provider and unregisters its models from models.json. It can be added again later."
181
+ },
182
+ "removed": {
183
+ "zh-CN": "已删除 {id}(备份:{backup})",
184
+ "en-US": "Removed {id} (backup: {backup})"
185
+ }
186
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "pi-models-discovery",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: discover models from {baseUrl}/models for providers marked with discoverModels in models.json",
5
+ "type": "module",
6
+ "files": [
7
+ "index.ts",
8
+ "src",
9
+ "locales",
10
+ "README.md",
11
+ "README.zh-CN.md"
12
+ ],
13
+ "scripts": {
14
+ "test": "tsx --test tests/catalog.test.ts",
15
+ "typecheck": "tsc --noEmit --pretty false",
16
+ "build": "npm run typecheck",
17
+ "check": "npm run typecheck && npm test && npm pack --dry-run --json > /dev/null"
18
+ },
19
+ "pi": {
20
+ "extensions": [
21
+ "./index.ts"
22
+ ]
23
+ },
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "license": "MIT",
28
+ "author": "maplezzk",
29
+ "homepage": "https://github.com/maplezzk/pi-extensions/tree/main/packages/pi-models-discovery",
30
+ "bugs": "https://github.com/maplezzk/pi-extensions/issues",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/maplezzk/pi-extensions.git",
34
+ "directory": "packages/pi-models-discovery"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "registry": "https://registry.npmjs.org"
39
+ },
40
+ "keywords": [
41
+ "pi-package",
42
+ "pi",
43
+ "pi-extension",
44
+ "coding-agent"
45
+ ],
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-ai": ">=0.80.0 <0.81.0",
48
+ "@earendil-works/pi-coding-agent": ">=0.80.0 <0.81.0",
49
+ "pi-extensions-i18n": "^0.3.0"
50
+ },
51
+ "devDependencies": {
52
+ "@earendil-works/pi-ai": "0.80.10",
53
+ "@earendil-works/pi-coding-agent": "0.80.10",
54
+ "@types/node": "24.12.4",
55
+ "tsx": "4.23.1",
56
+ "typescript": "5.9.3"
57
+ }
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,700 @@
1
+ /**
2
+ * 通用模型发现插件
3
+ *
4
+ * 读取 models.json 中带 "discoverModels": true 的 provider,请求
5
+ * GET {baseUrl}/models 自动发现模型并注册,无需手写 models 数组。
6
+ *
7
+ * 配置方式(二选一):
8
+ * 1. pi 终端内执行 /model-discovery,交互式添加/删除/重新发现 provider(推荐);
9
+ * 2. 直接编辑 ~/.pi/agent/models.json:
10
+ * {
11
+ * "providers": {
12
+ * "llm-proxy": {
13
+ * "name": "LLM Proxy",
14
+ * "baseUrl": "http://127.0.0.1:9000/pi/v1",
15
+ * "apiKey": "sk-1234",
16
+ * "api": "openai-completions",
17
+ * "discoverModels": true
18
+ * }
19
+ * }
20
+ * }
21
+ *
22
+ * 行为约定:
23
+ * - 首次发现成功后,模型列表持久化到 ~/.pi/agent/extensions/pi-models-discovery/cache.json;
24
+ * 之后每次启动直接读缓存注册,不请求网络。配置指纹
25
+ * (baseUrl+api+apiKey+headers+compat)变化时缓存自动失效,重新走网络发现。
26
+ * - /model-discovery-refresh 强制重新拉取所有发现 provider 并更新缓存;
27
+ * /model 打开时触发的在线 refreshModels 同样走网络并同步更新缓存。
28
+ * - baseUrl / api 由扩展显式转发(pi 的 extension 组合层要求),
29
+ * apiKey / name / headers / compat 不写回注册配置,由 pi 的 models.json 层回落生效。
30
+ * - provider 级 compat 会被合并进每个发现的模型(pi 的 models.json provider 级 compat
31
+ * 不作用于 extension 注册的模型,故在此转发)。
32
+ * - 同一 baseUrl+apiKey+headers 的多个 provider 共享一次 /models 请求。
33
+ * - 发现失败:该 provider 保留 models.json 手写 models(如有,作为离线回退),
34
+ * 并通过 notify 显式警告,不静默降级;单个 provider 失败不影响其他 provider 注册。
35
+ * - 注册 refreshModels:打开 /model 触发在线刷新时重新发现;
36
+ * 离线初始化(allowNetwork=false)返回上次成功列表(含缓存),尚无成功记录时抛错,
37
+ * 以免空列表清掉 models.json 手写 models。
38
+ * - 发现请求的 apiKey 解析仅支持字面量与 $ENV_VAR/${ENV_VAR} 插值;
39
+ * "!command" 形式的 apiKey 跳过发现(显式警告),pi 发起聊天请求时仍由 pi 自身解析。
40
+ * - /model-discovery 命令对 models.json 的修改立即生效(registerProvider 运行时可直接调用);
41
+ * 直接手编 models.json 后需 /reload 扩展生效。
42
+ * - 本插件不使用 console.*:所有用户可见消息走 ctx.ui.notify;
43
+ * 加载期(无 ctx)产生的消息收集到 pendingNotices,session_start 时统一 flush。
44
+ */
45
+
46
+ import type { Api } from "@earendil-works/pi-ai";
47
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
48
+ import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
49
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
50
+ import { dirname, join } from "node:path";
51
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
52
+
53
+ const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
54
+
55
+ const FETCH_TIMEOUT_MS = 5000;
56
+ const DISCOVERY_MARKER = "discoverModels";
57
+ const LOG_PREFIX = "[model-discovery]";
58
+ const API_CHOICES = ["openai-completions", "anthropic-messages", "openai-responses", "google-generative-ai"] as const;
59
+
60
+ /** 用户可见消息(级别与 ctx.ui.notify 的 type 对齐) */
61
+ interface Notice {
62
+ level: "info" | "warning" | "error";
63
+ message: string;
64
+ }
65
+
66
+ /** models.json 中 provider 条目的读取形态(含发现标记) */
67
+ interface DiscoveryProviderEntry {
68
+ id: string;
69
+ name?: string;
70
+ baseUrl?: string;
71
+ apiKey?: string;
72
+ api?: string;
73
+ headers?: Record<string, string>;
74
+ compat?: Record<string, unknown>;
75
+ }
76
+
77
+ interface ModelsResponse {
78
+ data?: Array<{
79
+ id?: string;
80
+ name?: string;
81
+ context_window?: number;
82
+ contextWindow?: number;
83
+ max_tokens?: number;
84
+ maxTokens?: number;
85
+ }>;
86
+ }
87
+
88
+ /** 持久化缓存中单个 provider 的条目 */
89
+ interface CachedProviderEntry {
90
+ fingerprint: string;
91
+ fetchedAt: string;
92
+ models: ProviderModelConfig[];
93
+ }
94
+
95
+ interface CacheFile {
96
+ version: 1;
97
+ providers: Record<string, CachedProviderEntry>;
98
+ }
99
+
100
+ /** 与 pi dist/utils/json.js 的 stripJsonComments 一致:去 // 行注释与尾随逗号,保留字符串字面量 */
101
+ function stripJsonComments(input: string): string {
102
+ return input
103
+ .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
104
+ .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
105
+ }
106
+
107
+ function modelsJsonPath(): string {
108
+ return join(getAgentDir(), "models.json");
109
+ }
110
+
111
+ function cachePath(): string {
112
+ return join(getAgentDir(), "extensions", "pi-models-discovery", "cache.json");
113
+ }
114
+
115
+ /** 缓存失效指纹:provider 配置中影响发现结果的字段 */
116
+ function providerFingerprint(entry: DiscoveryProviderEntry): string {
117
+ return JSON.stringify([
118
+ entry.baseUrl ?? "",
119
+ entry.api ?? "",
120
+ entry.apiKey ?? "",
121
+ entry.headers ?? {},
122
+ entry.compat ?? {},
123
+ ]);
124
+ }
125
+
126
+ /** 读取模型缓存;文件不存在视为空缓存,损坏则显式警告并视为空缓存(不静默降级) */
127
+ async function readCache(notices: Notice[]): Promise<CacheFile> {
128
+ let raw: string;
129
+ try {
130
+ raw = await readFile(cachePath(), "utf-8");
131
+ } catch (err) {
132
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
133
+ notices.push({
134
+ level: "warning",
135
+ message: `${LOG_PREFIX} ${i18n.t("cacheReadFailed", { reason: err instanceof Error ? err.message : String(err) })}`,
136
+ });
137
+ }
138
+ return { version: 1, providers: {} };
139
+ }
140
+ try {
141
+ const parsed = JSON.parse(raw) as CacheFile;
142
+ if (parsed && typeof parsed === "object" && parsed.providers && typeof parsed.providers === "object") {
143
+ return { version: 1, providers: parsed.providers };
144
+ }
145
+ throw new Error("unexpected cache shape");
146
+ } catch (err) {
147
+ notices.push({
148
+ level: "warning",
149
+ message: `${LOG_PREFIX} ${i18n.t("cacheReadFailed", { reason: err instanceof Error ? err.message : String(err) })}`,
150
+ });
151
+ return { version: 1, providers: {} };
152
+ }
153
+ }
154
+
155
+ /** 发现成功后持久化模型列表;写失败仅警告(不影响本次注册) */
156
+ async function persistCachedModels(
157
+ entry: DiscoveryProviderEntry,
158
+ models: ProviderModelConfig[],
159
+ notices: Notice[],
160
+ ): Promise<void> {
161
+ try {
162
+ const cache = await readCache([]);
163
+ cache.providers[entry.id] = {
164
+ fingerprint: providerFingerprint(entry),
165
+ fetchedAt: new Date().toISOString(),
166
+ models,
167
+ };
168
+ await mkdir(dirname(cachePath()), { recursive: true });
169
+ await writeFile(cachePath(), `${JSON.stringify(cache, null, 2)}\n`, "utf-8");
170
+ } catch (err) {
171
+ notices.push({
172
+ level: "warning",
173
+ message: `${LOG_PREFIX} ${entry.id}: ${i18n.t("cacheWriteFailed", { reason: err instanceof Error ? err.message : String(err) })}`,
174
+ });
175
+ }
176
+ }
177
+
178
+ /** 删除 provider 时同步移除其缓存条目 */
179
+ async function removeCachedModels(id: string, notices: Notice[]): Promise<void> {
180
+ try {
181
+ const cache = await readCache([]);
182
+ if (!(id in cache.providers)) return;
183
+ delete cache.providers[id];
184
+ await mkdir(dirname(cachePath()), { recursive: true });
185
+ await writeFile(cachePath(), `${JSON.stringify(cache, null, 2)}\n`, "utf-8");
186
+ } catch (err) {
187
+ notices.push({
188
+ level: "warning",
189
+ message: `${LOG_PREFIX} ${id}: ${i18n.t("cacheWriteFailed", { reason: err instanceof Error ? err.message : String(err) })}`,
190
+ });
191
+ }
192
+ }
193
+
194
+ /** 读取 models.json 完整内容(保留所有顶层字段与其他 provider 原样) */
195
+ async function readModelsFile(): Promise<{ data: Record<string, unknown>; error: string | null }> {
196
+ let raw: string;
197
+ try {
198
+ raw = await readFile(modelsJsonPath(), "utf-8");
199
+ } catch (err) {
200
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
201
+ return { data: {}, error: null };
202
+ }
203
+ return { data: {}, error: i18n.t("readFailed", { reason: err instanceof Error ? err.message : String(err) }) };
204
+ }
205
+ try {
206
+ const parsed = JSON.parse(stripJsonComments(raw)) as Record<string, unknown>;
207
+ return { data: parsed, error: null };
208
+ } catch (err) {
209
+ return { data: {}, error: i18n.t("parseFailed", { reason: err instanceof Error ? err.message : String(err) }) };
210
+ }
211
+ }
212
+
213
+ /** 写回 models.json;写前备份到 models.json.discovery-bak。注意:注释与键序格式不被保留 */
214
+ async function writeModelsFile(data: Record<string, unknown>): Promise<{ backup: string }> {
215
+ const path = modelsJsonPath();
216
+ const backup = `${path}.discovery-bak`;
217
+ try {
218
+ await writeFile(backup, await readFile(path, "utf-8"), "utf-8");
219
+ } catch (err) {
220
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
221
+ throw new Error(i18n.t("backupFailed", { reason: err instanceof Error ? err.message : String(err) }));
222
+ }
223
+ }
224
+ await writeFile(path, `${JSON.stringify(data, null, 4)}\n`, "utf-8");
225
+ return { backup };
226
+ }
227
+
228
+ /** 从完整 models.json 数据中筛出带发现标记的 provider */
229
+ function pickDiscoveryProviders(data: Record<string, unknown>): DiscoveryProviderEntry[] {
230
+ const rawProviders = (data.providers ?? {}) as Record<string, Record<string, unknown>>;
231
+ const providers: DiscoveryProviderEntry[] = [];
232
+ for (const [id, value] of Object.entries(rawProviders)) {
233
+ if (value?.[DISCOVERY_MARKER] !== true) continue;
234
+ providers.push({
235
+ id,
236
+ name: typeof value.name === "string" ? value.name : undefined,
237
+ baseUrl: typeof value.baseUrl === "string" ? value.baseUrl : undefined,
238
+ apiKey: typeof value.apiKey === "string" ? value.apiKey : undefined,
239
+ api: typeof value.api === "string" ? value.api : undefined,
240
+ headers:
241
+ value.headers && typeof value.headers === "object"
242
+ ? (value.headers as Record<string, string>)
243
+ : undefined,
244
+ compat:
245
+ value.compat && typeof value.compat === "object"
246
+ ? (value.compat as Record<string, unknown>)
247
+ : undefined,
248
+ });
249
+ }
250
+ return providers;
251
+ }
252
+
253
+ /**
254
+ * 解析发现请求用的配置值:字面量、$ENV_VAR / ${ENV_VAR} 插值、$$ 与 $! 转义。
255
+ * 不执行 "!command"(由 pi 请求时自行处理),遇到时返回 error。
256
+ */
257
+ function resolveEnvValue(raw: string): { value: string | null; error: string | null } {
258
+ if (raw.startsWith("!")) {
259
+ return { value: null, error: i18n.t("commandValueUnsupported") };
260
+ }
261
+ const missing: string[] = [];
262
+ const value = raw
263
+ .replace(/\$\$|\$!|\$\{([^}]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, braceName, plainName) => {
264
+ if (match === "$$") return "\0DOLLAR\0";
265
+ if (match === "$!") return "\0BANG\0";
266
+ const name = (braceName ?? plainName) as string;
267
+ const envValue = process.env[name];
268
+ if (envValue === undefined) {
269
+ missing.push(name);
270
+ return "";
271
+ }
272
+ return envValue;
273
+ })
274
+ .replaceAll("\0DOLLAR\0", "$")
275
+ .replaceAll("\0BANG\0", "!");
276
+ if (missing.length > 0) {
277
+ return { value: null, error: i18n.t("envMissing", { names: missing.join(", ") }) };
278
+ }
279
+ return { value, error: null };
280
+ }
281
+
282
+ function buildModel(
283
+ id: string,
284
+ name: string | undefined,
285
+ contextWindow: number | undefined,
286
+ maxTokens: number | undefined,
287
+ providerCompat: Record<string, unknown> | undefined,
288
+ ): ProviderModelConfig {
289
+ return {
290
+ id,
291
+ name: name ?? id,
292
+ reasoning: true,
293
+ input: ["text", "image"],
294
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
295
+ contextWindow: contextWindow ?? 1_000_000,
296
+ maxTokens: maxTokens ?? 65_536,
297
+ thinkingLevelMap: {
298
+ minimal: null,
299
+ low: null,
300
+ medium: null,
301
+ high: "high",
302
+ xhigh: "max",
303
+ },
304
+ compat: {
305
+ supportsDeveloperRole: false,
306
+ ...providerCompat,
307
+ },
308
+ };
309
+ }
310
+
311
+ /** 请求 {baseUrl}/models 并解析为模型配置;失败抛错(错误信息带原因) */
312
+ async function fetchModels(
313
+ entry: DiscoveryProviderEntry,
314
+ notices: Notice[],
315
+ ): Promise<ProviderModelConfig[]> {
316
+ if (!entry.baseUrl) {
317
+ throw new Error(i18n.t("missingBaseUrl"));
318
+ }
319
+ const headers: Record<string, string> = {};
320
+ for (const [key, rawValue] of Object.entries(entry.headers ?? {})) {
321
+ const resolved = resolveEnvValue(rawValue);
322
+ if (resolved.error) {
323
+ notices.push({
324
+ level: "warning",
325
+ message: `${LOG_PREFIX} ${i18n.t("headerSkipped", { id: entry.id, key, reason: resolved.error })}`,
326
+ });
327
+ continue;
328
+ }
329
+ if (resolved.value !== null) headers[key] = resolved.value;
330
+ }
331
+ if (entry.apiKey !== undefined) {
332
+ const resolved = resolveEnvValue(entry.apiKey);
333
+ if (resolved.error) {
334
+ throw new Error(`apiKey ${resolved.error}`);
335
+ }
336
+ if (resolved.value) {
337
+ headers.Authorization = `Bearer ${resolved.value}`;
338
+ }
339
+ }
340
+ const url = `${entry.baseUrl.replace(/\/+$/, "")}/models`;
341
+ let response: Response;
342
+ try {
343
+ response = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
344
+ } catch (err) {
345
+ throw new Error(i18n.t("fetchFailed", { url, reason: err instanceof Error ? err.message : String(err) }));
346
+ }
347
+ if (!response.ok) {
348
+ throw new Error(i18n.t("fetchHttpError", { url, status: response.status }));
349
+ }
350
+ const payload = (await response.json()) as ModelsResponse | Array<ModelsResponse["data"] extends (infer T)[] ? T : never>;
351
+ const entries = Array.isArray(payload) ? payload : (payload.data ?? []);
352
+ const models = entries
353
+ .filter((m): m is typeof m & { id: string } => typeof m?.id === "string" && m.id.length > 0)
354
+ .map((m) =>
355
+ buildModel(
356
+ m.id,
357
+ m.name,
358
+ m.context_window ?? m.contextWindow,
359
+ m.max_tokens ?? m.maxTokens,
360
+ entry.compat,
361
+ ),
362
+ );
363
+ if (models.length === 0) {
364
+ throw new Error(i18n.t("emptyModelList", { url }));
365
+ }
366
+ return models;
367
+ }
368
+
369
+ /** 共享 /models 请求的缓存:同一 baseUrl+apiKey+headers 只拉一次 */
370
+ type FetchCache = Map<string, Promise<ProviderModelConfig[]>>;
371
+
372
+ function fetchWithCache(cache: FetchCache, entry: DiscoveryProviderEntry, notices: Notice[]): Promise<ProviderModelConfig[]> {
373
+ const cacheKey = `${entry.baseUrl}\n${entry.apiKey ?? ""}\n${JSON.stringify(entry.headers ?? {})}`;
374
+ let pending = cache.get(cacheKey);
375
+ if (!pending) {
376
+ pending = fetchModels(entry, notices);
377
+ cache.set(cacheKey, pending);
378
+ }
379
+ return pending;
380
+ }
381
+
382
+ /** refreshModels 闭包共享的最近成功列表(缓存命中时即为缓存内容) */
383
+ interface LastModelsState {
384
+ lastModels: ProviderModelConfig[];
385
+ }
386
+
387
+ /**
388
+ * 构造 refreshModels:离线初始化返回最近成功列表(无记录则抛错,避免清空手写 models);
389
+ * 在线刷新强制重拉、更新最近列表并持久化缓存。
390
+ */
391
+ function createRefreshModels(entry: DiscoveryProviderEntry, notices: Notice[], state: LastModelsState) {
392
+ return async (context: { allowNetwork: boolean }) => {
393
+ if (!context.allowNetwork) {
394
+ if (state.lastModels.length === 0) {
395
+ throw new Error(i18n.t("offlineNoCache"));
396
+ }
397
+ return state.lastModels;
398
+ }
399
+ const refreshed = await fetchModels(entry, notices);
400
+ state.lastModels = refreshed;
401
+ await persistCachedModels(entry, refreshed, notices);
402
+ return refreshed;
403
+ };
404
+ }
405
+
406
+ /** 校验 baseUrl/api 齐备;缺失时推入警告并返回 false */
407
+ function validateEntry(entry: DiscoveryProviderEntry, notices: Notice[]): entry is DiscoveryProviderEntry & { baseUrl: string; api: string } {
408
+ if (!entry.baseUrl || !entry.api) {
409
+ notices.push({
410
+ level: "warning",
411
+ message: `${LOG_PREFIX} ${entry.id}: ${i18n.t("missingConfig", { field: !entry.baseUrl ? "baseUrl" : "api" })}`,
412
+ });
413
+ return false;
414
+ }
415
+ return true;
416
+ }
417
+
418
+ /**
419
+ * 对单个 provider 执行模型发现并注册(成功带 models,失败保留手写回退)。
420
+ * 启动期缓存未命中时与 /model-discovery、/model-discovery-refresh 命令共用;
421
+ * 运行期调用立即生效,无需 /reload。
422
+ * 成功时持久化模型缓存;返回发现的模型列表(失败为 null),消息写入 notices。
423
+ * 注意:本函数不再主动输出“发现成功”信息;调用方按需自行 notify,
424
+ * 避免启动期自动打印模型列表占用会话空间。
425
+ */
426
+ async function discoverAndRegister(
427
+ pi: ExtensionAPI,
428
+ entry: DiscoveryProviderEntry,
429
+ fetchCache: FetchCache,
430
+ notices: Notice[],
431
+ ): Promise<{ count: number; models: ProviderModelConfig[] } | null> {
432
+ if (!validateEntry(entry, notices)) return null;
433
+ const state: LastModelsState = { lastModels: [] };
434
+ const refreshModels = createRefreshModels(entry, notices, state);
435
+ try {
436
+ const models = await fetchWithCache(fetchCache, entry, notices);
437
+ state.lastModels = models;
438
+ pi.registerProvider(entry.id, {
439
+ baseUrl: entry.baseUrl,
440
+ api: entry.api as Api,
441
+ models,
442
+ refreshModels,
443
+ });
444
+ await persistCachedModels(entry, models, notices);
445
+ return { count: models.length, models };
446
+ } catch (err) {
447
+ const reason = err instanceof Error ? err.message : String(err);
448
+ notices.push({
449
+ level: "warning",
450
+ message: `${LOG_PREFIX} ${i18n.t("discoveryFailed", { id: entry.id, reason })}`,
451
+ });
452
+ pi.registerProvider(entry.id, {
453
+ baseUrl: entry.baseUrl,
454
+ api: entry.api as Api,
455
+ refreshModels,
456
+ });
457
+ return null;
458
+ }
459
+ }
460
+
461
+ /** 缓存命中路径:直接用持久化的模型列表注册,不请求网络 */
462
+ function registerFromCache(
463
+ pi: ExtensionAPI,
464
+ entry: DiscoveryProviderEntry,
465
+ models: ProviderModelConfig[],
466
+ notices: Notice[],
467
+ ): void {
468
+ if (!validateEntry(entry, notices)) return;
469
+ const state: LastModelsState = { lastModels: models };
470
+ const refreshModels = createRefreshModels(entry, notices, state);
471
+ pi.registerProvider(entry.id, {
472
+ baseUrl: entry.baseUrl,
473
+ api: entry.api as Api,
474
+ models,
475
+ refreshModels,
476
+ });
477
+ }
478
+
479
+ /** /model-discovery 交互式配置命令 */
480
+ function registerDiscoveryCommand(pi: ExtensionAPI, fetchCache: FetchCache) {
481
+ pi.registerCommand("model-discovery", {
482
+ description: i18n.t("commandDescription"),
483
+ handler: async (_args, ctx) => {
484
+ if (!ctx.hasUI) return;
485
+ while (true) {
486
+ const { data, error } = await readModelsFile();
487
+ if (error) {
488
+ ctx.ui.notify(`${LOG_PREFIX} ${error}`, "error");
489
+ return;
490
+ }
491
+ const providers = pickDiscoveryProviders(data);
492
+ const ADD = i18n.t("add");
493
+ const EXIT = i18n.t("exit");
494
+ const choices = [
495
+ ...providers.map((p) => `${p.id} — ${p.baseUrl ?? "?"}(${p.api ?? "?"})`),
496
+ ADD,
497
+ EXIT,
498
+ ];
499
+ const choice = await ctx.ui.select(
500
+ providers.length > 0 ? i18n.t("title", { count: providers.length }) : i18n.t("emptyTitle"),
501
+ choices,
502
+ );
503
+ if (choice === undefined || choice === EXIT) return;
504
+
505
+ if (choice === ADD) {
506
+ await addProviderFlow(pi, ctx, data, fetchCache);
507
+ continue;
508
+ }
509
+ const selected = providers[choices.indexOf(choice)];
510
+ if (selected) {
511
+ await manageProviderFlow(pi, ctx, data, selected, fetchCache);
512
+ }
513
+ }
514
+ },
515
+ });
516
+ }
517
+
518
+ /** /model-discovery-refresh 强制刷新命令:绕过启动缓存,重拉所有发现 provider 并更新持久化缓存 */
519
+ function registerRefreshCommand(pi: ExtensionAPI) {
520
+ pi.registerCommand("model-discovery-refresh", {
521
+ description: i18n.t("refreshDescription"),
522
+ handler: async (_args, ctx) => {
523
+ if (!ctx.hasUI) return;
524
+ const { data, error } = await readModelsFile();
525
+ if (error) {
526
+ ctx.ui.notify(`${LOG_PREFIX} ${error}`, "error");
527
+ return;
528
+ }
529
+ const providers = pickDiscoveryProviders(data);
530
+ if (providers.length === 0) {
531
+ ctx.ui.notify(i18n.t("refreshEmpty"), "info");
532
+ return;
533
+ }
534
+ // 每次刷新用新的请求级缓存:同 baseUrl 的 provider 仍共享一次请求,但不复用启动期结果
535
+ const fetchCache: FetchCache = new Map();
536
+ let ok = 0;
537
+ for (const entry of providers) {
538
+ const notices: Notice[] = [];
539
+ const result = await discoverAndRegister(pi, entry, fetchCache, notices);
540
+ for (const notice of notices) ctx.ui.notify(notice.message, notice.level);
541
+ if (result) {
542
+ ctx.ui.notify(
543
+ `${LOG_PREFIX} ${i18n.t("discoveredInfo", { id: entry.id, count: result.count, models: result.models.map((m) => m.id).join(", ") })}`,
544
+ "info",
545
+ );
546
+ ok++;
547
+ }
548
+ }
549
+ ctx.ui.notify(
550
+ i18n.t("refreshDone", { ok, total: providers.length }),
551
+ ok === providers.length ? "info" : "warning",
552
+ );
553
+ },
554
+ });
555
+ }
556
+
557
+ type CommandCtx = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
558
+
559
+ /** 将收集到的 Notice 一次性 flush 到 UI */
560
+ function flushNotices(ctx: CommandCtx, notices: Notice[]): void {
561
+ for (const notice of notices) ctx.ui.notify(notice.message, notice.level);
562
+ }
563
+
564
+ /** /model-discovery 添加 provider 交互流程 */
565
+ async function addProviderFlow(pi: ExtensionAPI, ctx: CommandCtx, data: Record<string, unknown>, fetchCache: FetchCache) {
566
+ const existingIds = new Set(Object.keys((data.providers ?? {}) as Record<string, unknown>));
567
+ const id = (await ctx.ui.input(i18n.t("providerId")))?.trim();
568
+ if (!id) return;
569
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(id)) {
570
+ ctx.ui.notify(i18n.t("invalidId"), "error");
571
+ return;
572
+ }
573
+ if (existingIds.has(id)) {
574
+ ctx.ui.notify(i18n.t("exists", { id }), "error");
575
+ return;
576
+ }
577
+ const baseUrl = (await ctx.ui.input(i18n.t("baseUrl")))?.trim();
578
+ if (!baseUrl) return;
579
+ if (!/^https?:\/\//.test(baseUrl)) {
580
+ ctx.ui.notify(i18n.t("invalidUrl"), "error");
581
+ return;
582
+ }
583
+ const api = await ctx.ui.select(i18n.t("api"), [...API_CHOICES]);
584
+ if (!api) return;
585
+ const apiKey = (await ctx.ui.input(i18n.t("apiKey")))?.trim();
586
+ const name = (await ctx.ui.input(i18n.t("displayName", { id })))?.trim();
587
+
588
+ const entry: Record<string, unknown> = { baseUrl, api, [DISCOVERY_MARKER]: true };
589
+ if (name) entry.name = name;
590
+ if (apiKey) entry.apiKey = apiKey;
591
+ const summary = i18n.t("summary", { id, baseUrl, api, apiKey: apiKey || i18n.t("noKey"), name: name || id });
592
+ if (!(await ctx.ui.confirm(i18n.t("confirmAdd"), summary))) return;
593
+
594
+ const providers = (data.providers ?? {}) as Record<string, unknown>;
595
+ providers[id] = entry;
596
+ data.providers = providers;
597
+ try {
598
+ const { backup } = await writeModelsFile(data);
599
+ ctx.ui.notify(i18n.t("written", { backup }), "info");
600
+ } catch (err) {
601
+ ctx.ui.notify(`${LOG_PREFIX} ${err instanceof Error ? err.message : err}`, "error");
602
+ return;
603
+ }
604
+
605
+ const notices: Notice[] = [];
606
+ const result = await discoverAndRegister(
607
+ pi,
608
+ { id, name: name || undefined, baseUrl, apiKey: apiKey || undefined, api },
609
+ fetchCache,
610
+ notices,
611
+ );
612
+ flushNotices(ctx, notices);
613
+ if (result) {
614
+ ctx.ui.notify(i18n.t("discovered", { id, count: result.count }), "info");
615
+ } else {
616
+ ctx.ui.notify(i18n.t("firstFailed", { id }), "warning");
617
+ }
618
+ }
619
+
620
+ /** /model-discovery 管理 provider(重新发现/删除)交互流程 */
621
+ async function manageProviderFlow(
622
+ pi: ExtensionAPI,
623
+ ctx: CommandCtx,
624
+ data: Record<string, unknown>,
625
+ entry: DiscoveryProviderEntry,
626
+ fetchCache: FetchCache,
627
+ ) {
628
+ const REDISCOVER = i18n.t("rediscover");
629
+ const REMOVE = i18n.t("remove");
630
+ const BACK = i18n.t("back");
631
+ const action = await ctx.ui.select(
632
+ i18n.t("manageTitle", { id: entry.id, baseUrl: entry.baseUrl ?? "?", api: entry.api ?? "?" }),
633
+ [REDISCOVER, REMOVE, BACK],
634
+ );
635
+ if (action === undefined || action === BACK) return;
636
+
637
+ if (action === REDISCOVER) {
638
+ const notices: Notice[] = [];
639
+ const result = await discoverAndRegister(pi, entry, fetchCache, notices);
640
+ flushNotices(ctx, notices);
641
+ if (result) {
642
+ ctx.ui.notify(i18n.t("rediscovered", { id: entry.id, count: result.count }), "info");
643
+ }
644
+ return;
645
+ }
646
+
647
+ // REMOVE
648
+ if (!(await ctx.ui.confirm(i18n.t("confirmRemove", { id: entry.id }), i18n.t("removeMessage")))) return;
649
+ const providers = (data.providers ?? {}) as Record<string, unknown>;
650
+ delete providers[entry.id];
651
+ data.providers = providers;
652
+ try {
653
+ const { backup } = await writeModelsFile(data);
654
+ pi.unregisterProvider(entry.id);
655
+ const notices: Notice[] = [];
656
+ await removeCachedModels(entry.id, notices);
657
+ flushNotices(ctx, notices);
658
+ ctx.ui.notify(i18n.t("removed", { id: entry.id, backup }), "info");
659
+ } catch (err) {
660
+ ctx.ui.notify(`${LOG_PREFIX} ${err instanceof Error ? err.message : err}`, "error");
661
+ }
662
+ }
663
+
664
+ export default async function (pi: ExtensionAPI) {
665
+ // 加载期没有 ctx,消息统一收集,session_start 时 flush(运行期后续追加的也会在下个 session 补发)
666
+ const pendingNotices: Notice[] = [];
667
+ const { data, error } = await readModelsFile();
668
+ if (error) {
669
+ pendingNotices.push({ level: "warning", message: `${LOG_PREFIX} ${error}` });
670
+ }
671
+ const providers = pickDiscoveryProviders(data);
672
+ const fetchCache: FetchCache = new Map();
673
+
674
+ if (providers.length > 0) {
675
+ const cache = await readCache(pendingNotices);
676
+ for (const entry of providers) {
677
+ const cached = cache.providers[entry.id];
678
+ if (
679
+ cached &&
680
+ cached.fingerprint === providerFingerprint(entry) &&
681
+ Array.isArray(cached.models) &&
682
+ cached.models.length > 0
683
+ ) {
684
+ registerFromCache(pi, entry, cached.models, pendingNotices);
685
+ } else {
686
+ await discoverAndRegister(pi, entry, fetchCache, pendingNotices);
687
+ // 启动期缓存未命中时保持静默,不打印模型列表
688
+ }
689
+ }
690
+ }
691
+
692
+ registerDiscoveryCommand(pi, fetchCache);
693
+ registerRefreshCommand(pi);
694
+
695
+ pi.on("session_start", (_event, ctx) => {
696
+ for (const notice of pendingNotices.splice(0)) {
697
+ ctx.ui.notify(notice.message, notice.level);
698
+ }
699
+ });
700
+ }