opencode2pi 0.0.0-stage → 0.3.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FanchangWang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,215 @@
1
- # Temporary Holding Version
1
+ # opencode2pi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 在 **omp** / **pi** 里直接用 [OpenCode Zen](https://opencode.ai/zen) 的**匿名免费模型**。
4
+
5
+ **无需 API key · 无需注册 · 无需登录 · 无需子进程 · 无需本地端口**
6
+
7
+ > ⚠️ **这是匿名免费通道,上游随时可能改闸门或直接关停。**
8
+ > 它不依赖任何凭据,所以上游一旦调整请求形状校验,本扩展会**全线 403**。
9
+ > 出问题先跑 `/opencode2pi doctor`,它会直接告诉你闸门还在不在。
10
+
11
+ ---
12
+
13
+ ## 安装
14
+
15
+ ```sh
16
+ # 推荐:npm(已发布,自动带 provenance)
17
+ omp install npm:opencode2pi
18
+ pi install npm:opencode2pi
19
+
20
+ # 备用:git @stable(最近一次发版)
21
+ omp install git:https://github.com/FanchangWang/opencode2pi@stable
22
+ pi install git:https://github.com/FanchangWang/opencode2pi@stable
23
+
24
+ # 追踪最新代码:装的是"此刻 main 指向的 commit",含未发布的改动,之后不会自动更新
25
+ omp install git:https://github.com/FanchangWang/opencode2pi@main
26
+ pi install git:https://github.com/FanchangWang/opencode2pi@main
27
+ ```
28
+
29
+ **为什么 npm 优先**:git 装的是源码快照,宿主要现读现编译;npm 装的是已发布的
30
+ tarball,带 provenance 签名(可在 npmjs.com 包页面核验),且 tag 与版本号一一对应。
31
+ 注意 **omp/pi 不支持直接装 `.tgz` 文件**(实测报 `package.json not found`),
32
+ 所以只能用 npm spec 或 git URL。
33
+
34
+ `@stable` 是最近一次发版的版本,随发版前移;要固定到某一版,去
35
+ [releases](https://github.com/FanchangWang/opencode2pi/releases) 挑一个 tag,
36
+ 把 `@stable` 换成那个 tag 名或 commit SHA。上游随时可能改闸门,只有钉死才能复现"当时能用"的状态。
37
+
38
+ `@latest` 用不了:git 没有这个 ref,omp 解析会报错。
39
+
40
+ ### 从源码运行(开发用)
41
+
42
+ ```sh
43
+ git clone https://github.com/FanchangWang/opencode2pi.git
44
+ cd opencode2pi
45
+
46
+ # 链接当前目录:改代码立即生效,不用重装
47
+ omp install .
48
+
49
+ # 单次试用,不落盘
50
+ omp -e ./src/index.ts -p --model opencode-zen-free/big-pickle "你好"
51
+ ```
52
+
53
+ `omp install .` 是**链接而非拷贝**:装的是路径,所以改 `src/` 下的代码下次启动就生效。
54
+ 代价是删掉或移动这个目录,扩展就失效了 —— 所以本地开发用它,给别人用请走上面的 npm 或 git。
55
+
56
+ > **不要只复制 `src/index.ts`**。扩展是多文件模块,`index.ts` 依赖同目录的其它模块。
57
+ > 要手动常驻就整目录复制:`cp -r src ~/.omp/agent/extensions/opencode2pi`。
58
+
59
+ ---
60
+
61
+ ## 使用
62
+
63
+ ### 选模型
64
+
65
+ 会话里用 `/model` 选择,provider 名是 `opencode-zen-free`。
66
+
67
+ 命令行要用 `--model <provider>/<model>`,**不要**用 `--provider`:
68
+
69
+ ```sh
70
+ # 对
71
+ omp -p --model opencode-zen-free/big-pickle "你好"
72
+
73
+ # 错,会报 Unknown provider
74
+ omp --provider opencode-zen-free
75
+ ```
76
+
77
+ > 扩展注册的 provider 在参数校验阶段还不可见,所以 `--provider` 一定失败。
78
+ > 同理 `omp models` **不列出**这个 provider,查清单请用会话内的 `/model`
79
+ > 或 `/opencode2pi status`。
80
+
81
+ ### 模型列表是自动发现的
82
+
83
+ 免费池轮换频繁,所以列表不是写死的,而是实时发现 + 元数据判定:
84
+
85
+ | 级 | 来源 | 作用 |
86
+ | --- | --- | --- |
87
+ | S1 | `GET {zen}/v1/models` | 谁现在真的在售 |
88
+ | S2 | [models.dev](https://models.dev) | 免费判定 + 真实能力参数 |
89
+ | S3 | 内置静态种子 | 完全离线时的兜底 |
90
+
91
+ 实测当前发现 **7 个** 免费模型(2026-10-11 实测后从 13 个减下来,恒定被拒的
92
+ id 已进 denylist,见 `docs/FINDINGS.md` §2.2)。付费模型不会进列表;断网也能用
93
+ (退回内置种子,能力参数不会变成错误的默认值)。
94
+
95
+ ### 图片输入
96
+
97
+ 能否附加图片取决于模型本身。把图片作为消息的一部分传入即可:
98
+
99
+ ```sh
100
+ omp -p "@photo.png" "这张图是什么颜色?"
101
+ ```
102
+
103
+ 只支持文本的模型会收到明确的拒绝提示,而不是模糊的 400。
104
+
105
+ ### 能力参数
106
+
107
+ 上下文长度、最大输出、计价、reasoning effort 都取自 models.dev 的真实数据,
108
+ 不是拍脑袋的默认值。某个字段上游没给时,会落到**保守默认值并写进日志**,
109
+ 而不是悄悄用一个错的数字。
110
+
111
+ ---
112
+
113
+ ## `/opencode2pi`
114
+
115
+ omp 没有扩展设置页,斜杠命令是本扩展唯一的界面。
116
+
117
+ | 命令 | 作用 |
118
+ | --- | --- |
119
+ | `/opencode2pi doctor` | 检查**形状闸门是否仍然成立**。上游一改闸门,这里立刻告警 |
120
+ | `/opencode2pi status` | 显示上次探测的结果;**从未探测过的模型(含新发现的)会当场补测** |
121
+ | `/opencode2pi probe` | 重新探测全部模型 |
122
+ | `/opencode2pi filter` | 选择要在 `/model` 里隐藏哪些模型(默认一个都不隐藏) |
123
+
124
+ 不带参数执行会弹出四项菜单。
125
+
126
+ ### 健康标记
127
+
128
+ | 标记 | 含义 |
129
+ | --- | --- |
130
+ | ✅ | 可用 |
131
+ | ⚠️ | **现在不行**(5xx、400、无法归类的错误),不代表模型没了 |
132
+ | 🚧 | **配额受限**(429 / transport 超时)。这是通道的匿名额度用完了,不是模型的问题,等一会儿会自动恢复 |
133
+ | ❌ | 确定死了(地区封锁,或连续两次 `ModelError`) |
134
+ | ❓ | 闸门或凭据被拒 —— 全 roster 的问题,跑 `doctor` |
135
+
136
+ 每行末尾直接带上原因(HTTP 状态与上游原文)和探测时间,所以不存在
137
+ 「这个模型到底怎么回事」需要另外去查的情况。超过 6 小时的结果标 `(已过期)`,
138
+ 并提示你去跑 `probe` —— `status` 不会替你重探。
139
+
140
+ **默认只标注、不隐藏。** 这条通道波动很大,单次失败不足以判死刑 ——
141
+ 实测有模型一分钟内先 `401 ModelError`、再 `200`,所以一次 `ModelError` 只标 ⚠️,
142
+ 连续两次才升级 ❌,任何一次成功都会清零。
143
+
144
+ ### 想让失败的模型不出现在 `/model` 里
145
+
146
+ `/opencode2pi filter`(或每次 `probe` 结束后自动询问)可以隐藏地区封锁的、以及所有
147
+ 探测失败的模型。这个选择记在数据目录的 `filters.json` 里,重启后仍然有效;
148
+ 随时可以改回「全部保留」。
149
+
150
+ **配额耗尽和链路故障不会隐藏任何模型**:那是通道的状态,不是模型的。被隐藏的只是
151
+ 不进 `/model` 与 `--model`,`status` 里照样能看到全部。
152
+
153
+ ---
154
+
155
+ ## 排障
156
+
157
+ ### 先跑 doctor
158
+
159
+ 大部分「全都不能用」的情况,先跑 `/opencode2pi doctor`。它会直接告诉你闸门还在不在。
160
+
161
+ ### 错误对照表
162
+
163
+ 扩展会把上游错误翻译成人能看懂的原因:
164
+
165
+ | 你看到的 | 归类 | 原因 / 处理 |
166
+ | --- | --- | --- |
167
+ | `上游形状闸门拒绝(FreeTierError)` | `SHAPE_REJECTED` | 闸门条件被破坏,通常是代码回归 —— 提 issue |
168
+ | `该模型在当前网络地区不可用(RegionError)` | `REGION_BLOCKED` | **地区封锁,不是凭据问题**。换模型或换网络节点 |
169
+ | `模型已下线(ModelError)` | `MODEL_GONE` | 上游已不再支持该模型 |
170
+ | `匿名配额按出口 IP 限流(429)` | `RATE_LIMIT` | 等配额恢复,或设 `PI_PROXY_OPENCODE_ZEN_FREE` 换出口 |
171
+ | `上游拒绝了请求(400)` | `REQUEST_REJECTED` | 模型无法处理这次输入,最常见是给只支持文本的模型发图片 |
172
+ | `上游暂时不可用(5xx)` | `UPSTREAM` | 上游临时故障,换模型重试 |
173
+
174
+ ### 其它常见问题
175
+
176
+ | 现象 | 处理 |
177
+ | --- | --- |
178
+ | `Unknown provider "opencode-zen-free"` | 用了 `--provider`。改用 `--model opencode-zen-free/<id>` |
179
+ | `omp models` 查不到这个 provider | 已知行为(实测),用会话内 `/model` 或 `/opencode2pi status` |
180
+ | 断网了还能用吗 | 能。退回内置种子,能力参数是正确的实测值 |
181
+
182
+ ---
183
+
184
+ ## 它做了什么、没做什么
185
+
186
+ 宿主内建的 `opencode-zen` provider 需要付费 `OPENCODE_API_KEY`;本扩展走的是 Zen 的
187
+ 匿名通道(`Authorization: Bearer public`),拿到的是那一批免费模型。
188
+
189
+ 流式引擎、tool call 分片聚合、reasoning、图片 modality、模型选择器 —— 全部由
190
+ omp/pi 内建,本扩展不重复实现。它只补上匿名通道要求的那部分请求形状,
191
+ 其余全部委托给宿主。细节见 [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md)。
192
+
193
+ **不做**:付费 Zen 通道、OpenAI/Anthropic 协议互转、IP 池轮换、子进程。
194
+
195
+ ---
196
+
197
+ ## 文档
198
+
199
+ | 文档 | 内容 |
200
+ | --- | --- |
201
+ | [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | 架构、闸门条件、宿主 API 实测行为、模块说明、验证与发布流程 |
202
+ | [`docs/FINDINGS.md`](docs/FINDINGS.md) | 实测结论存档:闸门精确条件、宿主扩展 API 的真实行为、同类扩展为什么都不能用 |
203
+ | [`docs/PLAN.md`](docs/PLAN.md) | 任务清单与验收标准 |
204
+ | [`AGENTS.md`](AGENTS.md) | 给接手的 AI:项目地图、验证命令、别踩的坑 |
205
+
206
+ ---
207
+
208
+ ## 致谢
209
+
210
+ 灵感与闸门条件的逆向,来自 [`FishBottle7/opencode2dsh`](https://github.com/FishBottle7/opencode2dsh)
211
+ (DSH 版的同类工具,其 `legacy/` 里的 Go sidecar 源自 [`opencode2api`](https://github.com/jasonxu114514/opencode2api))。
212
+
213
+ ## 许可证
214
+
215
+ MIT
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "opencode2pi",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.2",
4
+ "description": "OpenCode Zen's anonymous free models (no API key) as a native omp/pi provider extension.",
5
+ "license": "MIT",
6
+ "author": "FanchangWang",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/FanchangWang/opencode2pi.git"
10
+ },
11
+ "keywords": [
12
+ "omp",
13
+ "pi",
14
+ "pi-extension",
15
+ "opencode",
16
+ "opencode-zen",
17
+ "free-models",
18
+ "pi-package"
19
+ ],
20
+ "type": "module",
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/index.ts"
27
+ ]
28
+ },
29
+ "omp": {
30
+ "extensions": [
31
+ "./src/index.ts"
32
+ ]
33
+ },
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-ai": "*"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@earendil-works/pi-ai": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "files": [
43
+ "src/"
44
+ ],
45
+ "scripts": {
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "bun test",
48
+ "smoke": "omp -e ./src/index.ts -p --model opencode-zen-free/big-pickle \"Reply with exactly OK\"",
49
+ "registry": "npm --registry=https://registry.npmjs.org/",
50
+ "whoami": "npm --registry=https://registry.npmjs.org/ whoami",
51
+ "dry-run": "npm --registry=https://registry.npmjs.org/ pack --dry-run",
52
+ "release:check": "npm run typecheck && npm test && npm run dry-run"
53
+ },
54
+ "devDependencies": {
55
+ "@oh-my-pi/pi-coding-agent": "^18.6.1",
56
+ "@types/bun": "^1.4.2",
57
+ "@types/node": "^26.6.4",
58
+ "typescript": "^5.9.0"
59
+ }
60
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * `/opencode2pi` — the extension's only interface (docs/PLAN.md §T5).
3
+ *
4
+ * omp has no settings page for extensions, so a slash command plus the TUI is
5
+ * the whole surface. Four capabilities, each answering a question the user
6
+ * cannot answer any other way once the lane lives inside omp:
7
+ *
8
+ * doctor — the shape gate is hardcoded here, so when upstream changes it the
9
+ * only symptom is a silent wall of 403s. This turns that into one
10
+ * explicit alarm.
11
+ * status — report the last sweep: models it has never asked about are probed
12
+ * on the spot, and aged verdicts are flagged rather than re-run.
13
+ * probe — sweep the whole roster, because that costs real inference and is
14
+ * therefore the user's call, not a side effect of looking.
15
+ * filter — ask what the roster should hide, and remember the answer.
16
+ */
17
+
18
+ import type { ExtensionCommandContext, ProviderModelConfig } from '@oh-my-pi/pi-coding-agent'
19
+
20
+ import { discoverFreeModels } from './discovery.ts'
21
+ import { runDoctor } from './doctor.ts'
22
+ import { hidden, publishFilters, publishHealth, saveFilters } from './filters.ts'
23
+ import {
24
+ HEALTH_MARK,
25
+ HEALTH_TTL_MS,
26
+ isStale,
27
+ loadHealth,
28
+ probeAll,
29
+ saveHealth,
30
+ summarizeHealth,
31
+ type HealthRecord,
32
+ type HealthStore,
33
+ type ProbeResult,
34
+ } from './health.ts'
35
+ import { SEED_MODELS } from './seed.ts'
36
+
37
+ /** Model used for the gate check: a verified id, not whatever is selected. */
38
+ const DOCTOR_MODEL = SEED_MODELS[0]?.id ?? 'big-pickle'
39
+
40
+ const USAGE = '用法:/opencode2pi <doctor|status|probe|filter>'
41
+
42
+ const AGE = (record: HealthRecord): string => {
43
+ const minutes = Math.max(0, Math.round((Date.now() - record.checkedAt) / 60_000))
44
+ if (minutes < 1) return '刚刚探测'
45
+ if (minutes < 60) return `${minutes} 分钟前探测`
46
+ const hours = Math.round(minutes / 60)
47
+ return hours < 24 ? `${hours} 小时前探测` : `${Math.round(hours / 24)} 天前探测`
48
+ }
49
+
50
+ /**
51
+ * Render one roster line. Every model appears, whatever its verdict, and every
52
+ * line carries the reason: a mark alone leaves the reader to guess whether ⚠️
53
+ * means a 503, a 400 or a timeout, and those have different remedies.
54
+ *
55
+ * A model we hold no record for says so in words instead of borrowing a verdict
56
+ * mark. `·` is not one of the five verdicts — it says we have not asked, which
57
+ * is a fact about the last probe, not about the model.
58
+ */
59
+ function rosterLine(model: ProviderModelConfig, record: HealthRecord | undefined): string {
60
+ if (!record) return `· ${model.id} — ${model.name} · 未探测`
61
+ const stale = isStale(record) ? '(已过期)' : ''
62
+ return `${HEALTH_MARK[record.health]} ${model.id} — ${model.name} · ${record.detail} · ${AGE(record)}${stale}`
63
+ }
64
+
65
+ /** Render one finished probe: the verdict, the reason, and what it cost. */
66
+ function resultLine(result: ProbeResult, done: number, total: number): string {
67
+ const seconds = (result.latencyMs / 1000).toFixed(1)
68
+ return `[${done}/${total}] ${HEALTH_MARK[result.health]} ${result.modelId} — ${result.detail} · ${seconds}s`
69
+ }
70
+
71
+ async function currentRoster(): Promise<readonly ProviderModelConfig[]> {
72
+ // Never throws and never returns empty, so the roster is always showable.
73
+ const { models } = await discoverFreeModels()
74
+ return models.length > 0 ? models : SEED_MODELS
75
+ }
76
+
77
+ /**
78
+ * Probe a slice of the roster, printing each verdict as it lands.
79
+ *
80
+ * A sweep costs minutes of real inference, so batching the output until the end
81
+ * means a user staring at an unchanged screen cannot tell "slow model" from
82
+ * "hung". One line per model, immediately, answers that as it goes.
83
+ *
84
+ * `keep` is the whole roster, not the slice being probed: pruning to the batch
85
+ * would delete every verdict a partial sweep deliberately left alone.
86
+ */
87
+ async function sweep(
88
+ models: readonly ProviderModelConfig[],
89
+ keep: ReadonlySet<string>,
90
+ ctx: ExtensionCommandContext,
91
+ ): Promise<HealthStore> {
92
+ const results = await probeAll(models, (done, total, result) => {
93
+ ctx.ui.setWorkingMessage(`正在探测 ${done}/${total}:${result.modelId}`)
94
+ ctx.ui.notify(resultLine(result, done, total))
95
+ })
96
+ ctx.ui.setWorkingMessage()
97
+ const merged = await saveHealth(results, keep)
98
+ publishHealth(merged)
99
+ return merged
100
+ }
101
+
102
+ /**
103
+ * Report the roster, probing only what we have never asked about.
104
+ *
105
+ * `status` answers "what does the last probe say", and the store is that answer:
106
+ * re-probing the whole roster on every `status` would burn minutes of real
107
+ * inference to redraw a picture the user already has. Two cases are exceptions,
108
+ * because they are gaps rather than pictures — a model that has never been probed
109
+ * (which includes every model discovered since the last sweep) is probed right
110
+ * here, because showing `未探测` for something we could have asked about is a
111
+ * worse answer than asking. A verdict that has merely aged out is not a gap: it
112
+ * is a real result that has stopped being current, so it is reported with its
113
+ * age and one line pointing at `probe`. Re-probing on age would make `status` a
114
+ * `probe` with extra steps.
115
+ */
116
+ async function showStatus(ctx: ExtensionCommandContext): Promise<void> {
117
+ const roster = await currentRoster()
118
+ const stored = await loadHealth()
119
+ publishHealth(stored)
120
+
121
+ const unprobed = roster.filter((model) => !stored[model.id])
122
+ const aged = roster.filter((model) => stored[model.id] && isStale(stored[model.id]))
123
+ const keep = new Set(roster.map((model) => model.id))
124
+
125
+ let merged = stored
126
+ if (unprobed.length) {
127
+ if (!ctx.hasUI) {
128
+ // Print mode cannot render a progress indicator for minutes at a
129
+ // time; say so rather than hang a headless run on nothing.
130
+ ctx.ui.notify(
131
+ `有 ${unprobed.length} 个模型从未探测(${unprobed.map((model) => model.id).join(', ')})。` +
132
+ '逐模型探测需要交互式 TUI,请在 TUI 里运行 status 或 probe。',
133
+ 'warning',
134
+ )
135
+ } else {
136
+ ctx.ui.notify(
137
+ `正在探测 ${unprobed.length} 个尚未探测过的模型(${unprobed.map((model) => model.id).join(', ')})…`,
138
+ )
139
+ merged = await sweep(unprobed, keep, ctx)
140
+ }
141
+ }
142
+
143
+ ctx.ui.notify(
144
+ `opencode2pi 模型状态:${summarizeHealth(roster, merged)}\n` +
145
+ `${roster.map((model) => rosterLine(model, merged[model.id])).join('\n')}` +
146
+ hiddenNote() +
147
+ staleNote(aged.map((model) => model.id)),
148
+ )
149
+ }
150
+
151
+ /** A trailing line pointing at `probe` when the report it just showed has aged. */
152
+ function staleNote(aged: readonly string[]): string {
153
+ if (!aged.length) return ''
154
+ return (
155
+ `\n⚠️ ${aged.length} 个模型的结果已超过 ${HEALTH_TTL_MS / 3_600_000} 小时(${aged.join(', ')})。` +
156
+ '免费池波动很快,运行 /opencode2pi probe 重新探测全部模型。'
157
+ )
158
+ }
159
+
160
+ /** Probe the whole roster, report it, and ask about hiding. */
161
+ async function runProbe(ctx: ExtensionCommandContext): Promise<void> {
162
+ if (!ctx.hasUI) {
163
+ ctx.ui.notify('逐模型探测需要交互式 TUI(打印模式下不可用)。')
164
+ return
165
+ }
166
+
167
+ const roster = await currentRoster()
168
+ const keep = new Set(roster.map((model) => model.id))
169
+ ctx.ui.notify(
170
+ `正在探测 ${roster.length} 个模型(每个模型一条结果,陆续输出;全部结束后给出汇总与完整列表)…`,
171
+ )
172
+ const merged = await sweep(roster, keep, ctx)
173
+ // The per-model lines stream in completion order; this closing block is the
174
+ // canonical snapshot — every model, in roster order, with verdict and reason.
175
+ ctx.ui.notify(
176
+ `探测完成:${summarizeHealth(roster, merged)}\n` +
177
+ `${roster.map((model) => rosterLine(model, merged[model.id])).join('\n')}` +
178
+ hiddenNote(),
179
+ )
180
+ // The verdicts are only useful if they can change something, and whether to
181
+ // hide is the one decision we refuse to make on the user's behalf.
182
+ await chooseFilters(ctx)
183
+ }
184
+
185
+ /** A trailing line saying what the current filters remove, or that they do not. */
186
+ function hiddenNote(): string {
187
+ const excluded = [...hidden()]
188
+ return excluded.length ? `\n已从列表隐藏:${excluded.join(', ')}(用 /opencode2pi filter 调整)` : ''
189
+ }
190
+
191
+ /**
192
+ * Ask what the roster should hide, and apply the answer.
193
+ *
194
+ * This is the user's call, not ours: the same model can answer ✅ on one lane and
195
+ * be region-blocked on another, so an automatic rule would be wrong half the
196
+ * time. Cancelling changes nothing — the default is to hide nothing.
197
+ */
198
+ async function chooseFilters(ctx: ExtensionCommandContext): Promise<void> {
199
+ if (!ctx.hasUI) {
200
+ ctx.ui.notify('过滤设置需要交互式 TUI(打印模式下不可用)。')
201
+ return
202
+ }
203
+ const choice = await ctx.ui.select('opencode2pi · 模型过滤', [
204
+ { label: '全部保留(只标注,不隐藏)', description: '默认:任何模型都不从列表里移除' },
205
+ { label: '隐藏地区封锁的', description: '上游按当前出口拒绝的模型(403 · not available in your country)' },
206
+ { label: '隐藏所有探测失败的', description: '下线、地区封锁、请求被拒、上游故障;配额与链路问题不算' },
207
+ ])
208
+ const next =
209
+ choice === '隐藏地区封锁的'
210
+ ? { hideRegionBlocked: true, hideFailed: false }
211
+ : choice === '隐藏所有探测失败的'
212
+ ? { hideRegionBlocked: true, hideFailed: true }
213
+ : choice === '全部保留(只标注,不隐藏)'
214
+ ? { hideRegionBlocked: false, hideFailed: false }
215
+ : undefined
216
+ if (!next) return
217
+ await saveFilters(next)
218
+ publishFilters(next)
219
+ ctx.ui.notify(`已更新过滤:当前隐藏 ${hidden().size} 个模型(/model 与 --model 里不再出现)`)
220
+ }
221
+
222
+ async function showMenu(ctx: ExtensionCommandContext): Promise<void> {
223
+ if (!ctx.hasUI) {
224
+ ctx.ui.notify(USAGE)
225
+ return
226
+ }
227
+ const choice = await ctx.ui.select('opencode2pi', [
228
+ { label: 'doctor', description: '检查形状闸门是否仍然成立(发一个最小闸门形状请求)' },
229
+ { label: 'status', description: '显示上次探测的结果;从未探测过的模型会自动补测' },
230
+ { label: 'probe', description: '重新探测全部模型(并发,2 并发)' },
231
+ { label: 'filter', description: '选择要在 /model 里隐藏哪些模型(默认一个都不隐藏)' },
232
+ ])
233
+ // `select` resolves to the chosen label, so the labels double as the keys.
234
+ if (choice === 'doctor') await runDoctorCommand(ctx)
235
+ else if (choice === 'status') await showStatus(ctx)
236
+ else if (choice === 'probe') await runProbe(ctx)
237
+ else if (choice === 'filter') await chooseFilters(ctx)
238
+ }
239
+
240
+ async function runDoctorCommand(ctx: ExtensionCommandContext): Promise<void> {
241
+ if (ctx.hasUI) ctx.ui.notify('正在检查形状闸门…')
242
+ const report = await runDoctor(DOCTOR_MODEL)
243
+ ctx.ui.notify(`${report.headline}\n${report.detail}`, report.ok ? 'info' : 'error')
244
+ }
245
+
246
+ export async function handleCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
247
+ const sub = args.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
248
+ if (sub === 'doctor') return runDoctorCommand(ctx)
249
+ if (sub === 'status') return showStatus(ctx)
250
+ if (sub === 'probe') return runProbe(ctx)
251
+ if (sub === 'filter') return chooseFilters(ctx)
252
+ if (sub === '') return showMenu(ctx)
253
+ ctx.ui.notify(USAGE, 'warning')
254
+ }