@fanchaozz/provider-manager 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/test.ts ADDED
@@ -0,0 +1,354 @@
1
+ /**
2
+ * test.ts — model 可用性测试
3
+ *
4
+ * 3 档检查,每档独立返回 ok:
5
+ * - auth modelRegistry.getProviderAuthStatus(provider) → configured? source?
6
+ * - reachable GET `${baseUrl}/models`(或 Google 端点)期望 2xx
7
+ * - generated modelRegistry.complete(...) 一次最小生成(maxTokens 硬上限 16)
8
+ *
9
+ * mode:
10
+ * - "quick" → auth + reachable
11
+ * - "full" → generated(auth+reachable 也跑)
12
+ * - "both" → 等价 "full"
13
+ *
14
+ * 安全:
15
+ * - 必传 signal,超时 10s(可改)
16
+ * - maxTokens 硬上限 16(即便 caller 传 1000 也截断)
17
+ * - 不并发跨 provider
18
+ * - 失败不重试
19
+ *
20
+ * 内存缓存:Map<"${provider}/${modelId}", TestResult>(会话级,不持久化)
21
+ */
22
+
23
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+ import { readModelsJson, type ModelsJson } from "./store.ts";
25
+
26
+ // ============================================================================
27
+ // Types
28
+ // ============================================================================
29
+
30
+ export type TestMode = "quick" | "full" | "both";
31
+
32
+ export type CheckResult = {
33
+ ok: boolean;
34
+ [key: string]: unknown;
35
+ };
36
+
37
+ export type TestResult = {
38
+ provider: string;
39
+ model: string;
40
+ mode: TestMode;
41
+ ok: boolean;
42
+ latencyMs: number;
43
+ checks: {
44
+ auth: { ok: boolean; source?: string; label?: string; error?: string };
45
+ reachable: { ok: boolean; status?: number; url?: string; error?: string };
46
+ generated?: {
47
+ ok: boolean;
48
+ stopReason?: string;
49
+ content?: string; // 截断到 50 字符
50
+ usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number };
51
+ error?: string;
52
+ };
53
+ };
54
+ testedAt: number;
55
+ };
56
+
57
+ // ============================================================================
58
+ // Cache (session-scoped, not persisted)
59
+ // ============================================================================
60
+
61
+ const cache = new Map<string, TestResult>();
62
+ const cacheKey = (provider: string, model: string) => `${provider}/${model}`;
63
+
64
+ export function getCached(provider: string, model: string): TestResult | undefined {
65
+ return cache.get(cacheKey(provider, model));
66
+ }
67
+
68
+ export function clearCache(): void {
69
+ cache.clear();
70
+ }
71
+
72
+ function storeCached(result: TestResult): void {
73
+ cache.set(cacheKey(result.provider, result.model), result);
74
+ }
75
+
76
+ // ============================================================================
77
+ // Auth check
78
+ // ============================================================================
79
+
80
+ /** 把新旧两种 AuthStatus 形态({ok, source} 和 {configured, source})都接受 */
81
+ function isAuthOk(status: any): boolean {
82
+ if (!status) return false;
83
+ if (typeof status.ok === "boolean") return status.ok;
84
+ if (typeof status.configured === "boolean") return status.configured;
85
+ return false;
86
+ }
87
+
88
+ function authSource(status: any): string | undefined {
89
+ return status?.source ?? status?.label;
90
+ }
91
+
92
+ function authLabel(status: any): string | undefined {
93
+ return status?.label;
94
+ }
95
+
96
+ function checkAuth(ctx: ExtensionContext, provider: string): TestResult["checks"]["auth"] {
97
+ let status: any;
98
+ try {
99
+ status = (ctx.modelRegistry as any).getProviderAuthStatus(provider);
100
+ } catch (err) {
101
+ return { ok: false, error: `auth check threw: ${err instanceof Error ? err.message : String(err)}` };
102
+ }
103
+ if (isAuthOk(status)) {
104
+ return { ok: true, source: authSource(status), label: authLabel(status) };
105
+ }
106
+ const src = authSource(status) ?? "unknown";
107
+ return { ok: false, source: src, label: authLabel(status), error: `No key configured (source: ${src})` };
108
+ }
109
+
110
+ // ============================================================================
111
+ // Reachable check
112
+ // ============================================================================
113
+
114
+ /** 从 models.json 读 baseUrl/api(仅自定义 provider) */
115
+ function readProviderConfig(json: ModelsJson, provider: string): { baseUrl?: string; apiKey?: string; api?: string } {
116
+ const p = json.providers[provider];
117
+ if (!p) return {};
118
+ const out: { baseUrl?: string; apiKey?: string; api?: string } = {};
119
+ if (typeof p.baseUrl === "string") out.baseUrl = p.baseUrl;
120
+ if (typeof p.apiKey === "string") out.apiKey = p.apiKey;
121
+ if (typeof p.api === "string") out.api = p.api;
122
+ return out;
123
+ }
124
+
125
+ function probeUrl(baseUrl: string, api: string | undefined, apiKey: string | undefined): { url: string; headers: Record<string, string> } {
126
+ const base = baseUrl.replace(/\/+$/, "");
127
+ if (api === "google-generative-ai") {
128
+ const key = apiKey ? `?key=${encodeURIComponent(apiKey)}` : "";
129
+ return { url: `${base}/models${key}`, headers: {} };
130
+ }
131
+ // OpenAI-compat / anthropic-messages 等统一走 GET /models
132
+ const headers: Record<string, string> = {};
133
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
134
+ return { url: `${base}/models`, headers };
135
+ }
136
+
137
+ async function checkReachable(json: ModelsJson, provider: string, timeoutMs: number, signal?: AbortSignal): Promise<TestResult["checks"]["reachable"]> {
138
+ const cfg = readProviderConfig(json, provider);
139
+ if (!cfg.baseUrl) {
140
+ return { ok: false, error: `provider "${provider}" has no baseUrl in models.json; reachable check skipped` };
141
+ }
142
+ const { url, headers } = probeUrl(cfg.baseUrl, cfg.api, cfg.apiKey);
143
+
144
+ const ctrl = new AbortController();
145
+ const timer = setTimeout(() => ctrl.abort(new Error("reachable timeout")), timeoutMs);
146
+ const onAbort = () => ctrl.abort(signal!.reason);
147
+ if (signal) signal.addEventListener("abort", onAbort);
148
+
149
+ try {
150
+ const res = await fetch(url, { method: "GET", headers, signal: ctrl.signal });
151
+ const ok = res.status >= 200 && res.status < 300;
152
+ // 不读 body,只要状态码
153
+ try { await res.body?.cancel(); } catch { /* ignore */ }
154
+ return ok
155
+ ? { ok: true, status: res.status, url }
156
+ : { ok: false, status: res.status, url, error: `HTTP ${res.status} ${res.statusText}` };
157
+ } catch (err) {
158
+ return { ok: false, url, error: err instanceof Error ? err.message : String(err) };
159
+ } finally {
160
+ clearTimeout(timer);
161
+ if (signal) signal.removeEventListener("abort", onAbort);
162
+ }
163
+ }
164
+
165
+ // ============================================================================
166
+ // Generated check
167
+ // ============================================================================
168
+
169
+ const DEFAULT_PROMPT = "Reply with the single word: ok";
170
+ const DEFAULT_MAX_TOKENS = 4;
171
+ const HARD_MAX_TOKENS_CAP = 16;
172
+ const MAX_CONTENT_LEN = 50;
173
+
174
+ async function checkGenerated(opts: {
175
+ ctx: ExtensionContext;
176
+ modelId: string;
177
+ provider: string;
178
+ prompt?: string;
179
+ maxTokens?: number;
180
+ timeoutMs: number;
181
+ signal?: AbortSignal;
182
+ }): Promise<TestResult["checks"]["generated"]> {
183
+ const prompt = opts.prompt ?? DEFAULT_PROMPT;
184
+ const requested = opts.maxTokens ?? DEFAULT_MAX_TOKENS;
185
+ const maxTokens = Math.min(Math.max(1, requested), HARD_MAX_TOKENS_CAP);
186
+
187
+ // 找到 model 对象(modelRegistry 有 find(provider, id))
188
+ const model = (opts.ctx.modelRegistry as any).find?.(opts.provider, opts.modelId);
189
+ if (!model) {
190
+ return { ok: false, error: `model ${opts.provider}/${opts.modelId} not found in modelRegistry` };
191
+ }
192
+
193
+ const ctrl = new AbortController();
194
+ const timer = setTimeout(() => ctrl.abort(new Error("generated timeout")), opts.timeoutMs);
195
+ const onAbort = () => ctrl.abort(opts.signal!.reason);
196
+ if (opts.signal) opts.signal.addEventListener("abort", onAbort);
197
+
198
+ const context = {
199
+ systemPrompt: "You are a test probe. Reply concisely.",
200
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
201
+ };
202
+
203
+ try {
204
+ const msg = await (opts.ctx.modelRegistry as any).complete(model, context, {
205
+ signal: ctrl.signal,
206
+ maxTokens,
207
+ });
208
+ const ok = msg?.stopReason === "stop" || msg?.stopReason === "length";
209
+ const text = (msg?.content ?? [])
210
+ .filter((c: any) => c?.type === "text")
211
+ .map((c: any) => c.text)
212
+ .join("");
213
+ const content = text.length > MAX_CONTENT_LEN ? text.slice(0, MAX_CONTENT_LEN) + "..." : text;
214
+ const usage = msg?.usage ? {
215
+ input: msg.usage.input ?? 0,
216
+ output: msg.usage.output ?? 0,
217
+ cacheRead: msg.usage.cacheRead ?? 0,
218
+ cacheWrite: msg.usage.cacheWrite ?? 0,
219
+ cost: msg.usage.cost?.total ?? 0,
220
+ } : undefined;
221
+ return ok
222
+ ? { ok: true, stopReason: msg.stopReason, content, usage }
223
+ : { ok: false, stopReason: msg?.stopReason, content, error: msg?.errorMessage ?? `stopReason=${msg?.stopReason}` };
224
+ } catch (err) {
225
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
226
+ } finally {
227
+ clearTimeout(timer);
228
+ if (opts.signal) opts.signal.removeEventListener("abort", onAbort);
229
+ }
230
+ }
231
+
232
+ // ============================================================================
233
+ // Public API
234
+ // ============================================================================
235
+
236
+ export type TestModelOpts = {
237
+ ctx: ExtensionContext;
238
+ provider: string;
239
+ model: string;
240
+ mode?: TestMode; // default "quick"
241
+ prompt?: string;
242
+ maxTokens?: number;
243
+ timeoutMs?: number; // default 10000
244
+ signal?: AbortSignal;
245
+ };
246
+
247
+ /** 测单个 model;结果写入 cache */
248
+ export async function testModel(opts: TestModelOpts): Promise<TestResult> {
249
+ const mode = opts.mode ?? "quick";
250
+ const timeoutMs = opts.timeoutMs ?? parseInt(process.env.PI_PROVIDER_TEST_TIMEOUT ?? "10000", 10);
251
+
252
+ const start = Date.now();
253
+ const json = await readModelsJson();
254
+
255
+ const checks: TestResult["checks"] = {
256
+ auth: checkAuth(opts.ctx, opts.provider),
257
+ reachable: { ok: false, error: "skipped" },
258
+ };
259
+
260
+ if (checks.auth.ok) {
261
+ checks.reachable = await checkReachable(json, opts.provider, timeoutMs, opts.signal);
262
+ } else {
263
+ checks.reachable = { ok: false, error: "skipped (auth failed)" };
264
+ }
265
+
266
+ if (mode === "full" || mode === "both") {
267
+ if (checks.auth.ok && checks.reachable.ok) {
268
+ checks.generated = await checkGenerated({
269
+ ctx: opts.ctx,
270
+ provider: opts.provider,
271
+ modelId: opts.model,
272
+ prompt: opts.prompt,
273
+ maxTokens: opts.maxTokens,
274
+ timeoutMs,
275
+ signal: opts.signal,
276
+ });
277
+ } else {
278
+ checks.generated = { ok: false, error: "skipped (auth or reachable failed)" };
279
+ }
280
+ }
281
+
282
+ const ok = checks.auth.ok && checks.reachable.ok && (checks.generated?.ok ?? true);
283
+ const result: TestResult = {
284
+ provider: opts.provider,
285
+ model: opts.model,
286
+ mode,
287
+ ok,
288
+ latencyMs: Date.now() - start,
289
+ checks,
290
+ testedAt: Date.now(),
291
+ };
292
+ storeCached(result);
293
+ return result;
294
+ }
295
+
296
+ export type TestProviderOpts = {
297
+ ctx: ExtensionContext;
298
+ provider: string;
299
+ modelIds: string[];
300
+ mode?: TestMode;
301
+ concurrency?: number; // default 3
302
+ timeoutMs?: number;
303
+ signal?: AbortSignal;
304
+ onProgress?: (done: number, total: number, result: TestResult) => void;
305
+ };
306
+
307
+ /** 批量测 provider 下多个 model;并发度默认 3 */
308
+ export async function testProvider(opts: TestProviderOpts): Promise<TestResult[]> {
309
+ const concurrency = Math.max(1, opts.concurrency ?? 3);
310
+ const total = opts.modelIds.length;
311
+ const out: TestResult[] = [];
312
+ let cursor = 0;
313
+ let done = 0;
314
+
315
+ async function worker(): Promise<void> {
316
+ while (cursor < total) {
317
+ const i = cursor++;
318
+ const modelId = opts.modelIds[i];
319
+ const result = await testModel({
320
+ ctx: opts.ctx,
321
+ provider: opts.provider,
322
+ model: modelId,
323
+ mode: opts.mode,
324
+ timeoutMs: opts.timeoutMs,
325
+ signal: opts.signal,
326
+ });
327
+ out[i] = result;
328
+ done++;
329
+ opts.onProgress?.(done, total, result);
330
+ }
331
+ }
332
+
333
+ const workers = Array.from({ length: Math.min(concurrency, total) }, () => worker());
334
+ await Promise.all(workers);
335
+ return out;
336
+ }
337
+
338
+ // ============================================================================
339
+ // Display helpers
340
+ // ============================================================================
341
+
342
+ /** 把 TestResult 格式化成多行可读文本(detail panel / notify) */
343
+ export function formatTestResult(r: TestResult): string {
344
+ const lines: string[] = [];
345
+ lines.push(`${r.provider}/${r.model} ${r.ok ? "✓ ok" : "✗ fail"} (${r.latencyMs}ms, mode=${r.mode})`);
346
+ lines.push(` auth ${r.checks.auth.ok ? "✓" : "✗"} ${r.checks.auth.source ?? ""} ${r.checks.auth.error ? "— " + r.checks.auth.error : ""}`);
347
+ lines.push(` reachable ${r.checks.reachable.ok ? "✓" : "✗"} ${r.checks.reachable.status ?? ""} ${r.checks.reachable.error ? "— " + r.checks.reachable.error : ""}`);
348
+ if (r.checks.generated) {
349
+ const g = r.checks.generated;
350
+ lines.push(` generated ${g.ok ? "✓" : "✗"} ${g.stopReason ?? ""} ${g.content ? `— "${g.content}"` : ""} ${g.error ? "— " + g.error : ""}`);
351
+ if (g.usage) lines.push(` usage: in=${g.usage.input} out=${g.usage.output} cost=$${g.usage.cost.toFixed(6)}`);
352
+ }
353
+ return lines.join("\n");
354
+ }