@genee/omp-opsx-addon 0.1.0
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/README.md +83 -0
- package/index.ts +1327 -0
- package/lib/agent-defs.ts +291 -0
- package/lib/change-scanner.ts +72 -0
- package/lib/cursor-auth.ts +230 -0
- package/lib/direct-fetchers.ts +155 -0
- package/lib/edit-variant-pin.ts +156 -0
- package/lib/error-scan.ts +97 -0
- package/lib/family-filter.ts +174 -0
- package/lib/model-selector.ts +307 -0
- package/lib/model-tiers.ts +164 -0
- package/lib/notify.ts +4 -0
- package/lib/openspec-tasks.ts +28 -0
- package/lib/peak-detector.ts +44 -0
- package/lib/reachability-cache.ts +288 -0
- package/lib/reachability-probe.ts +459 -0
- package/lib/reachability-refresh.ts +84 -0
- package/lib/selection-filters.ts +100 -0
- package/lib/selection-lifecycle.ts +17 -0
- package/lib/session-title.ts +186 -0
- package/lib/session-usage.ts +252 -0
- package/lib/state.ts +95 -0
- package/lib/system-prompt.ts +160 -0
- package/lib/tiers-data.ts +136 -0
- package/lib/tiers-updater.ts +146 -0
- package/lib/unified-config.ts +463 -0
- package/lib/usage-estimator.ts +154 -0
- package/lib/usage-poller.ts +1200 -0
- package/lib/usage-poller.worker.ts +31 -0
- package/lib/usage-redis-client.ts +594 -0
- package/lib/usage-redis-multiproc.worker.ts +80 -0
- package/lib/usage-redis-probe.ts +389 -0
- package/lib/usage-render.ts +526 -0
- package/lib/usage-resolver.ts +477 -0
- package/lib/usage-sampler.ts +77 -0
- package/lib/usage-widget.ts +112 -0
- package/package.json +47 -0
- package/types/bun.d.ts +21 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider 可达性探测模块。
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* 1. 从候选模型列表中按 provider 分组提取去重端点
|
|
6
|
+
* 2. 对端点执行 TLS 握手探测(判可达)
|
|
7
|
+
* 3. 可选第二层 HTTP 探测(probe_http_enabled 开启时)
|
|
8
|
+
*
|
|
9
|
+
* 探测策略(遵循 design.md D1-D3):
|
|
10
|
+
* - D1: 按 provider 去重端点,同 host 任一可达即 provider 可达(宽容策略)
|
|
11
|
+
* - D2: 第一层 TLS 握手探测,成功或证书验证阶段错误判可达,超时/连接失败/重置判不可达
|
|
12
|
+
* - D3: 第二层 HTTP 探测默认关闭,留接口
|
|
13
|
+
*
|
|
14
|
+
* 探测异常全部吞没并 warn,不向上抛出。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { connect as tlsConnect } from 'node:tls';
|
|
18
|
+
import type { TLSSocket } from 'node:tls';
|
|
19
|
+
import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
|
|
20
|
+
import { resolveCursorAccessToken } from './cursor-auth.js';
|
|
21
|
+
|
|
22
|
+
// ── 类型 ────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** 去重后的探测端点。 */
|
|
25
|
+
export interface ProbeEndpoint {
|
|
26
|
+
host: string;
|
|
27
|
+
port: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 单端点探测结果。 */
|
|
31
|
+
export interface ProbeResult {
|
|
32
|
+
reachable: boolean;
|
|
33
|
+
/** 探测方法:'tls'、'http' 或 'discovery' */
|
|
34
|
+
method: 'tls' | 'http' | 'discovery';
|
|
35
|
+
/** 被探测的主机名 */
|
|
36
|
+
host: string;
|
|
37
|
+
/** 失败原因,仅 reachable=false 时有值 */
|
|
38
|
+
reason?: string;
|
|
39
|
+
/** 模型级探测的真实可用模型 id 列表(仅 discovery 方法) */
|
|
40
|
+
availableModelIds?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* TLS 连接工厂签名,测试 seam 可替换。
|
|
45
|
+
* 默认实现为 node:tls 的 connect。
|
|
46
|
+
*/
|
|
47
|
+
export interface TlsConnectFn {
|
|
48
|
+
(opts: {
|
|
49
|
+
host: string;
|
|
50
|
+
port: number;
|
|
51
|
+
servername: string;
|
|
52
|
+
timeout: number;
|
|
53
|
+
}): TLSSocket;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 每个 provider 的去重端点集合。 */
|
|
57
|
+
export type ProviderEndpoints = Map<string, ProbeEndpoint[]>;
|
|
58
|
+
|
|
59
|
+
// ── 端点提取 ─────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 解析 URL 中的 host 与 port,未指定端口默认 443。
|
|
63
|
+
*/
|
|
64
|
+
function parseHostPort(baseUrl: string): ProbeEndpoint | null {
|
|
65
|
+
try {
|
|
66
|
+
const u = new URL(baseUrl);
|
|
67
|
+
const host = u.hostname;
|
|
68
|
+
if (!host) return null;
|
|
69
|
+
return { host, port: u.port ? parseInt(u.port, 10) : 443 };
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 从模型列表中按 provider 分组提取去重端点。
|
|
77
|
+
* 同一 provider 的模型共用 baseUrl 端点,只保留一次。
|
|
78
|
+
*/
|
|
79
|
+
export function extractEndpoints(models: Model<Api>[]): ProviderEndpoints {
|
|
80
|
+
const result: ProviderEndpoints = new Map();
|
|
81
|
+
for (const m of models) {
|
|
82
|
+
if (!m.baseUrl) continue;
|
|
83
|
+
const ep = parseHostPort(m.baseUrl);
|
|
84
|
+
if (!ep) continue;
|
|
85
|
+
|
|
86
|
+
let eps = result.get(m.provider);
|
|
87
|
+
if (!eps) {
|
|
88
|
+
eps = [];
|
|
89
|
+
result.set(m.provider, eps);
|
|
90
|
+
}
|
|
91
|
+
// 去重:同一 provider 下同 host:port 只保留一个
|
|
92
|
+
if (!eps.some((e) => e.host === ep.host && e.port === ep.port)) {
|
|
93
|
+
eps.push(ep);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── 证书错误判定 ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** 错误消息包含这些子串 → 证书验证阶段错误 → 判可达。 */
|
|
102
|
+
const CERT_ERROR_TOKENS = [
|
|
103
|
+
'CERT_',
|
|
104
|
+
'UNABLE_TO_VERIFY',
|
|
105
|
+
'certificate',
|
|
106
|
+
'self signed',
|
|
107
|
+
'SELF_SIGNED',
|
|
108
|
+
'DEPTH_ZERO_SELF_SIGNED',
|
|
109
|
+
'ERR_SSL_',
|
|
110
|
+
'SSL_',
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 判定 TLS 错误是否为证书验证阶段错误(非网络不可达)。
|
|
115
|
+
* 规则 design.md D2:握手到达证书验证阶段即判可达。
|
|
116
|
+
*/
|
|
117
|
+
function isCertError(msg: string): boolean {
|
|
118
|
+
return CERT_ERROR_TOKENS.some((tok) => msg.includes(tok));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── 单端点 TLS 探测 ──────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 对单个端点执行 TLS 握手探测。
|
|
125
|
+
*
|
|
126
|
+
* 判定规则(design.md D2):
|
|
127
|
+
* - TLS 握手成功 → 可达
|
|
128
|
+
* - 证书验证阶段错误 → 可达(证书问题不等于网络不可达)
|
|
129
|
+
* - 超时 → 不可达
|
|
130
|
+
* - 连接拒绝/重置/网络不可达 → 不可达
|
|
131
|
+
*/
|
|
132
|
+
function probeTlsEndpoint(
|
|
133
|
+
endpoint: ProbeEndpoint,
|
|
134
|
+
timeoutMs: number,
|
|
135
|
+
connectFn: TlsConnectFn,
|
|
136
|
+
): Promise<ProbeResult> {
|
|
137
|
+
return new Promise<ProbeResult>((resolve) => {
|
|
138
|
+
const socket = connectFn({
|
|
139
|
+
host: endpoint.host,
|
|
140
|
+
port: endpoint.port,
|
|
141
|
+
servername: endpoint.host,
|
|
142
|
+
timeout: timeoutMs,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
let settled = false;
|
|
146
|
+
|
|
147
|
+
const settle = (reachable: boolean, reason?: string) => {
|
|
148
|
+
if (settled) return;
|
|
149
|
+
settled = true;
|
|
150
|
+
try { socket.destroy(); } catch { /* ignore */ }
|
|
151
|
+
resolve({ reachable, method: 'tls', host: endpoint.host, reason });
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
socket.on('secureConnect', () => settle(true));
|
|
155
|
+
|
|
156
|
+
socket.on('error', (err: NodeJS.ErrnoException) => {
|
|
157
|
+
const msg = err.message || '';
|
|
158
|
+
if (isCertError(msg)) {
|
|
159
|
+
settle(true);
|
|
160
|
+
} else {
|
|
161
|
+
settle(false, msg);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
socket.on('timeout', () => {
|
|
166
|
+
settle(false, `timeout after ${timeoutMs}ms`);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── HTTP 探测(第二层,默认关闭)─────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/** HTTP 探测配置 */
|
|
174
|
+
export interface HttpProbeOptions {
|
|
175
|
+
/** API key resolver(用于最小 chat completion 请求,可同步或异步) */
|
|
176
|
+
getApiKey?: (provider: string) => string | undefined | Promise<string | undefined>;
|
|
177
|
+
/** 超时 ms */
|
|
178
|
+
timeoutMs: number;
|
|
179
|
+
/** 最小 chat completion 使用的模型 ID */
|
|
180
|
+
model?: string;
|
|
181
|
+
/** fetch 实现(测试 seam,默认全局 fetch) */
|
|
182
|
+
fetchImpl?: typeof fetch;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 区域封锁判定的关键词 */
|
|
186
|
+
const REGION_BLOCK_TOKENS = ['region', 'not available', 'in your area'];
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 对 TLS 可达的端点发送最小 HTTP 请求(1 token chat completion),
|
|
190
|
+
* 检测区域封锁类 403。
|
|
191
|
+
*
|
|
192
|
+
* probe_http_enabled 时在第一层 TLS 探测之后调用。
|
|
193
|
+
* 返回 403 且错误文本包含 region 字样 → 判不可达。
|
|
194
|
+
* 200/401/404/429 判可达(网络可达,只是鉴权/路径/限流问题)。
|
|
195
|
+
* 网络异常/超时吞没并判不可达。
|
|
196
|
+
*/
|
|
197
|
+
export async function probeHttp(
|
|
198
|
+
endpoint: ProbeEndpoint,
|
|
199
|
+
provider: string,
|
|
200
|
+
opts: HttpProbeOptions,
|
|
201
|
+
): Promise<ProbeResult> {
|
|
202
|
+
const fetchFn = opts.fetchImpl ?? fetch;
|
|
203
|
+
const apiKey = await opts.getApiKey?.(provider);
|
|
204
|
+
const model = opts.model ?? 'default';
|
|
205
|
+
|
|
206
|
+
const base = endpoint.port === 443
|
|
207
|
+
? `https://${endpoint.host}`
|
|
208
|
+
: `https://${endpoint.host}:${endpoint.port}`;
|
|
209
|
+
const url = `${base}/chat/completions`;
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const headers: Record<string, string> = {
|
|
213
|
+
'Content-Type': 'application/json',
|
|
214
|
+
};
|
|
215
|
+
if (apiKey) {
|
|
216
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const response = await fetchFn(url, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers,
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
model,
|
|
224
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
225
|
+
max_tokens: 1,
|
|
226
|
+
}),
|
|
227
|
+
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// 403 + region 关键词 → 区域封锁 → 不可达
|
|
231
|
+
if (response.status === 403) {
|
|
232
|
+
try {
|
|
233
|
+
const text = await response.text();
|
|
234
|
+
const lower = text.toLowerCase();
|
|
235
|
+
if (REGION_BLOCK_TOKENS.some((tok) => lower.includes(tok))) {
|
|
236
|
+
return {
|
|
237
|
+
reachable: false,
|
|
238
|
+
method: 'http',
|
|
239
|
+
host: endpoint.host,
|
|
240
|
+
reason: `403 region blocked: ${text.slice(0, 200)}`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// 无法读取响应体 → 至少 network 是通的 → 可达
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 200/401/404/429 → 网络可达(鉴权失败/模型不存在/限流 = 网络正常)
|
|
249
|
+
return {
|
|
250
|
+
reachable: true,
|
|
251
|
+
method: 'http',
|
|
252
|
+
host: endpoint.host,
|
|
253
|
+
};
|
|
254
|
+
} catch (e: unknown) {
|
|
255
|
+
// 网络异常/超时 → 不可达;吞没异常不向上抛
|
|
256
|
+
const err = e as Error & { name?: string };
|
|
257
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
|
|
258
|
+
return {
|
|
259
|
+
reachable: false,
|
|
260
|
+
method: 'http',
|
|
261
|
+
host: endpoint.host,
|
|
262
|
+
reason: `timeout after ${opts.timeoutMs}ms`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
reachable: false,
|
|
267
|
+
method: 'http',
|
|
268
|
+
host: endpoint.host,
|
|
269
|
+
reason: err.message ?? String(e),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── 模型级可用性探测(动态发现过滤)──────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/** 模型级探测配置 */
|
|
277
|
+
export interface AvailableModelsProbeOptions {
|
|
278
|
+
/** 超时 ms,默认 3000 */
|
|
279
|
+
timeoutMs?: number;
|
|
280
|
+
/** fetch 实现(测试 seam,默认全局 fetch) */
|
|
281
|
+
fetchImpl?: typeof fetch;
|
|
282
|
+
/** cursor access token 解析器(默认用 cursor-auth 的 resolveCursorAccessToken) */
|
|
283
|
+
resolveCursorToken?: () => Promise<{ accessToken: string } | null>;
|
|
284
|
+
/** opencode-go auth cookie(默认读 OPENCODE_GO_AUTH_COOKIE) */
|
|
285
|
+
opencodeCookie?: string;
|
|
286
|
+
/** opencode-go workspace id(默认读 OPENCODE_GO_WORKSPACE_ID) */
|
|
287
|
+
opencodeWorkspaceId?: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** 模型级探测结果。availableIds 为 null 表示无动态发现接口或探测失败。 */
|
|
291
|
+
export interface AvailableModelsResult {
|
|
292
|
+
provider: string;
|
|
293
|
+
/** 真实可用的模型 id 集合;null = 未知(不应用过滤) */
|
|
294
|
+
availableIds: string[] | null;
|
|
295
|
+
/** 探测方法 */
|
|
296
|
+
method: 'discovery';
|
|
297
|
+
host: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const CURSOR_GET_USABLE_MODELS_URL = 'https://api2.cursor.sh/agent.v1.AgentService/GetUsableModels';
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 探测 cursor 的真实可用模型(GetUsableModels)。
|
|
304
|
+
* 返回可用 modelId 列表;失败返回 null(不阻断)。
|
|
305
|
+
*/
|
|
306
|
+
async function probeCursorModels(opts: AvailableModelsProbeOptions): Promise<string[] | null> {
|
|
307
|
+
try {
|
|
308
|
+
const resolveToken = opts.resolveCursorToken ?? (async () => {
|
|
309
|
+
const { accessToken } = (await resolveCursorAccessToken()) ?? {};
|
|
310
|
+
return accessToken ? { accessToken } : null;
|
|
311
|
+
});
|
|
312
|
+
const auth = await resolveToken();
|
|
313
|
+
if (!auth?.accessToken) return null;
|
|
314
|
+
|
|
315
|
+
const fetchFn = opts.fetchImpl ?? fetch;
|
|
316
|
+
const res = await fetchFn(CURSOR_GET_USABLE_MODELS_URL, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: {
|
|
319
|
+
'content-type': 'application/json',
|
|
320
|
+
authorization: `Bearer ${auth.accessToken}`,
|
|
321
|
+
'x-cursor-client-version': '0.1.0',
|
|
322
|
+
},
|
|
323
|
+
body: JSON.stringify({}),
|
|
324
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 3000),
|
|
325
|
+
});
|
|
326
|
+
if (!res.ok) return null;
|
|
327
|
+
const data = (await res.json()) as { models?: Array<{ modelId?: string }> };
|
|
328
|
+
const ids = (data.models ?? []).map((m) => m.modelId).filter((x): x is string => !!x);
|
|
329
|
+
return ids.length > 0 ? ids : null;
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 探测 opencode-go 的真实可用模型(GET /v1/models,OpenAI 兼容)。
|
|
337
|
+
* 返回可用模型 id 列表;失败返回 null。
|
|
338
|
+
*/
|
|
339
|
+
async function probeOpencodeModels(opts: AvailableModelsProbeOptions): Promise<string[] | null> {
|
|
340
|
+
try {
|
|
341
|
+
const cookie = opts.opencodeCookie ?? process.env.OPENCODE_GO_AUTH_COOKIE?.trim() ?? '';
|
|
342
|
+
const wsId = opts.opencodeWorkspaceId ?? process.env.OPENCODE_GO_WORKSPACE_ID?.trim() ?? '';
|
|
343
|
+
if (!cookie || !wsId) return null;
|
|
344
|
+
|
|
345
|
+
const fetchFn = opts.fetchImpl ?? fetch;
|
|
346
|
+
const res = await fetchFn('https://opencode.ai/zen/go/v1/models', {
|
|
347
|
+
headers: { authorization: `Bearer ${cookie}` },
|
|
348
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 3000),
|
|
349
|
+
});
|
|
350
|
+
if (!res.ok) return null;
|
|
351
|
+
const data = (await res.json()) as { data?: Array<{ id?: string }> };
|
|
352
|
+
const ids = (data.data ?? []).map((m) => m.id).filter((x): x is string => !!x);
|
|
353
|
+
return ids.length > 0 ? ids : null;
|
|
354
|
+
} catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 支持动态发现接口的 provider → 探测函数。 */
|
|
360
|
+
const AVAILABLE_MODELS_PROBES: Record<string, (opts: AvailableModelsProbeOptions) => Promise<string[] | null>> = {
|
|
361
|
+
cursor: probeCursorModels,
|
|
362
|
+
'opencode-go': probeOpencodeModels,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 对 provider 执行模型级可用性探测。
|
|
367
|
+
* 无动态发现接口的 provider 返回 { availableIds: null }(不应用过滤)。
|
|
368
|
+
* 异常全部吞没并返回 null,绝不抛出。
|
|
369
|
+
*/
|
|
370
|
+
export async function probeAvailableModels(
|
|
371
|
+
provider: string,
|
|
372
|
+
opts: AvailableModelsProbeOptions = {},
|
|
373
|
+
): Promise<AvailableModelsResult> {
|
|
374
|
+
const probe = AVAILABLE_MODELS_PROBES[provider];
|
|
375
|
+
const host = provider === 'cursor' ? 'api2.cursor.sh' : 'opencode.ai';
|
|
376
|
+
if (!probe) {
|
|
377
|
+
return { provider, availableIds: null, method: 'discovery', host };
|
|
378
|
+
}
|
|
379
|
+
const ids = await probe(opts);
|
|
380
|
+
return { provider, availableIds: ids, method: 'discovery', host };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── 批量探测 ─────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
export interface ProbeProvidersOptions {
|
|
386
|
+
/** 单端点超时 ms,默认 3000 */
|
|
387
|
+
timeoutMs?: number;
|
|
388
|
+
/** TLS 连接工厂(测试 seam) */
|
|
389
|
+
connectionFactory?: TlsConnectFn;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 对全部 provider 的端点并发执行 TLS 探测。
|
|
394
|
+
*
|
|
395
|
+
* 返回按 provider 分组的探测结果。
|
|
396
|
+
* provider 判可达规则:其下任一端点可达即判可达(宽容策略,design.md D1)。
|
|
397
|
+
* 探测异常的端点记录 warn 并继续。
|
|
398
|
+
*/
|
|
399
|
+
export async function probeProviders(
|
|
400
|
+
providerEndpoints: ProviderEndpoints,
|
|
401
|
+
opts: ProbeProvidersOptions = {},
|
|
402
|
+
): Promise<Map<string, ProbeResult>> {
|
|
403
|
+
const timeoutMs = opts.timeoutMs ?? 3000;
|
|
404
|
+
const connectFn = opts.connectionFactory ?? tlsConnect;
|
|
405
|
+
|
|
406
|
+
// 对每个 provider 的全部端点并发探测
|
|
407
|
+
const probes: Promise<[string, ProbeResult]>[] = [];
|
|
408
|
+
|
|
409
|
+
for (const [provider, endpoints] of providerEndpoints) {
|
|
410
|
+
for (const ep of endpoints) {
|
|
411
|
+
probes.push(
|
|
412
|
+
probeTlsEndpoint(ep, timeoutMs, connectFn).then(
|
|
413
|
+
(r) => [provider, r] as [string, ProbeResult],
|
|
414
|
+
),
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const settled = await Promise.allSettled(probes);
|
|
420
|
+
|
|
421
|
+
// 按 provider 聚合:任一可达即 provider 可达
|
|
422
|
+
const providerReachability = new Map<string, boolean>();
|
|
423
|
+
const providerReasons: string[] = [];
|
|
424
|
+
|
|
425
|
+
for (const s of settled) {
|
|
426
|
+
if (s.status === 'rejected') {
|
|
427
|
+
console.warn(`[omp-opsx-addon] probe error: ${s.reason}`);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const [provider, result] = s.value;
|
|
431
|
+
if (!providerReachability.has(provider)) {
|
|
432
|
+
providerReachability.set(provider, result.reachable);
|
|
433
|
+
} else if (result.reachable) {
|
|
434
|
+
providerReachability.set(provider, true);
|
|
435
|
+
}
|
|
436
|
+
if (!result.reachable) {
|
|
437
|
+
providerReasons.push(`${provider}/${result.host}: ${result.reason ?? 'unreachable'}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (providerReasons.length > 0) {
|
|
442
|
+
console.warn(`[omp-opsx-addon] unreachable endpoints:\n ${providerReasons.join('\n ')}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 构建返回结果
|
|
446
|
+
const results = new Map<string, ProbeResult>();
|
|
447
|
+
for (const [provider, reachable] of providerReachability) {
|
|
448
|
+
const eps = providerEndpoints.get(provider) ?? [];
|
|
449
|
+
const mainHost = eps[0]?.host ?? 'unknown';
|
|
450
|
+
results.set(provider, {
|
|
451
|
+
reachable,
|
|
452
|
+
method: 'tls',
|
|
453
|
+
host: mainHost,
|
|
454
|
+
reason: reachable ? undefined : 'no reachable endpoint',
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return results;
|
|
459
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
|
|
2
|
+
import type { ResolvedOpsxConfig } from './unified-config.js';
|
|
3
|
+
import { extractEndpoints, probeAvailableModels, probeProviders } from './reachability-probe.js';
|
|
4
|
+
import {
|
|
5
|
+
getAvailableModelIds,
|
|
6
|
+
getFreshlyProbedProviders,
|
|
7
|
+
getUnreachableProviders,
|
|
8
|
+
mergeProbeResults,
|
|
9
|
+
type CacheProbeResult,
|
|
10
|
+
} from './reachability-cache.js';
|
|
11
|
+
|
|
12
|
+
const DISCOVERY_PROVIDERS = ['cursor', 'opencode-go'] as const;
|
|
13
|
+
|
|
14
|
+
export function loadAvailableByProvider(ttlMs: number): Map<string, string[] | null> {
|
|
15
|
+
const map = new Map<string, string[] | null>();
|
|
16
|
+
for (const p of DISCOVERY_PROVIDERS) {
|
|
17
|
+
map.set(p, getAvailableModelIds(p, ttlMs));
|
|
18
|
+
}
|
|
19
|
+
return map;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Refresh TLS + model-discovery caches. Cursor GetUsableModels is probed when
|
|
24
|
+
* the TTL cache has no list, so regional Claude/GPT never enter first pick.
|
|
25
|
+
*/
|
|
26
|
+
export async function refreshReachability(
|
|
27
|
+
models: Model<Api>[] | null | undefined,
|
|
28
|
+
config: Pick<ResolvedOpsxConfig, 'probe_enabled' | 'probe_timeout_ms' | 'probe_ttl_ms' | 'excluded_providers'>,
|
|
29
|
+
): Promise<{ unreachable: Set<string>; availableByProvider: Map<string, string[] | null> }> {
|
|
30
|
+
const ttl = config.probe_ttl_ms;
|
|
31
|
+
const excluded = new Set(config.excluded_providers);
|
|
32
|
+
const unreachable = getUnreachableProviders(ttl);
|
|
33
|
+
for (const p of excluded) unreachable.add(p);
|
|
34
|
+
|
|
35
|
+
if (!config.probe_enabled || !models || models.length === 0) {
|
|
36
|
+
return { unreachable, availableByProvider: loadAvailableByProvider(ttl) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const endpoints = extractEndpoints(models);
|
|
40
|
+
for (const p of excluded) endpoints.delete(p);
|
|
41
|
+
const fresh = getFreshlyProbedProviders(ttl);
|
|
42
|
+
for (const p of [...endpoints.keys()]) {
|
|
43
|
+
if (fresh.has(p)) endpoints.delete(p);
|
|
44
|
+
}
|
|
45
|
+
if (endpoints.size > 0) {
|
|
46
|
+
const tls = await probeProviders(endpoints, { timeoutMs: config.probe_timeout_ms });
|
|
47
|
+
const mapped = new Map<string, CacheProbeResult>();
|
|
48
|
+
for (const [provider, result] of tls) {
|
|
49
|
+
mapped.set(provider, {
|
|
50
|
+
reachable: result.reachable,
|
|
51
|
+
method: result.method,
|
|
52
|
+
host: result.host,
|
|
53
|
+
reason: result.reason,
|
|
54
|
+
});
|
|
55
|
+
// Cursor / OpenCode Go stay in the pool even if a 3s TLS probe
|
|
56
|
+
// of the public host fails — quota fetch and discovery are the
|
|
57
|
+
// real signals. TLS exclusion is for official Anthropic/OpenAI etc.
|
|
58
|
+
if (!result.reachable && !(DISCOVERY_PROVIDERS as readonly string[]).includes(provider)) {
|
|
59
|
+
unreachable.add(provider);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
mergeProbeResults(mapped);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const discovery = new Map<string, CacheProbeResult>();
|
|
66
|
+
await Promise.all(
|
|
67
|
+
DISCOVERY_PROVIDERS.map(async (provider) => {
|
|
68
|
+
if (excluded.has(provider)) return;
|
|
69
|
+
if (getAvailableModelIds(provider, ttl)) return;
|
|
70
|
+
const r = await probeAvailableModels(provider, { timeoutMs: config.probe_timeout_ms });
|
|
71
|
+
if (!r.availableIds) return;
|
|
72
|
+
discovery.set(provider, {
|
|
73
|
+
reachable: true,
|
|
74
|
+
method: 'discovery',
|
|
75
|
+
host: r.host,
|
|
76
|
+
availableModelIds: r.availableIds,
|
|
77
|
+
});
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
if (discovery.size > 0) mergeProbeResults(discovery);
|
|
81
|
+
|
|
82
|
+
for (const p of getUnreachableProviders(ttl)) unreachable.add(p);
|
|
83
|
+
return { unreachable, availableByProvider: loadAvailableByProvider(ttl) };
|
|
84
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
|
|
2
|
+
import { matchesSelector, matchesTokenPrefix } from './family-filter.js';
|
|
3
|
+
|
|
4
|
+
/** Case-insensitive model-id membership for GetUsableModels / /v1/models lists. */
|
|
5
|
+
export function idInAvailableList(modelId: string, available: readonly string[]): boolean {
|
|
6
|
+
const t = modelId.toLowerCase();
|
|
7
|
+
for (const raw of available) {
|
|
8
|
+
const a = raw.toLowerCase();
|
|
9
|
+
if (a === t) return true;
|
|
10
|
+
if (a.endsWith(`/${t}`) || t.endsWith(`/${a}`)) return true;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function filterSelectableModels<T extends { provider: string; id: string }>(
|
|
16
|
+
models: T[],
|
|
17
|
+
opts: {
|
|
18
|
+
blockedSelectors?: Set<string>;
|
|
19
|
+
unreachableProviders?: Set<string>;
|
|
20
|
+
excludedProviders?: readonly string[];
|
|
21
|
+
/** provider → discovered ids; missing/null means unknown (do not filter that provider). */
|
|
22
|
+
availableByProvider?: Map<string, string[] | null>;
|
|
23
|
+
/** Keep only models matching any of these selectors (three-level grammar OR). */
|
|
24
|
+
selectors?: readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* Drop models matching any of these selectors. ALWAYS evaluated with the
|
|
27
|
+
* bare-word (token-prefix) semantics regardless of the selector shape: a
|
|
28
|
+
* glob input is tokenized like a bare word (`*` and other non-alphanumerics
|
|
29
|
+
* split it, `*` is dropped) and never dispatched through the glob grammar.
|
|
30
|
+
* The only caller is the `--china` CHINA_EXCLUDE_FAMILIES gateway.
|
|
31
|
+
*/
|
|
32
|
+
excludeSelectors?: readonly string[];
|
|
33
|
+
},
|
|
34
|
+
): T[] {
|
|
35
|
+
const blocked = opts.blockedSelectors ?? new Set();
|
|
36
|
+
const unreachable = opts.unreachableProviders ?? new Set();
|
|
37
|
+
const excluded = new Set(opts.excludedProviders ?? []);
|
|
38
|
+
const available = opts.availableByProvider;
|
|
39
|
+
const selectors = opts.selectors;
|
|
40
|
+
const excludeSelectors = opts.excludeSelectors;
|
|
41
|
+
|
|
42
|
+
const discoveryWipes = new Set<string>();
|
|
43
|
+
if (available) {
|
|
44
|
+
const counts = new Map<string, { total: number; kept: number }>();
|
|
45
|
+
for (const m of models) {
|
|
46
|
+
if (excluded.has(m.provider) || unreachable.has(m.provider)) continue;
|
|
47
|
+
const list = available.get(m.provider);
|
|
48
|
+
if (!list || list.length === 0) continue;
|
|
49
|
+
const st = counts.get(m.provider) ?? { total: 0, kept: 0 };
|
|
50
|
+
st.total++;
|
|
51
|
+
if (idInAvailableList(m.id, list)) st.kept++;
|
|
52
|
+
counts.set(m.provider, st);
|
|
53
|
+
}
|
|
54
|
+
for (const [p, st] of counts) {
|
|
55
|
+
if (st.total > 0 && st.kept === 0) discoveryWipes.add(p);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return models.filter((m) => {
|
|
60
|
+
if (excluded.has(m.provider) || unreachable.has(m.provider)) return false;
|
|
61
|
+
if (blocked.has(`${m.provider}/${m.id}`)) return false;
|
|
62
|
+
const list = available?.get(m.provider);
|
|
63
|
+
if (list && list.length > 0 && !discoveryWipes.has(m.provider) && !idInAvailableList(m.id, list)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
// Selector constraint, orthogonal to the provider-level filters above.
|
|
67
|
+
if (selectors && selectors.length > 0 && !selectors.some((f) => matchesSelector(m.provider, m.id, f))) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (excludeSelectors && excludeSelectors.some((f) => matchesTokenPrefix(m.provider, m.id, f))) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function collectAvailableByProvider(
|
|
78
|
+
providers: readonly string[],
|
|
79
|
+
getIds: (provider: string) => string[] | null,
|
|
80
|
+
): Map<string, string[] | null> {
|
|
81
|
+
const map = new Map<string, string[] | null>();
|
|
82
|
+
for (const p of providers) {
|
|
83
|
+
map.set(p, getIds(p));
|
|
84
|
+
}
|
|
85
|
+
return map;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function reachabilityFingerprint(opts: {
|
|
89
|
+
unreachable: Iterable<string>;
|
|
90
|
+
availableByProvider: Map<string, string[] | null>;
|
|
91
|
+
blocked: Iterable<string>;
|
|
92
|
+
}): string {
|
|
93
|
+
const u = [...opts.unreachable].sort().join(',');
|
|
94
|
+
const b = [...opts.blocked].sort().join(',');
|
|
95
|
+
const a = [...opts.availableByProvider.entries()]
|
|
96
|
+
.sort(([x], [y]) => x.localeCompare(y))
|
|
97
|
+
.map(([p, ids]) => `${p}=${ids ? ids.slice().sort().join('+') : '?'}`)
|
|
98
|
+
.join('|');
|
|
99
|
+
return `u:${u};b:${b};a:${a}`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
|
|
2
|
+
import { candidateQuota } from './model-selector.js';
|
|
3
|
+
import type { ProviderHealth } from './usage-resolver.js';
|
|
4
|
+
|
|
5
|
+
export function shouldReselectPrimary(opts: {
|
|
6
|
+
explicitModel: boolean;
|
|
7
|
+
current: { provider: string; id: string; name?: string } | null;
|
|
8
|
+
health: Map<string, ProviderHealth> | null | undefined;
|
|
9
|
+
nextPick: Model<Api> | null;
|
|
10
|
+
}): boolean {
|
|
11
|
+
if (opts.explicitModel) return false;
|
|
12
|
+
if (!opts.current) return false;
|
|
13
|
+
const quota = candidateQuota(opts.current, opts.health?.get(opts.current.provider));
|
|
14
|
+
if (!quota.exhausted) return false;
|
|
15
|
+
if (!opts.nextPick) return false;
|
|
16
|
+
return opts.nextPick.provider !== opts.current.provider || opts.nextPick.id !== opts.current.id;
|
|
17
|
+
}
|