@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.
@@ -0,0 +1,288 @@
1
+ /**
2
+ * 可达性探测缓存模块。
3
+ *
4
+ * 缓存文件:`~/.omp/cache/omp-opsx-addon-reachability.json`
5
+ * 复用 usage-poller 的持久化模式(CacheFile 契约、test seam)。
6
+ *
7
+ * 职责:
8
+ * 1. 探测结果的读写与 TTL 判定
9
+ * 2. blockedModels(403 记忆)的加载与落盘
10
+ * 3. 文件缺失/损坏 → 优雅降级,不抛异常
11
+ *
12
+ * 设计要点(遵循 design.md D4-D5):
13
+ * - D4: 单文件承载探测结果与 403 记忆
14
+ * - D5: per-provider 10 分钟 TTL,按 checkedAt 独立判定
15
+ */
16
+
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
+ import { join, dirname } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+
21
+ // ── 缓存契约 ─────────────────────────────────────────────────────────────
22
+
23
+ /** 缓存文件的 JSON 结构。 */
24
+ export interface ReachabilityCacheFile {
25
+ /** 缓存格式版本 */
26
+ version: 1;
27
+ /** 全局时间戳(本次写入时刻) */
28
+ fetchedAt: number;
29
+ /** per-provider 探测记录 */
30
+ providers: Record<string, ReachabilityProviderEntry>;
31
+ /** 403 屏蔽模型列表(`provider/modelId` 格式) */
32
+ blockedModels: string[];
33
+ /** blockedModels 最后更新时间戳 */
34
+ blockedUpdatedAt: number;
35
+ }
36
+
37
+ /** 单个 provider 的探测记录。 */
38
+ export interface ReachabilityProviderEntry {
39
+ reachable: boolean;
40
+ /** 探测时间戳(epoch ms),驱动 per-provider TTL 判定 */
41
+ checkedAt: number;
42
+ /** 探测方法:'tls'、'http' 或 'discovery' */
43
+ method: 'tls' | 'http' | 'discovery';
44
+ /** 主探测主机名 */
45
+ host: string;
46
+ /** 失败原因(仅不可达时记录) */
47
+ reason?: string;
48
+ /** 模型级探测得到的真实可用模型 id 列表(undefined = 未探测/无接口) */
49
+ availableModelIds?: string[];
50
+ }
51
+
52
+ /** 探测结果的简化视图(来自 reachability-probe.ts 的 ProbeResult) */
53
+ export interface CacheProbeResult {
54
+ reachable: boolean;
55
+ method: 'tls' | 'http' | 'discovery';
56
+ host: string;
57
+ reason?: string;
58
+ /** 模型级探测的真实可用模型 id 列表 */
59
+ availableModelIds?: string[];
60
+ }
61
+
62
+ // ── 路径 ─────────────────────────────────────────────────────────────────
63
+
64
+ const CACHE_DIR = join(homedir(), '.omp', 'cache');
65
+ const CACHE_FILE = join(CACHE_DIR, 'omp-opsx-addon-reachability.json');
66
+
67
+ /** 版本号:仅匹配 version 1 */
68
+ const CACHE_VERSION = 1;
69
+
70
+ // ── test seam ─────────────────────────────────────────────────────────────
71
+
72
+ let cacheFileOverride: string | null = null;
73
+
74
+ /** 解析缓存文件路径(生产路径或测试临时路径)。 */
75
+ const resolveCacheFile = (): string => cacheFileOverride ?? CACHE_FILE;
76
+
77
+ // ── 内部工具 ──────────────────────────────────────────────────────────────
78
+
79
+ function ensureDir(dir: string): void {
80
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
81
+ }
82
+
83
+ // ── 缓存读写 ──────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * 同步读取缓存文件。
87
+ * 缺失/损坏/版本不匹配返回 null,不抛异常。
88
+ */
89
+ export function loadReachabilityCache(): ReachabilityCacheFile | null {
90
+ try {
91
+ const path = resolveCacheFile();
92
+ if (!existsSync(path)) return null;
93
+
94
+ const raw = readFileSync(path, 'utf-8');
95
+ const parsed = JSON.parse(raw);
96
+
97
+ // 版本检查
98
+ if (parsed == null || typeof parsed !== 'object' || parsed.version !== CACHE_VERSION) {
99
+ return null;
100
+ }
101
+
102
+ // 校验基本结构的合法性
103
+ if (
104
+ typeof parsed.fetchedAt !== 'number' ||
105
+ parsed.providers == null ||
106
+ typeof parsed.providers !== 'object'
107
+ ) {
108
+ return null;
109
+ }
110
+
111
+ // blockedModels 兼容旧缓存无此字段
112
+ if (!Array.isArray(parsed.blockedModels)) {
113
+ parsed.blockedModels = [];
114
+ }
115
+ if (typeof parsed.blockedUpdatedAt !== 'number') {
116
+ parsed.blockedUpdatedAt = 0;
117
+ }
118
+
119
+ return parsed as ReachabilityCacheFile;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * 将缓存对象写盘。
127
+ * 确保目录存在,写失败吞没异常并 warn。
128
+ */
129
+ export function writeReachabilityCache(cache: ReachabilityCacheFile): void {
130
+ try {
131
+ const path = resolveCacheFile();
132
+ ensureDir(dirname(path));
133
+ writeFileSync(path, JSON.stringify(cache, null, 2), 'utf-8');
134
+ } catch (e) {
135
+ console.warn(`[omp-opsx-addon] failed to write reachability cache: ${e}`);
136
+ }
137
+ }
138
+
139
+ // ── 不可达 provider 查询(TTL)───────────────────────────────────────────
140
+
141
+ /**
142
+ * 返回缓存中 TTL 内且不可达的 provider 集合。
143
+ * 过期记录忽略(过期后重新探测可能变为可达)。
144
+ */
145
+ export function getUnreachableProviders(ttlMs: number): Set<string> {
146
+ const cache = loadReachabilityCache();
147
+ if (!cache) return new Set();
148
+
149
+ const now = Date.now();
150
+ const result = new Set<string>();
151
+
152
+ for (const [provider, entry] of Object.entries(cache.providers)) {
153
+ if (entry.reachable) continue;
154
+ if (now - entry.checkedAt > ttlMs) continue; // 过期忽略
155
+ result.add(provider);
156
+ }
157
+
158
+ return result;
159
+ }
160
+
161
+ /**
162
+ * 返回缓存中 TTL 内 provider 的真实可用模型 id 列表。
163
+ * 无记录/过期/未探测时返回 null(表示未知,不应用过滤)。
164
+ */
165
+ export function getAvailableModelIds(provider: string, ttlMs: number): string[] | null {
166
+ const cache = loadReachabilityCache();
167
+ if (!cache) return null;
168
+ const entry = cache.providers[provider];
169
+ if (!entry || !Array.isArray(entry.availableModelIds)) return null;
170
+ if (Date.now() - entry.checkedAt > ttlMs) return null; // 过期忽略
171
+ return entry.availableModelIds;
172
+ }
173
+
174
+ // ── 新鲜 provider 查询(TTL,不分正负)───────────────────────────────────
175
+
176
+ /**
177
+ * 返回缓存中 TTL 内已探测的 provider 集合(无论可达与否)。
178
+ * 用于 refresh 时跳过仍在 TTL 内的 provider,避免重复探测。
179
+ * 对应 design.md D5:可达与不可达结果共用同一 TTL。
180
+ */
181
+ export function getFreshlyProbedProviders(ttlMs: number): Set<string> {
182
+ const cache = loadReachabilityCache();
183
+ if (!cache) return new Set();
184
+
185
+ const now = Date.now();
186
+ const result = new Set<string>();
187
+
188
+ for (const [provider, entry] of Object.entries(cache.providers)) {
189
+ if (now - entry.checkedAt <= ttlMs) {
190
+ result.add(provider);
191
+ }
192
+ }
193
+
194
+ return result;
195
+ }
196
+
197
+ // ── 合并探测结果写入缓存 ─────────────────────────────────────────────────
198
+
199
+ /**
200
+ * 将新探测结果按 provider 合并进缓存并整体覆盖写盘。
201
+ * 仅更新被探测的 provider,其他 provider 的旧记录保留。
202
+ */
203
+ export function mergeProbeResults(providerResults: Map<string, CacheProbeResult>): void {
204
+ const existing = loadReachabilityCache();
205
+ const now = Date.now();
206
+
207
+ const providers: Record<string, ReachabilityProviderEntry> = existing?.providers
208
+ ? { ...existing.providers }
209
+ : {};
210
+
211
+ for (const [provider, result] of providerResults) {
212
+ providers[provider] = {
213
+ reachable: result.reachable,
214
+ checkedAt: now,
215
+ method: result.method,
216
+ host: result.host,
217
+ reason: result.reason,
218
+ // discovery 结果携带可用模型列表;其他方法不覆盖已有列表
219
+ availableModelIds: result.method === 'discovery'
220
+ ? (result.availableModelIds ?? [])
221
+ : (providers[provider]?.availableModelIds ?? result.availableModelIds),
222
+ };
223
+ }
224
+
225
+ const cache: ReachabilityCacheFile = {
226
+ version: CACHE_VERSION,
227
+ fetchedAt: now,
228
+ providers,
229
+ blockedModels: existing?.blockedModels ?? [],
230
+ blockedUpdatedAt: existing?.blockedUpdatedAt ?? 0,
231
+ };
232
+
233
+ writeReachabilityCache(cache);
234
+ }
235
+
236
+ // ── blockedModels(403 记忆)持久化 ──────────────────────────────────────
237
+
238
+ /**
239
+ * 从缓存文件同步加载 blockedModels 列表。
240
+ * 缺失/损坏返回空数组。
241
+ */
242
+ export function loadBlockedModels(): string[] {
243
+ const cache = loadReachabilityCache();
244
+ if (!cache || !Array.isArray(cache.blockedModels)) return [];
245
+ return cache.blockedModels;
246
+ }
247
+
248
+ /**
249
+ * 将 blockedModels set 落盘(追加到已有缓存文件的 blockedModels 字段)。
250
+ * 写失败吞没异常,不抛出。
251
+ */
252
+ export function persistBlockedModels(blockedSet: Set<string>): void {
253
+ const existing = loadReachabilityCache();
254
+ const now = Date.now();
255
+
256
+ const providers: Record<string, ReachabilityProviderEntry> = existing?.providers
257
+ ? { ...existing.providers }
258
+ : {};
259
+
260
+ const cache: ReachabilityCacheFile = {
261
+ version: CACHE_VERSION,
262
+ fetchedAt: existing?.fetchedAt ?? now,
263
+ providers,
264
+ blockedModels: [...blockedSet],
265
+ blockedUpdatedAt: now,
266
+ };
267
+
268
+ writeReachabilityCache(cache);
269
+ }
270
+
271
+ // ── test seams ────────────────────────────────────────────────────────────
272
+ // Redirect disk I/O to a temp path and reset module state between tests.
273
+ // Production code never calls these (the underscore prefix signals test-only intent).
274
+
275
+ /**
276
+ * 设置测试用缓存文件路径。
277
+ * 传入 `null` 恢复默认路径。
278
+ */
279
+ export function _setCacheFileForTest(path: string | null): void {
280
+ cacheFileOverride = path;
281
+ }
282
+
283
+ /**
284
+ * 重置模块测试状态。
285
+ */
286
+ export function _resetForTest(): void {
287
+ cacheFileOverride = null;
288
+ }