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/discovery.ts
ADDED
|
@@ -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
|
+
}
|