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 +21 -0
- package/README.md +214 -2
- package/package.json +58 -4
- package/src/commands.ts +254 -0
- package/src/discovery.ts +260 -0
- package/src/doctor.ts +64 -0
- package/src/errors.ts +95 -0
- package/src/filters.ts +121 -0
- package/src/gate.ts +94 -0
- package/src/health.ts +332 -0
- package/src/host.d.ts +15 -0
- package/src/index.ts +254 -0
- package/src/metadata.ts +198 -0
- package/src/seed.ts +133 -0
- package/src/session.ts +107 -0
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
|
+
}
|