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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Free-model discovery (docs/PLAN.md §T2).
3
+ *
4
+ * Three sources, and the ladder is deliberate:
5
+ *
6
+ * S1 GET {zen}/v1/models — who is actually for sale right now
7
+ * S2 models.dev — what each one costs, and its real capabilities
8
+ * S3 static seed — works with no network at all
9
+ *
10
+ * S1 is what keeps dead ids out. Measured 2026-10-05: 36 ids are priced at
11
+ * zero in models.dev but only 13 of those are live on Zen — intersecting the
12
+ * two is the difference between a useful list and 23 entries that 400 on first
13
+ * use. S2 is what makes the judgment trustworthy: it is also the capability
14
+ * source for T1, so both tiers share one parse.
15
+ *
16
+ * Everything runs concurrently and under our own deadline, because the host
17
+ * kills `fetchDynamicModels` at a hard 15 s
18
+ * (`RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS`) and a rejected fetch leaves the
19
+ * provider with nothing. Returning the seed on any failure is the whole
20
+ * contract: discovery must never be able to break the provider.
21
+ */
22
+
23
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
24
+ import { tmpdir } from 'node:os'
25
+ import { join } from 'node:path'
26
+
27
+ import type { ProviderModelConfig } from '@oh-my-pi/pi-coding-agent'
28
+
29
+ import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, isFreeModel, normalizeEntry, type ModelsDevEntry } from './metadata.ts'
30
+ import { SEED_MODELS, VERIFIED_FREE } from './seed.ts'
31
+
32
+ const ZEN_BASE = 'https://opencode.ai/zen/v1'
33
+ const MODELS_DEV_URL = 'https://models.dev/api.json'
34
+ const MODELS_DEV_PROVIDER = 'opencode'
35
+ const ANONYMOUS_KEY = 'public'
36
+
37
+ /** Per-request deadline. Both run concurrently, so this is the whole budget. */
38
+ const REQUEST_TIMEOUT_MS = 9_000
39
+ /** models.dev changes sub-daily; a day-old cache costs nothing and survives outages. */
40
+ const METADATA_TTL_MS = 24 * 60 * 60 * 1000
41
+
42
+ interface MetadataCache {
43
+ readonly fetchedAt: number
44
+ readonly entries: readonly ModelsDevEntry[]
45
+ }
46
+
47
+ function cacheFile(): string {
48
+ return join(tmpdir(), 'opencode2pi', `models-dev-${MODELS_DEV_PROVIDER}.json`)
49
+ }
50
+
51
+ /** Narrows an arbitrary parsed JSON blob to a models.dev entry, or drops it. */
52
+ function readEntry(raw: unknown): ModelsDevEntry | undefined {
53
+ if (typeof raw !== 'object' || raw === null) return undefined
54
+ if (!('id' in raw) || typeof raw.id !== 'string' || raw.id.length === 0) return undefined
55
+ // The published shape is trusted field-by-field at use time; the container
56
+ // is the only thing worth checking structurally, because a malformed entry
57
+ // would otherwise surface as a confusing default-value log line.
58
+ return raw as ModelsDevEntry
59
+ }
60
+
61
+ async function readDiskCache(): Promise<MetadataCache | undefined> {
62
+ try {
63
+ const parsed: unknown = JSON.parse(await readFile(cacheFile(), 'utf8'))
64
+ if (typeof parsed !== 'object' || parsed === null) return undefined
65
+ if (!('entries' in parsed) || !('fetchedAt' in parsed)) return undefined
66
+ const { entries, fetchedAt } = parsed
67
+ if (typeof entries !== 'object' || entries === null) return undefined
68
+ if (typeof fetchedAt !== 'number' || !Number.isFinite(fetchedAt)) return undefined
69
+
70
+ const restored: ModelsDevEntry[] = []
71
+ for (const value of Object.values(entries)) {
72
+ const entry = readEntry(value)
73
+ if (entry) restored.push(entry)
74
+ }
75
+ return restored.length > 0 ? { entries: restored, fetchedAt } : undefined
76
+ } catch {
77
+ return undefined
78
+ }
79
+ }
80
+
81
+ async function writeDiskCache(entries: readonly ModelsDevEntry[]): Promise<void> {
82
+ const cache: MetadataCache = {
83
+ fetchedAt: Date.now(),
84
+ entries: [...entries],
85
+ }
86
+ try {
87
+ await mkdir(join(tmpdir(), 'opencode2pi'), { recursive: true })
88
+ await writeFile(cacheFile(), JSON.stringify(cache), 'utf8')
89
+ } catch {
90
+ // A cache we cannot write is a performance loss, never a failure.
91
+ }
92
+ }
93
+
94
+ /** Parse the provider slice out of models.dev's whole-catalogue payload. */
95
+ function parseCatalog(payload: unknown): ModelsDevEntry[] {
96
+ if (typeof payload !== 'object' || payload === null) return []
97
+ if (!(MODELS_DEV_PROVIDER in payload)) return []
98
+ const provider = payload[MODELS_DEV_PROVIDER]
99
+ if (typeof provider !== 'object' || provider === null) return []
100
+ if (!('models' in provider)) return []
101
+ const models = provider.models
102
+ if (typeof models !== 'object' || models === null) return []
103
+
104
+ const entries: ModelsDevEntry[] = []
105
+ for (const value of Object.values(models)) {
106
+ const entry = readEntry(value)
107
+ if (entry) entries.push(entry)
108
+ }
109
+ return entries
110
+ }
111
+
112
+ interface MetadataOutcome {
113
+ readonly entries: readonly ModelsDevEntry[]
114
+ readonly source: 'network' | 'cache' | 'none'
115
+ readonly detail?: string
116
+ }
117
+
118
+ let metadataInflight: Promise<MetadataOutcome> | undefined
119
+
120
+ /**
121
+ * Resolve models.dev entries.
122
+ *
123
+ * models.dev turns over sub-daily, so a cache younger than the TTL is used
124
+ * without touching the network at all — that is what keeps the discovery
125
+ * budget spent on the Zen catalogue, which genuinely has to be live. A stale
126
+ * cache still beats nothing: last month's capabilities are worth more than
127
+ * the host's silent 128000/16384 fallback.
128
+ */
129
+ export function loadMetadata(): Promise<MetadataOutcome> {
130
+ metadataInflight ??= (async (): Promise<MetadataOutcome> => {
131
+ const disk = await readDiskCache()
132
+ if (disk && Date.now() - disk.fetchedAt < METADATA_TTL_MS) {
133
+ return { entries: disk.entries, source: 'cache', detail: 'fresh' }
134
+ }
135
+ try {
136
+ const response = await fetch(MODELS_DEV_URL, {
137
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
138
+ headers: { accept: 'application/json' },
139
+ })
140
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
141
+ const entries = parseCatalog(await response.json())
142
+ if (entries.length === 0) throw new Error(`no models for provider ${MODELS_DEV_PROVIDER}`)
143
+ await writeDiskCache(entries)
144
+ return { entries, source: 'network' }
145
+ } catch (error) {
146
+ if (disk) return { entries: disk.entries, source: 'cache', detail: `stale: ${String(error)}` }
147
+ return { entries: [], source: 'none', detail: String(error) }
148
+ }
149
+ })()
150
+ return metadataInflight
151
+ }
152
+
153
+ /** Live catalogue straight from Zen: the authority on what exists right now. */
154
+ async function fetchLiveModelIds(): Promise<string[]> {
155
+ const response = await fetch(`${ZEN_BASE}/models`, {
156
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
157
+ headers: { accept: 'application/json', authorization: `Bearer ${'public'}` },
158
+ })
159
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
160
+ const payload: unknown = await response.json()
161
+ if (typeof payload !== 'object' || payload === null) throw new Error('unexpected /models shape')
162
+ if (!('data' in payload) || !Array.isArray(payload.data)) throw new Error('missing data[]')
163
+ const ids: string[] = []
164
+ for (const item of payload.data) {
165
+ if (typeof item === 'object' && item !== null && 'id' in item && typeof item.id === 'string') {
166
+ ids.push(item.id)
167
+ }
168
+ }
169
+ return ids
170
+ }
171
+
172
+ export interface DiscoveryResult {
173
+ readonly models: readonly ProviderModelConfig[]
174
+ readonly diagnostics: readonly string[]
175
+ }
176
+
177
+ /**
178
+ * Resolve the free lane. Never rejects and never returns an empty list: an
179
+ * empty or rejected fetch would leave the provider with no models at all.
180
+ */
181
+ export async function discoverFreeModels(): Promise<DiscoveryResult> {
182
+ const diagnostics: string[] = []
183
+
184
+ const [live, metadata] = await Promise.all([
185
+ fetchLiveModelIds().catch((error: unknown) => {
186
+ diagnostics.push(`S1 Zen /models unavailable (${String(error)}) → seed candidates`)
187
+ return undefined
188
+ }),
189
+ loadMetadata(),
190
+ ])
191
+
192
+ if (metadata.source === 'network') {
193
+ diagnostics.push(`S2 models.dev: ${metadata.entries.length} entries (network)`)
194
+ } else if (metadata.source === 'cache') {
195
+ diagnostics.push(`S2 models.dev: ${metadata.entries.length} entries (disk cache, ${metadata.detail ?? 'stale'})`)
196
+ } else {
197
+ diagnostics.push(`S2 models.dev unavailable (${metadata.detail ?? 'unknown'}) → seed capabilities`)
198
+ }
199
+
200
+ const byId = new Map<string, ModelsDevEntry>()
201
+ for (const entry of metadata.entries) byId.set(entry.id, entry)
202
+
203
+ // S3: the seed is always a candidate, so a Zen outage cannot empty the list.
204
+ const candidates = live ?? SEED_MODELS.map((model) => model.id)
205
+ if (live) diagnostics.push(`S1 Zen /models: ${live.length} ids live`)
206
+
207
+ const models: ProviderModelConfig[] = []
208
+ const seen = new Set<string>()
209
+ for (const id of candidates) {
210
+ if (seen.has(id)) continue
211
+ seen.add(id)
212
+ if (!isFreeModel(id, byId.get(id), VERIFIED_FREE)) continue
213
+
214
+ const entry = byId.get(id)
215
+ if (entry) {
216
+ const normalized = normalizeEntry(entry)
217
+ // Capability notes are the whole point of T1: a defaulted field that
218
+ // nobody logs is the failure mode FINDINGS §7.2 describes.
219
+ if (normalized.notes.length > 0) diagnostics.push(`${id}: ${normalized.notes.join('; ')}`)
220
+ const { config } = normalized
221
+ models.push({
222
+ ...config,
223
+ name: /free/i.test(config.name) ? config.name : `${config.name} (free)`,
224
+ })
225
+ continue
226
+ }
227
+
228
+ // No metadata for this id: the seed carries measured capabilities for the
229
+ // ids we have actually exercised, so prefer it over guessing.
230
+ const seed = SEED_MODELS.find((model) => model.id === id)
231
+ if (seed) {
232
+ models.push(seed)
233
+ continue
234
+ }
235
+ // Live and free-by-name but entirely unknown (no models.dev entry at
236
+ // all). Conservative on every capability: no reasoning, text only,
237
+ // host-generic limits — stated, not silent.
238
+ diagnostics.push(
239
+ `${id}: no metadata, free by name → conservative defaults ` +
240
+ `(${DEFAULT_CONTEXT_WINDOW}/${DEFAULT_MAX_TOKENS}, text only)`,
241
+ )
242
+ models.push({
243
+ id,
244
+ name: `${id} (free)`,
245
+ reasoning: false,
246
+ input: ['text'],
247
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
248
+ contextWindow: DEFAULT_CONTEXT_WINDOW,
249
+ maxTokens: DEFAULT_MAX_TOKENS,
250
+ })
251
+ }
252
+
253
+ if (models.length === 0) {
254
+ diagnostics.push('discovery produced no models → seed')
255
+ return { models: SEED_MODELS, diagnostics }
256
+ }
257
+
258
+ diagnostics.push(`final: ${models.length} free models`)
259
+ return { models, diagnostics }
260
+ }
package/src/doctor.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `/opencode2pi doctor` (docs/PLAN.md §T5).
3
+ *
4
+ * The shape gate is hardcoded in this extension, and nothing about it is
5
+ * negotiated: when upstream changes the check, *every* request starts failing
6
+ * with a bare 403 and no model in particular looks guilty. Upstream has revised
7
+ * its policy three times in three weeks, so this turns that silent total
8
+ * failure into one explicit, classified verdict.
9
+ *
10
+ * One request, because the gate is global — a second probe would only confirm
11
+ * what the first already decided.
12
+ */
13
+
14
+ import { classifyUpstreamFailure, type UpstreamFailure } from './errors.ts'
15
+ import { GATE_CONDITIONS, sendGateProbe } from './gate.ts'
16
+
17
+ export interface DoctorReport {
18
+ readonly ok: boolean
19
+ readonly kind: UpstreamFailure | 'OK'
20
+ readonly headline: string
21
+ readonly detail: string
22
+ readonly latencyMs: number
23
+ readonly modelId: string
24
+ }
25
+
26
+ export async function runDoctor(modelId: string): Promise<DoctorReport> {
27
+ const probe = await sendGateProbe(modelId)
28
+
29
+ if (probe.ok) {
30
+ return {
31
+ ok: true,
32
+ kind: 'OK',
33
+ modelId,
34
+ headline: `✅ 闸门正常(${modelId},${probe.latencyMs}ms)`,
35
+ detail: '三个必要条件仍然成立,匿名通道可用。',
36
+ latencyMs: probe.latencyMs,
37
+ }
38
+ }
39
+
40
+ if (probe.transportError !== undefined) {
41
+ return {
42
+ ok: false,
43
+ kind: 'UPSTREAM',
44
+ modelId,
45
+ headline: `无法连接 Zen:${probe.transportError}`,
46
+ detail: '这是网络或代理问题,不是闸门问题。检查 PI_PROXY / 网络连通性后重试。',
47
+ latencyMs: probe.latencyMs,
48
+ }
49
+ }
50
+
51
+ const classification = classifyUpstreamFailure(probe.status, probe.body)
52
+ const shapeBroken = classification.kind === 'SHAPE_REJECTED'
53
+
54
+ return {
55
+ ok: false,
56
+ kind: classification.kind,
57
+ modelId,
58
+ latencyMs: probe.latencyMs,
59
+ headline: `${shapeBroken ? '🚨' : '⚠️'} 网关拒绝了这次请求:${classification.summary}`,
60
+ detail: shapeBroken
61
+ ? `上游很可能改了形状闸门,本扩展的硬编码已过期。本次发送的条件:\n${GATE_CONDITIONS.map((c) => ` - ${c}`).join('\n')}\n请提 issue。原始响应:${probe.body.slice(0, 300)}`
62
+ : `原始响应(HTTP ${probe.status}):${probe.body.slice(0, 300)}`,
63
+ }
64
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Upstream failure classification (docs/PLAN.md §T4).
3
+ *
4
+ * Without this the user sees `Error from provider (Console): ...` and has to
5
+ * guess. On the anonymous lane the difference is not cosmetic: a region block
6
+ * and a broken shape gate produce *both* a 403, and telling someone their
7
+ * "API key is invalid" when their network is geo-blocked sends them looking in
8
+ * exactly the wrong place. opencode2dsh shipped that same bug until 0.3.5.
9
+ *
10
+ * So the classifier is ordered by how much the distinction costs when missed:
11
+ * region and credential are checked before the generic auth verdict, because
12
+ * both are 403/401 responses that otherwise collapse into "auth problem".
13
+ */
14
+
15
+ export type UpstreamFailure =
16
+ | 'SHAPE_REJECTED'
17
+ | 'REGION_BLOCKED'
18
+ | 'MODEL_GONE'
19
+ | 'RATE_LIMIT'
20
+ | 'REQUEST_REJECTED'
21
+ | 'UPSTREAM'
22
+ | 'AUTH'
23
+ | 'UNKNOWN'
24
+
25
+ export interface Classification {
26
+ readonly kind: UpstreamFailure
27
+ /** Operator-facing explanation; this replaces the raw provider string. */
28
+ readonly summary: string
29
+ }
30
+
31
+ const ADVICE: Readonly<Record<UpstreamFailure, string>> = {
32
+ SHAPE_REJECTED: '上游形状闸门拒绝(FreeTierError):通常是本扩展的 bug,请提 issue。闸门需要 Bearer public + 规范 session id + tools 里同时有 bash 和 read。',
33
+ REGION_BLOCKED: '该模型在当前网络地区不可用(RegionError):这是地区封锁,不是凭据问题,换一个模型或网络节点。',
34
+ MODEL_GONE: '模型已下线(ModelError):上游已不再支持该模型,请查看可用模型列表。',
35
+ RATE_LIMIT: '匿名配额按出口 IP 限流(429):等待配额恢复,或设置 PI_PROXY_OPENCODE_ZEN_FREE 换出口(宿主按 provider id 派生命名:opencode-zen-free → OPENCODE_ZEN_FREE)。',
36
+ REQUEST_REJECTED: '上游拒绝了请求(400):该模型无法处理这次输入。最常见的是给只支持文本的模型发图片 —— 本扩展按 models.dev 的 modalities 声明放行,换一个支持对应输入的模型即可。',
37
+ UPSTREAM: '上游暂时不可用(5xx):稍后重试,或换一个模型。',
38
+ AUTH: '匿名凭据被拒:本扩展不需要 API key,出现这个说明上游认证方式变了,请提 issue。',
39
+ UNKNOWN: '未分类的上游错误。',
40
+ }
41
+
42
+ /**
43
+ * Classify a failed response.
44
+ *
45
+ * `status` is the provider-reported HTTP status, which every provider in the
46
+ * host populates on the error message. Marker strings are checked first and are
47
+ * the stronger signal: they name the upstream failure mode outright, and they
48
+ * survive a change in how the status is surfaced.
49
+ */
50
+ export function classifyUpstreamFailure(status: number | undefined, message: string): Classification {
51
+ const text = message.toLowerCase()
52
+
53
+ // Region before everything auth-shaped: a geo block is a 403, so any
54
+ // status-driven auth verdict reached first would mislabel it.
55
+ if (text.includes('regionerror') || text.includes('not available in your country') || text.includes('geo-block')) {
56
+ return { kind: 'REGION_BLOCKED', summary: ADVICE.REGION_BLOCKED }
57
+ }
58
+ if (text.includes('freetiererror') || text.includes('free tier') || text.includes('missingsessionid')) {
59
+ return { kind: 'SHAPE_REJECTED', summary: ADVICE.SHAPE_REJECTED }
60
+ }
61
+ if (text.includes('modelerror') || text.includes('model is unavailable') || text.includes('not supported')) {
62
+ return { kind: 'MODEL_GONE', summary: ADVICE.MODEL_GONE }
63
+ }
64
+ if (status === 429 || text.includes('rate limit') || text.includes('too many requests')) {
65
+ return { kind: 'RATE_LIMIT', summary: ADVICE.RATE_LIMIT }
66
+ }
67
+ if (status === 401) return { kind: 'AUTH', summary: ADVICE.AUTH }
68
+ if (status !== undefined && status >= 500) return { kind: 'UPSTREAM', summary: ADVICE.UPSTREAM }
69
+
70
+ // An unmarked 403 on this lane is overwhelmingly the shape gate: the
71
+ // anonymous credential is a fixed literal, so there is no key to have got
72
+ // wrong. Saying "auth" here would send users hunting for a key they do not
73
+ // have and never needed.
74
+ if (status === 403) return { kind: 'SHAPE_REJECTED', summary: ADVICE.SHAPE_REJECTED }
75
+
76
+ // A 400 is the model refusing the *content*, not the lane: measured on a
77
+ // text-only model handed a PNG, it arrives as "400 ... Endpoint is
78
+ // unavailable." — the same phrase a genuine outage uses. Status decides,
79
+ // because "retry later" advice for a request that can never succeed sends
80
+ // the user into a pointless loop.
81
+ if (status === 400) return { kind: 'REQUEST_REJECTED', summary: ADVICE.REQUEST_REJECTED }
82
+
83
+ // Only with no status at all does the free text decide between an outage
84
+ // and something unrecognized.
85
+ if (text.includes('endpoint is unavailable') || text.includes('service unavailable') || text.includes('bad gateway')) {
86
+ return { kind: 'UPSTREAM', summary: ADVICE.UPSTREAM }
87
+ }
88
+
89
+ return { kind: 'UNKNOWN', summary: `${ADVICE.UNKNOWN} ${message}` }
90
+ }
91
+
92
+ /** Compose the message the host will display for a classified failure. */
93
+ export function formatFailure(modelId: string, classification: Classification): string {
94
+ return `[opencode2pi/${modelId}] ${classification.summary}`
95
+ }
package/src/filters.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Which models the roster hides, decided by the user rather than by us.
3
+ *
4
+ * Health is annotation by default: a model that failed one probe stays in the
5
+ * list, because the evidence says verdicts move (docs/FINDINGS.md §8 —
6
+ * `nemotron-3.ultra-free` answered `401 ModelError` and then `200` a minute
7
+ * apart). Anything automatic would therefore be wrong half the time, so the rule
8
+ * set is opt-in and the user picks it in the TUI.
9
+ *
10
+ * What is *never* offered is hiding on a quota pause or an unreachable lane:
11
+ * those are facts about the channel, not about the model, and hiding on them
12
+ * empties the roster at exactly the moment it is worth having.
13
+ */
14
+
15
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
16
+ import { tmpdir } from 'node:os'
17
+ import { join } from 'node:path'
18
+
19
+ import type { HealthStore, ModelHealth } from './health.ts'
20
+
21
+ export interface ProbeFilters {
22
+ /** Hide models the upstream refuses from this egress. */
23
+ readonly hideRegionBlocked: boolean
24
+ /** Hide models that failed for a reason attributable to them. */
25
+ readonly hideFailed: boolean
26
+ }
27
+
28
+ export const DEFAULT_FILTERS: ProbeFilters = { hideRegionBlocked: false, hideFailed: false }
29
+
30
+ /**
31
+ * Verdicts that belong to the model rather than to the lane.
32
+ *
33
+ * Keyed on the *displayed* verdict rather than on the failure kind, because that
34
+ * is what the roster line shows and what the promise has to hold against:
35
+ * `RATE_LIMIT` and a transport timeout are both 🚧 `limited`, and a 400 or an
36
+ * unrecognised 5xx is ⚠️ `flaky`. Keying on the verdict makes "配额与链路故障
37
+ * 永远不隐藏任何模型" true by construction instead of by a list someone can
38
+ * forget to update.
39
+ */
40
+ const HIDDEN_VERDICTS: ReadonlySet<ModelHealth> = new Set<ModelHealth>(['dead', 'flaky'])
41
+
42
+ /** Ids the roster should not advertise under these filters. */
43
+ export function hiddenFromStore(store: HealthStore, filters: ProbeFilters): ReadonlySet<string> {
44
+ if (!filters.hideFailed && !filters.hideRegionBlocked) return new Set()
45
+ const hidden = new Set<string>()
46
+ for (const [id, record] of Object.entries(store)) {
47
+ if (filters.hideFailed && HIDDEN_VERDICTS.has(record.health)) hidden.add(id)
48
+ else if (filters.hideRegionBlocked && record.kind === 'REGION_BLOCKED') hidden.add(id)
49
+ }
50
+ return hidden
51
+ }
52
+
53
+ function storeFile(): string {
54
+ return join(tmpdir(), 'opencode2pi', 'filters.json')
55
+ }
56
+
57
+ export async function loadFilters(): Promise<ProbeFilters> {
58
+ try {
59
+ const parsed: unknown = JSON.parse(await readFile(storeFile(), 'utf8'))
60
+ if (typeof parsed !== 'object' || parsed === null) return DEFAULT_FILTERS
61
+ const record = parsed as Partial<ProbeFilters>
62
+ return {
63
+ hideRegionBlocked: record.hideRegionBlocked === true,
64
+ hideFailed: record.hideFailed === true,
65
+ }
66
+ } catch {
67
+ return DEFAULT_FILTERS
68
+ }
69
+ }
70
+
71
+ export async function saveFilters(filters: ProbeFilters): Promise<void> {
72
+ try {
73
+ await mkdir(join(tmpdir(), 'opencode2pi'), { recursive: true })
74
+ await writeFile(storeFile(), JSON.stringify(filters), 'utf8')
75
+ } catch {
76
+ // An unwritable filter file costs one re-selection, never a wrong roster.
77
+ }
78
+ }
79
+
80
+ let activeFilters: ProbeFilters = DEFAULT_FILTERS
81
+ let activeHealth: HealthStore = {}
82
+ let activeHidden: ReadonlySet<string> = new Set()
83
+
84
+ const listeners: (() => void)[] = []
85
+
86
+ /** Ids the roster must not advertise right now. */
87
+ export function hidden(): ReadonlySet<string> {
88
+ return activeHidden
89
+ }
90
+
91
+ /** Re-derive the hidden set and tell the provider registry when it moved. */
92
+ function republish(): void {
93
+ const next = hiddenFromStore(activeHealth, activeFilters)
94
+ if (next.size === activeHidden.size && [...next].every((id) => activeHidden.has(id))) return
95
+ activeHidden = next
96
+ for (const listener of listeners) listener()
97
+ }
98
+
99
+ /**
100
+ * Adopt the user's choice. The caller has already asked; this only records the
101
+ * answer and re-derives what it hides.
102
+ */
103
+ export function publishFilters(filters: ProbeFilters): void {
104
+ activeFilters = filters
105
+ republish()
106
+ }
107
+
108
+ /** Adopt a fresh verdict store, so the hidden set follows every probe. */
109
+ export function publishHealth(store: HealthStore): void {
110
+ activeHealth = store
111
+ republish()
112
+ }
113
+
114
+ /** Called by the provider registration when the roster it advertises changes. */
115
+ export function onHiddenChange(listener: () => void): () => void {
116
+ listeners.push(listener)
117
+ return () => {
118
+ const index = listeners.indexOf(listener)
119
+ if (index >= 0) listeners.splice(index, 1)
120
+ }
121
+ }
package/src/gate.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The gate-shaped request, in one place.
3
+ *
4
+ * Both `doctor` and the per-model health probe need to put a minimal
5
+ * agent-shaped request in front of Zen, and they must send *exactly* the same
6
+ * bytes: if the two drifted, `doctor` could report a healthy gate that the real
7
+ * path no longer satisfies, which is the one failure this code exists to catch.
8
+ *
9
+ * The shape is not negotiable (docs/FINDINGS.md §2). All three conditions are
10
+ * load-bearing, and the third has two independent halves — a non-streaming
11
+ * request is rejected even with both tools present.
12
+ */
13
+
14
+ import { canonicalSessionID, sessionHeaders } from './session.ts'
15
+
16
+ const ZEN_BASE = 'https://opencode.ai/zen/v1'
17
+ const ANONYMOUS_KEY = 'public'
18
+ const GATE_TIMEOUT_MS = 30_000
19
+
20
+ export interface GateProbe {
21
+ readonly ok: boolean
22
+ readonly status: number | undefined
23
+ readonly body: string
24
+ readonly latencyMs: number
25
+ /** Set when the request never produced an HTTP response. */
26
+ readonly transportError?: string
27
+ }
28
+
29
+ /** Tool stubs the gate requires; never meant to be called. */
30
+ function gateTools() {
31
+ const stub = (name: string) => ({
32
+ type: 'function' as const,
33
+ function: {
34
+ name,
35
+ description: 'Reserved for the host runtime; do not call it.',
36
+ parameters: { type: 'object', properties: {} },
37
+ },
38
+ })
39
+ return [stub('bash'), stub('read')]
40
+ }
41
+
42
+
43
+ /** The three gate conditions, restated so a failing `doctor` is actionable. */
44
+ export const GATE_CONDITIONS: readonly string[] = [
45
+ 'Authorization: Bearer public',
46
+ 'canonical session id ^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$',
47
+ 'agent-shaped body: stream:true and tools containing bash and read',
48
+ ]
49
+ /**
50
+ * Send one minimal gate-shaped streaming request and report what came back.
51
+ *
52
+ * On success the stream is cancelled rather than drained: reaching a 200 *is*
53
+ * the verdict, and reading a completion would spend tokens to learn nothing new.
54
+ * The session id is derived per call so repeated probes do not share upstream
55
+ * cache affinity.
56
+ */
57
+ export async function sendGateProbe(modelId: string): Promise<GateProbe> {
58
+ const startedAt = Date.now()
59
+ const session = canonicalSessionID(`opencode2pi:gate:${modelId}:${startedAt}`)
60
+
61
+ let response: Response
62
+ try {
63
+ response = await fetch(`${ZEN_BASE}/chat/completions`, {
64
+ method: 'POST',
65
+ headers: {
66
+ 'user-agent': 'opencode/1.18.31 (win32 x64; node24.19.0)',
67
+ 'x-opencode-client': 'cli',
68
+ 'x-opencode-project': 'prj_opencode2pi00000000000000',
69
+ authorization: `Bearer ${ANONYMOUS_KEY}`,
70
+ 'content-type': 'application/json',
71
+ ...sessionHeaders(session),
72
+ },
73
+ body: JSON.stringify({
74
+ model: modelId,
75
+ stream: true,
76
+ max_tokens: 16,
77
+ messages: [{ role: 'user', content: 'Reply with OK.' }],
78
+ tools: gateTools(),
79
+ }),
80
+ signal: AbortSignal.timeout(GATE_TIMEOUT_MS),
81
+ })
82
+ } catch (error) {
83
+ return { ok: false, status: undefined, body: '', latencyMs: Date.now() - startedAt, transportError: String(error) }
84
+ }
85
+
86
+ const latencyMs = Date.now() - startedAt
87
+ if (response.ok) {
88
+ await response.body?.cancel()
89
+ return { ok: true, status: response.status, body: '', latencyMs }
90
+ }
91
+
92
+ const body = await response.text().catch(() => '')
93
+ return { ok: false, status: response.status, body, latencyMs }
94
+ }