opencode2pi 0.0.0-stage → 0.3.1

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/src/health.ts ADDED
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Per-model health probing (docs/PLAN.md §T5).
3
+ *
4
+ * The probe speaks to Zen directly rather than through the host engine, for
5
+ * two reasons. It has to: the whole point of `doctor` is to test the *shape
6
+ * gate* this extension hardcodes, and routing through the engine would make a
7
+ * broken gate indistinguishable from a broken model. And it keeps the check
8
+ * honest about cost — one aborted stream beats a full agent turn.
9
+ *
10
+ * Two verdicts, deliberately kept apart:
11
+ *
12
+ * dead — upstream will never serve this id again (401 / 404 / ModelError /
13
+ * geo-block). Sticky: a later 503 does not resurrect it.
14
+ * flaky — unavailable *right now* (429 / 5xx / timeout / a rejected
15
+ * request). Never promoted to dead on transient evidence, which is
16
+ * how a model that is merely having a bad afternoon would get
17
+ * wrongly struck off the roster.
18
+ *
19
+ * Health is annotation only. It never removes a model from the roster.
20
+ */
21
+
22
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
23
+ import { tmpdir } from 'node:os'
24
+ import { join } from 'node:path'
25
+
26
+ import type { ProviderModelConfig } from '@oh-my-pi/pi-coding-agent'
27
+
28
+ import { classifyUpstreamFailure, type UpstreamFailure } from './errors.ts'
29
+ import { sendGateProbe } from './gate.ts'
30
+
31
+ /**
32
+ * Probe pacing.
33
+ *
34
+ * The anonymous lane meters per egress IP, so a sweep of 13 requests fired at
35
+ * once spends quota the next sweep needs. Concurrency 2 with a short pause keeps
36
+ * a sweep from starving its own follow-up.
37
+ *
38
+ * Measured 2026-10-06: 429 turned out to be metered *per model*, not per IP —
39
+ * `big-pickle` answered 200 in the same minute that two other ids answered 429,
40
+ * serially and 1.5 s apart. So pacing reduces self-inflicted pressure but does
41
+ * not cure it, which is why a rate-limited verdict gets its own mark instead of
42
+ * being reported as an unstable model.
43
+ */
44
+ const PROBE_CONCURRENCY = 2
45
+ const PROBE_GAP_MS = 500
46
+
47
+ function delay(ms: number): Promise<void> {
48
+ const { promise, resolve } = Promise.withResolvers<void>()
49
+ setTimeout(resolve, ms)
50
+ return promise
51
+ }
52
+ /**
53
+ * How long a verdict may be shown before `status` stops treating it as current.
54
+ *
55
+ * Six hours, not a day: the free pool churns on the order of hours, and a stale
56
+ * verdict is flagged (`(已过期)`) rather than re-run — a full sweep costs real
57
+ * inference, so it is the user's call, which is what `probe` is for.
58
+ */
59
+ export const HEALTH_TTL_MS = 6 * 60 * 60 * 1000
60
+
61
+ export type ModelHealth = 'ok' | 'flaky' | 'dead' | 'unknown' | 'limited'
62
+
63
+ export const HEALTH_MARK: Readonly<Record<ModelHealth, string>> = {
64
+ ok: '✅',
65
+ flaky: '⚠️',
66
+ dead: '❌',
67
+ unknown: '❓',
68
+ limited: '🚧',
69
+ }
70
+
71
+ export interface HealthRecord {
72
+ readonly health: ModelHealth
73
+ readonly kind: UpstreamFailure | 'OK'
74
+ readonly detail: string
75
+ readonly checkedAt: number
76
+ /** Consecutive `MODEL_GONE` verdicts; two are required before ❌. */
77
+ readonly terminalFailures: number
78
+ /** Consecutive transient failures; hints at an outage, never at death. */
79
+ readonly transientFailures: number
80
+ }
81
+
82
+ /** Consecutive `ModelError`s required before a model is called dead. */
83
+ export const TERMINAL_FAILURES_TO_CONDEMN = 2
84
+
85
+ /**
86
+ * Map a classified failure onto a provisional health verdict.
87
+ *
88
+ * Shape and auth rejections are deliberately *not* per-model verdicts: both are
89
+ * lane-wide, so blaming a model for them would paint ❌ across the entire roster
90
+ * at the exact moment the gate breaks. Those surface through `doctor`.
91
+ *
92
+ * `MODEL_GONE` starts as ⚠️ rather than ❌, and `saveHealth` only promotes it
93
+ * after {@link TERMINAL_FAILURES_TO_CONDEMN} consecutive hits. That is not
94
+ * caution for its own sake — measured 2026-10-05, `nemotron-3.ultra-free`
95
+ * answered `401 ModelError` on one probe and `200` on the next, a minute
96
+ * apart. Promoting on the first terminal verdict would mark a working model
97
+ * dead, which is the exact mistake this whole feature exists to avoid.
98
+ *
99
+ * `REGION_BLOCKED` is the exception: a geo block is deterministic for a given
100
+ * egress, so one hit is conclusive.
101
+ *
102
+ * `RATE_LIMIT` is not a verdict on the model at all. The anonymous lane meters
103
+ * quota separately per model — measured 2026-10-06, `big-pickle` answered 200
104
+ * while two other ids answered 429 in the same minute, probed serially — so a
105
+ * 429 says "this id is out of quota right now", which is a fact about the lane
106
+ * and not about whether the model works. It gets its own mark so that a quota
107
+ * pause never masquerades as an unstable model.
108
+ */
109
+ export function healthFor(kind: UpstreamFailure | 'OK'): ModelHealth {
110
+ switch (kind) {
111
+ case 'OK':
112
+ return 'ok'
113
+ case 'MODEL_GONE':
114
+ return 'flaky'
115
+ // A geo block is deterministic for a given egress: the same request from
116
+ // the same network keeps saying no.
117
+ case 'REGION_BLOCKED':
118
+ return 'dead'
119
+ case 'RATE_LIMIT':
120
+ return 'limited'
121
+ // A request the upstream rejected is the model refusing this particular
122
+ // round, which is evidence about the request, not a state of the model:
123
+ // measured on this lane an id answers 400 for one request and works for
124
+ // the next. An unrecognised failure carries no more
125
+ // evidence than a 5xx does — the classifier simply did not recognise the
126
+ // text — so giving it a verdict of its own would invent a fifth state
127
+ // that only ever showed up in `status`.
128
+ case 'REQUEST_REJECTED':
129
+ case 'UNKNOWN':
130
+ case 'UPSTREAM':
131
+ return 'flaky'
132
+ // The shape gate and the credential are lane-wide, so blaming a model
133
+ // for them would paint ❓ across the whole roster at the exact moment
134
+ // the gate breaks. They surface through `doctor` instead.
135
+ case 'SHAPE_REJECTED':
136
+ case 'AUTH':
137
+ return 'unknown'
138
+ }
139
+ }
140
+
141
+ export interface ProbeResult extends HealthRecord {
142
+ readonly modelId: string
143
+ readonly latencyMs: number
144
+ }
145
+
146
+ async function probeModel(modelId: string): Promise<ProbeResult> {
147
+ const probe = await sendGateProbe(modelId)
148
+ const base = { modelId, checkedAt: Date.now(), terminalFailures: 0, transientFailures: 0, latencyMs: probe.latencyMs }
149
+
150
+ if (probe.ok) {
151
+ return { ...base, health: 'ok', kind: 'OK', detail: `HTTP ${probe.status}` }
152
+ }
153
+ if (probe.transportError !== undefined) {
154
+ // A timeout or transport failure says nothing about the model itself:
155
+ // the 30 s budget can expire on a slow lane, or the request never left.
156
+ return { ...base, health: 'limited', kind: 'UPSTREAM', detail: `transport: ${probe.transportError}` }
157
+ }
158
+
159
+ const kind = classifyUpstreamFailure(probe.status, probe.body).kind
160
+ return {
161
+ ...base,
162
+ health: healthFor(kind),
163
+ kind,
164
+ detail: `HTTP ${probe.status} ${probe.body.slice(0, 200)}`,
165
+ }
166
+ }
167
+
168
+ /** Probe every model with a bounded, paced worker pool, reporting progress as it goes. */
169
+ export async function probeAll(
170
+ models: readonly ProviderModelConfig[],
171
+ onProgress?: (done: number, total: number, result: ProbeResult) => void,
172
+ ): Promise<ProbeResult[]> {
173
+ const results: ProbeResult[] = []
174
+ let cursor = 0
175
+
176
+ async function worker(): Promise<void> {
177
+ while (cursor < models.length) {
178
+ const model = models[cursor++]
179
+ if (!model) return
180
+ const result = await probeModel(model.id)
181
+ results.push(result)
182
+ onProgress?.(results.length, models.length, result)
183
+ await delay(PROBE_GAP_MS)
184
+ }
185
+ }
186
+
187
+ await Promise.all(Array.from({ length: Math.min(PROBE_CONCURRENCY, models.length) }, worker))
188
+ results.sort((a, b) => a.modelId.localeCompare(b.modelId))
189
+ return results
190
+ }
191
+
192
+ export type HealthStore = Readonly<Record<string, HealthRecord>>
193
+
194
+ function storeFile(): string {
195
+ return join(tmpdir(), 'opencode2pi', 'health.json')
196
+ }
197
+
198
+ export async function loadHealth(): Promise<HealthStore> {
199
+ try {
200
+ const parsed: unknown = JSON.parse(await readFile(storeFile(), 'utf8'))
201
+ if (typeof parsed !== 'object' || parsed === null) return {}
202
+ const out: Record<string, HealthRecord> = {}
203
+ for (const [id, value] of Object.entries(parsed)) {
204
+ if (typeof value !== 'object' || value === null) continue
205
+ if (!('health' in value) || !('checkedAt' in value)) continue
206
+ const record = value as Partial<HealthRecord>
207
+ if (typeof record.checkedAt !== 'number') continue
208
+ if (
209
+ record.health !== 'ok' &&
210
+ record.health !== 'flaky' &&
211
+ record.health !== 'dead' &&
212
+ record.health !== 'unknown' &&
213
+ record.health !== 'limited'
214
+ )
215
+ continue
216
+ out[id] = {
217
+ health: record.health,
218
+ kind: record.kind ?? 'UNKNOWN',
219
+ detail: typeof record.detail === 'string' ? record.detail : '',
220
+ checkedAt: record.checkedAt,
221
+ terminalFailures: typeof record.terminalFailures === 'number' ? record.terminalFailures : 0,
222
+ transientFailures: typeof record.transientFailures === 'number' ? record.transientFailures : 0,
223
+ }
224
+ }
225
+ return out
226
+ } catch {
227
+ return {}
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Merge fresh probe results into the store.
233
+ *
234
+ * A single `MODEL_GONE` is not enough to condemn: it is promoted to ❌ only
235
+ * after {@link TERMINAL_FAILURES_TO_CONDEMN} consecutive terminal verdicts, and
236
+ * any success clears both counters so a model with a bad afternoon recovers on
237
+ * its own. A model already marked dead keeps the verdict across a *transient*
238
+ * re-probe, but a success always clears it.
239
+ */
240
+ export function mergeHealth(previous: HealthStore, results: readonly ProbeResult[]): HealthStore {
241
+ const merged: Record<string, HealthRecord> = { ...previous }
242
+
243
+ for (const result of results) {
244
+ // Read the accumulating map, not the pre-call snapshot: two terminal
245
+ // verdicts for the same id within one batch must count as consecutive.
246
+ const before = merged[result.modelId]
247
+ const terminalFailures = result.kind === 'MODEL_GONE' ? (before?.terminalFailures ?? 0) + 1 : 0
248
+ const transientFailures = result.health === 'ok' ? 0 : (before?.transientFailures ?? 0) + 1
249
+
250
+ let health = result.health
251
+ if (result.kind === 'MODEL_GONE' && terminalFailures >= TERMINAL_FAILURES_TO_CONDEMN) {
252
+ health = 'dead'
253
+ } else if (before?.health === 'dead' && result.health !== 'ok') {
254
+ health = 'dead'
255
+ }
256
+
257
+ merged[result.modelId] = {
258
+ health,
259
+ kind: result.kind,
260
+ detail: result.detail,
261
+ checkedAt: result.checkedAt,
262
+ terminalFailures,
263
+ transientFailures,
264
+ }
265
+ }
266
+ return merged
267
+ }
268
+
269
+ /**
270
+ * Drop every model the current roster no longer contains.
271
+ *
272
+ * The free lane follows upstream: models get added and withdrawn, so a
273
+ * merge-only store accumulates the whole history of ids — `no-such-model-xyz`
274
+ * and other ids that were never real — and grows without bound. A probe covers
275
+ * exactly the current roster (`probeAll` walks it end to end), so the ids in
276
+ * `results` *are* the roster, and anything else is a model upstream retired.
277
+ * Keeping those records would answer no question anyone can still ask.
278
+ */
279
+ export function pruneHealth(store: HealthStore, keep: ReadonlySet<string>): HealthStore {
280
+ const pruned: Record<string, HealthRecord> = {}
281
+ for (const [id, record] of Object.entries(store)) {
282
+ if (keep.has(id)) pruned[id] = record
283
+ }
284
+ return pruned
285
+ }
286
+
287
+ /**
288
+ * Merge fresh probe results into the store and write it back.
289
+ *
290
+ * `keep` is the roster the caller just looked at, not the ids this batch
291
+ * covered: `status` probes only the models it has no record of, and pruning to
292
+ * the batch would throw away every verdict it just refused to re-run.
293
+ */
294
+ export async function saveHealth(results: readonly ProbeResult[], keep: ReadonlySet<string>): Promise<HealthStore> {
295
+ const merged = pruneHealth(mergeHealth(await loadHealth(), results), keep)
296
+
297
+ try {
298
+ await mkdir(join(tmpdir(), 'opencode2pi'), { recursive: true })
299
+ await writeFile(storeFile(), JSON.stringify(merged), 'utf8')
300
+ } catch {
301
+ // An unwritable cache only costs a re-probe.
302
+ }
303
+ return merged
304
+ }
305
+
306
+ export function isStale(record: HealthRecord | undefined): boolean {
307
+ return record === undefined || Date.now() - record.checkedAt > HEALTH_TTL_MS
308
+ }
309
+
310
+ /** Canonical order, so the summary reads the same way on every run. */
311
+ const VERDICT_ORDER: readonly ModelHealth[] = ['ok', 'flaky', 'limited', 'unknown', 'dead']
312
+
313
+ /**
314
+ * Summarize the roster's verdicts as they are *displayed*.
315
+ *
316
+ * The store, never the raw probe results, is the source of truth here: a second
317
+ * consecutive `ModelError` promotes ⚠️ to ❌ in {@link mergeHealth} only, so
318
+ * counting raw results makes the summary contradict the lines right below it. A
319
+ * model with no record is not a verdict and is not counted as one — its roster
320
+ * line says `未探测`, which is a statement about our coverage, not about the
321
+ * model.
322
+ */
323
+ export function summarizeHealth(roster: readonly { readonly id: string }[], store: HealthStore): string {
324
+ const counts: Record<ModelHealth, number> = { ok: 0, flaky: 0, dead: 0, unknown: 0, limited: 0 }
325
+ for (const model of roster) {
326
+ const health = store[model.id]?.health
327
+ if (health) counts[health]++
328
+ }
329
+ return VERDICT_ORDER.filter((health) => counts[health] > 0)
330
+ .map((health) => `${HEALTH_MARK[health]} ${counts[health]}`)
331
+ .join(' ')
332
+ }
package/src/host.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * omp resolves `@earendil-works/*` at runtime to its own built-in pi-ai copy
3
+ * through a legacy-compat shim (docs/FINDINGS.md §3.4), so the extension must
4
+ * import through that specifier or it would register its custom API into a
5
+ * second, unrelated registry instance.
6
+ *
7
+ * The npm package of the same name is an older build that predates
8
+ * `registerCustomApi` (docs/FINDINGS.md §3.3), so it is deliberately NOT a
9
+ * dependency: installing it would shadow this declaration and type the
10
+ * extension against an API surface the host does not have. Instead, point the
11
+ * specifier at the host's own type package, which ships with omp itself.
12
+ */
13
+ declare module '@earendil-works/pi-ai' {
14
+ export * from '@oh-my-pi/pi-ai'
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * opencode2pi — OpenCode Zen's anonymous free lane as an omp/pi provider.
3
+ *
4
+ * The built-in `opencode-zen` provider requires a paid `OPENCODE_API_KEY`.
5
+ * This extension needs none: the upstream gate is a request-shape check, not
6
+ * a credential check. Three conditions, all confirmed live on 2026-10-05:
7
+ *
8
+ * 1. `Authorization: Bearer public` (anonymous credential)
9
+ * 2. canonical session id `^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$`
10
+ * 3. an agent-shaped body: `tools` containing `bash` and `read`
11
+ *
12
+ * Non-streaming requests are rejected with FreeTierError. Host streaming is
13
+ * inherent to the openai-completions engine, so condition 3 is the only body
14
+ * change this extension has to make.
15
+ *
16
+ * Integration seam (verified against omp 18.6.1 — see docs/FINDINGS.md):
17
+ * - `pi.registerProvider` config accepts `onPayload` / `prepareRequest`
18
+ * WITHOUT ERROR but NEVER CALLS THEM. Do not use them.
19
+ * - The working seam is `registerCustomApi(apiId, streamSimple, sourceId)`,
20
+ * which receives `(model, context, options)` — a structured context, not a
21
+ * serialized body. Shape the context, then delegate to the host engine
22
+ * with `model.api` swapped back to `openai-completions` to avoid recursion.
23
+ * - `toolChoice` lives on the *options* object, not on `Context`; setting it
24
+ * on the context is a silent no-op.
25
+ */
26
+
27
+ import { createHash } from 'node:crypto'
28
+
29
+ import { createAssistantMessageEventStream, registerCustomApi, streamSimple } from '@earendil-works/pi-ai'
30
+ import type { AssistantMessage, Context, Model, SimpleStreamOptions } from '@earendil-works/pi-ai'
31
+ import type { ExtensionAPI, ProviderModelConfig } from '@oh-my-pi/pi-coding-agent'
32
+
33
+ import { discoverFreeModels } from './discovery.ts'
34
+ import { handleCommand } from './commands.ts'
35
+ import { classifyUpstreamFailure, formatFailure } from './errors.ts'
36
+ import { hidden, loadFilters, onHiddenChange, publishFilters, publishHealth } from './filters.ts'
37
+ import { loadHealth } from './health.ts'
38
+ import { PROCESS_SESSION, sessionForRequest, sessionHeaders } from './session.ts'
39
+ import { SEED_MODELS, UNAVAILABLE, VERIFIED_FREE } from './seed.ts'
40
+
41
+ const PROVIDER = 'opencode-zen-free'
42
+ const API_ID = 'openai-completions-zen-free'
43
+ const ZEN_BASE = 'https://opencode.ai/zen/v1'
44
+ const ANONYMOUS_KEY = 'public'
45
+ const SOURCE_ID = 'opencode2pi'
46
+
47
+ const PROJECT_ID = 'prj_' + createHash('sha256').update('omp:default-project').digest('hex').slice(0, 24)
48
+
49
+ const DISGUISE_HEADERS: Record<string, string> = {
50
+ 'user-agent': 'opencode/1.18.31 (win32 x64; node24.19.0)',
51
+ 'x-opencode-client': 'cli',
52
+ 'x-opencode-project': PROJECT_ID,
53
+ }
54
+
55
+ /** The upstream gate demands a `bash` and a `read` function in `tools`. */
56
+ const GATE_TOOL_NAMES = ['bash', 'read'] as const
57
+
58
+ const GATE_TOOLS = GATE_TOOL_NAMES.map((name) => ({
59
+ name,
60
+ description: 'Reserved for the host runtime; do not call it.',
61
+ parameters: { type: 'object' as const, properties: {} },
62
+ }))
63
+
64
+ /**
65
+ * Ensure the gate tools are present.
66
+ *
67
+ * The host ships real `bash` and `read` tools, so in practice this is a no-op
68
+ * and the stubs never reach the model. It only bites when a caller runs with no
69
+ * tools at all (a bare completion), where the gate would otherwise fail — so in
70
+ * that case the stubs arrive with `toolChoice: 'none'` to stop the model from
71
+ * calling a stub the host cannot service.
72
+ */
73
+ function withGateTools(
74
+ context: Context,
75
+ options: SimpleStreamOptions,
76
+ ): { context: Context; options: SimpleStreamOptions } {
77
+ const existing = context.tools ?? []
78
+ const names = new Set(existing.map((tool) => tool.name))
79
+ const missing = GATE_TOOLS.filter((tool) => !names.has(tool.name))
80
+ if (missing.length === 0) return { context, options }
81
+
82
+ if (existing.length > 0) return { context: { ...context, tools: [...existing, ...missing] }, options }
83
+
84
+ return {
85
+ context: { ...context, tools: [...missing] },
86
+ options: { ...options, toolChoice: 'none' },
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Replace a raw provider error with one that names the actual failure mode.
92
+ *
93
+ * `errorClassificationMessage` is preserved (and seeded from the original text
94
+ * when absent) because that is the field the host's recovery logic reads; only
95
+ * the human-facing `errorMessage` is rewritten. Without that split the
96
+ * classification would be display-only but would also erase the host's own
97
+ * retry decisions.
98
+ */
99
+ function explainFailure(modelId: string, message: AssistantMessage): AssistantMessage {
100
+ const original = message.errorMessage ?? ''
101
+ const classification = classifyUpstreamFailure(message.errorStatus, original)
102
+ return {
103
+ ...message,
104
+ errorMessage: formatFailure(modelId, classification),
105
+ errorClassificationMessage: message.errorClassificationMessage ?? original,
106
+ }
107
+ }
108
+
109
+ let log: ExtensionAPI['logger'] | undefined
110
+ let warnedMissingSession = false
111
+
112
+ /**
113
+ * Custom API handler: shapes the context, pins the conversation's session id,
114
+ * classifies failures, then delegates the actual streaming to the host engine.
115
+ */
116
+ registerCustomApi(API_ID, (model: Model, context: Context, options?: SimpleStreamOptions) => {
117
+ const incoming = options ?? {}
118
+ const gated = withGateTools(context, incoming)
119
+
120
+ const session = sessionForRequest(options)
121
+ if (!session.derived && !warnedMissingSession) {
122
+ warnedMissingSession = true
123
+ log?.warn(
124
+ `[${PROVIDER}] host supplied no session identity; using process-level session ${session.id}`,
125
+ )
126
+ }
127
+
128
+ // The session headers ride on the model we hand the engine rather than on
129
+ // a wrapped `options.fetch`: `fetch` is optional, and a missing wrapper
130
+ // would silently drop the session header and fail the upstream gate with a
131
+ // bare 403. Merging onto the model's own headers also preserves whatever
132
+ // the host already resolved there.
133
+ const builtin: Model = {
134
+ ...model,
135
+ api: 'openai-completions' as Model['api'],
136
+ headers: { ...model.headers, ...sessionHeaders(session.id) },
137
+ }
138
+ const forwarded = gated.options
139
+
140
+ const upstream = streamSimple(builtin, gated.context, forwarded)
141
+
142
+ // Failures arrive as a stream *event*, not a rejected promise, so
143
+ // classification means forwarding the stream and rewriting that event.
144
+ const relayed = createAssistantMessageEventStream()
145
+ void (async () => {
146
+ try {
147
+ for await (const event of upstream) {
148
+ relayed.push(
149
+ event.type === 'error'
150
+ ? { ...event, error: explainFailure(model.id, event.error) }
151
+ : event,
152
+ )
153
+ }
154
+ relayed.end(await upstream.result())
155
+ } catch (error) {
156
+ relayed.fail(error)
157
+ }
158
+ })()
159
+ return relayed
160
+ }, SOURCE_ID)
161
+
162
+ /**
163
+ * The catalog we publish. Starts as the seed — the only thing that can exist
164
+ * before discovery — and is replaced by the discovered list once it lands.
165
+ */
166
+ let catalog: readonly ProviderModelConfig[] = SEED_MODELS
167
+
168
+ /**
169
+ * Write the provider registry from the catalog minus what the user hid.
170
+ *
171
+ * This is the *only* place the model list is written, and it writes the whole
172
+ * list every time: re-registering a provider name replaces its entire model
173
+ * slice (host contract, `src/config/model-registry.ts`), which is what makes a
174
+ * filter choice reach `/model` and `--model`.
175
+ *
176
+ * Discovery is ours to run, not the host's. Registering `fetchDynamicModels`
177
+ * looked equivalent and is not: the host caches a dynamic result for 24 h
178
+ * under a fingerprint of its *own* empty static list, so a warm cache never
179
+ * calls the fetcher again, and a re-registration inside a session never re-runs
180
+ * discovery — the models merged at startup stay in the registry no matter what
181
+ * we filter. Both were measured: a hidden model came straight back after
182
+ * `/opencode2pi filter`, and one that had never been in the seed survived.
183
+ */
184
+ function applyProvider(pi: ExtensionAPI): void {
185
+ const excluded = hidden()
186
+ const models = catalog.filter((model) => !excluded.has(model.id))
187
+ if (models.length === 0) {
188
+ // The host reads a provider with no models as a URL override and keeps the
189
+ // previous list, so a filter that hides everything would silently do
190
+ // nothing. Say so instead.
191
+ log?.warn(`[${PROVIDER}] 过滤规则隐藏了全部模型,模型列表不会缩小;用 /opencode2pi filter 放宽`)
192
+ }
193
+
194
+ pi.registerProvider(PROVIDER, {
195
+ baseUrl: ZEN_BASE,
196
+ api: API_ID,
197
+ apiKey: ANONYMOUS_KEY,
198
+ authHeader: true,
199
+ headers: DISGUISE_HEADERS,
200
+ models: [...models],
201
+ })
202
+ }
203
+
204
+ /** Re-discover the free lane and republish. A failure keeps what we have. */
205
+ async function refreshCatalog(pi: ExtensionAPI): Promise<void> {
206
+ const { models, diagnostics } = await discoverFreeModels()
207
+ for (const line of diagnostics) log?.info(`[${PROVIDER}] ${line}`)
208
+ if (models.length === 0) return
209
+ catalog = models
210
+ applyProvider(pi)
211
+ }
212
+
213
+ export default async function (pi: ExtensionAPI): Promise<void> {
214
+ log = pi.logger
215
+
216
+ // The stored choices are part of what the roster is, so they are adopted
217
+ // before the first registration rather than applied on the next probe.
218
+ publishFilters(await loadFilters())
219
+ publishHealth(await loadHealth())
220
+ onHiddenChange(() => applyProvider(pi))
221
+ applyProvider(pi)
222
+
223
+ // The host awaits extension factories and only then drains the registration
224
+ // queue (`extensibility/extensions/loader.ts`), so waiting here is what lets
225
+ // `--model provider/id` resolve an id that discovery found and the seed does
226
+ // not carry. The seed is already registered, so a discovery failure costs
227
+ // nothing but the wait — and that wait is the one the host imposed on itself
228
+ // back when it owned discovery.
229
+ await refreshCatalog(pi)
230
+
231
+ pi.registerCommand('opencode2pi', {
232
+ description:
233
+ 'opencode2pi 诊断:doctor 检查形状闸门,status 显示上次探测结果,probe 重新探测全部模型,filter 设置隐藏规则',
234
+ handler: handleCommand,
235
+ })
236
+
237
+ pi.logger.info(
238
+ `[${PROVIDER}] registered · process session ${PROCESS_SESSION} · seed ${SEED_MODELS.length} models · ` +
239
+ `verified-free ${Object.keys(VERIFIED_FREE).length} · known-rejected ${Object.keys(UNAVAILABLE).length} · ` +
240
+ `hidden ${hidden().size}`,
241
+ )
242
+ }
243
+
244
+ export {
245
+ explainFailure,
246
+ withGateTools,
247
+ PROVIDER,
248
+ API_ID,
249
+ SOURCE_ID,
250
+ PROCESS_SESSION,
251
+ SEED_MODELS,
252
+ UNAVAILABLE,
253
+ VERIFIED_FREE,
254
+ }