@fanchaozz/provider-manager 0.2.0 → 0.2.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.
Files changed (5) hide show
  1. package/commands.ts +6 -4
  2. package/forms.ts +19 -2
  3. package/package.json +4 -2
  4. package/test.ts +354 -354
  5. package/ui.ts +17 -3
package/commands.ts CHANGED
@@ -259,7 +259,8 @@ async function testCommand(ctx: ExtensionCommandContext, arg: string): Promise<v
259
259
  }
260
260
 
261
261
  const result = await testModel({ ctx: ctx as any, provider, model, mode: "full" });
262
- ctx.ui.notify(formatTestResult(result), result.ok ? "info" : "warning");
262
+ // 同步 dashboard:测试结果统一 info(showStatus 可覆盖),失败语义靠文本 ✗ fail 前缀表达
263
+ ctx.ui.notify(formatTestResult(result), "info");
263
264
  }
264
265
 
265
266
  /** 批量测某 provider 全部 model;无参数时取第一个 provider。 */
@@ -295,7 +296,8 @@ async function testAllCommand(ctx: ExtensionCommandContext, providerId: string |
295
296
  mode: "full",
296
297
  concurrency: 3,
297
298
  });
298
- for (const r of results) {
299
- ctx.ui.notify(formatTestResult(r), r.ok ? "info" : "warning");
300
- }
299
+ // 批量结果拼成一条 notify:逐条 notify 会被 showStatus 原地覆盖,只残留最后一条
300
+ const okCount = results.filter((r) => r.ok).length;
301
+ const summary = results.map((r) => formatTestResult(r)).join("\n\n") + `\n${provider}: ${okCount}/${results.length} ok`;
302
+ ctx.ui.notify(summary, "info");
301
303
  }
package/forms.ts CHANGED
@@ -36,6 +36,7 @@ export const DEFAULT_MODEL_CONFIG: {
36
36
  contextWindow: number;
37
37
  maxTokens: number;
38
38
  thinkingLevelMap: ModelConfig["thinkingLevelMap"];
39
+ compat: { supportsDeveloperRole: boolean };
39
40
  } = {
40
41
  reasoning: true,
41
42
  input: ["text", "image"],
@@ -50,6 +51,8 @@ export const DEFAULT_MODEL_CONFIG: {
50
51
  xhigh: null,
51
52
  max: null,
52
53
  },
54
+ // Zhipu GLM 等 OpenAI-compat 网关拒收 role:"developer"(会返 422)。默认 false → pi 用 system role。
55
+ compat: { supportsDeveloperRole: false },
53
56
  };
54
57
 
55
58
  // ============================================================================
@@ -88,13 +91,19 @@ export function ensureDefaultConfigFile(): string | null {
88
91
  }
89
92
 
90
93
  function isValidDefaultModelConfig(v: any): v is typeof DEFAULT_MODEL_CONFIG {
94
+ const compatOk = !v.compat
95
+ || (typeof v.compat === "object" && !Array.isArray(v.compat) && (
96
+ v.compat.supportsDeveloperRole === undefined
97
+ || typeof v.compat.supportsDeveloperRole === "boolean"
98
+ ));
91
99
  return (
92
100
  v && typeof v === "object" &&
93
101
  typeof v.reasoning === "boolean" &&
94
102
  Array.isArray(v.input) && v.input.every((x: any) => x === "text" || x === "image") && v.input.length > 0 &&
95
103
  typeof v.contextWindow === "number" && v.contextWindow > 0 && Number.isFinite(v.contextWindow) &&
96
104
  typeof v.maxTokens === "number" && v.maxTokens > 0 && Number.isFinite(v.maxTokens) &&
97
- v.thinkingLevelMap && typeof v.thinkingLevelMap === "object" && !Array.isArray(v.thinkingLevelMap)
105
+ v.thinkingLevelMap && typeof v.thinkingLevelMap === "object" && !Array.isArray(v.thinkingLevelMap) &&
106
+ compatOk
98
107
  );
99
108
  }
100
109
 
@@ -109,7 +118,11 @@ export function loadDefaultModelConfig(): typeof DEFAULT_MODEL_CONFIG {
109
118
  const raw = readFileSync(p, "utf8");
110
119
  const parsed = JSON.parse(raw);
111
120
  const cfg = parsed?.defaultModel;
112
- if (isValidDefaultModelConfig(cfg)) return cfg;
121
+ if (isValidDefaultModelConfig(cfg)) {
122
+ // 补全缺失的 compat(老 config 没有这个字段时默认为 false)
123
+ if (!cfg.compat) cfg.compat = { supportsDeveloperRole: false };
124
+ return cfg;
125
+ }
113
126
  } catch {
114
127
  // 回退到代码默认
115
128
  }
@@ -363,6 +376,8 @@ export async function editModelFlow(
363
376
  { key: "contextWindow", label: "contextWindow", type: "number", validate: (v) => typeof v === "number" && v >= 0 ? null : "must be non-negative" },
364
377
  { key: "maxTokens", label: "maxTokens", type: "number", validate: (v) => typeof v === "number" && v >= 0 ? null : "must be non-negative" },
365
378
  { key: "thinkingLevelMap", label: "thinkingLevelMap", type: "levelmap", hint: "(empty = remove)" },
379
+ // Zhipu GLM 等 OpenAI-compat 网关不接受 role:"developer" (会返 422)。默认 no 用 system role。
380
+ { key: "supportsDeveloperRole", label: "supportsDeveloperRole (compat)", type: "select", options: ["no", "yes"], hint: "Zhipu GLM 等需 no (用 system role)" },
366
381
  ];
367
382
  const initial: Record<string, unknown> = {
368
383
  name: cur.name ?? "",
@@ -371,6 +386,7 @@ export async function editModelFlow(
371
386
  contextWindow: cur.contextWindow ?? 0,
372
387
  maxTokens: cur.maxTokens ?? 0,
373
388
  thinkingLevelMap: cur.thinkingLevelMap ?? null,
389
+ supportsDeveloperRole: (cur.compat as any)?.supportsDeveloperRole === true ? "yes" : "no",
374
390
  };
375
391
  const result = await runFormEditor(ctx, `Edit model "${providerId}/${modelId}"`, fields, initial);
376
392
  if (!result.saved) { onDone?.(); return; }
@@ -383,6 +399,7 @@ export async function editModelFlow(
383
399
  contextWindow: (v.contextWindow as number) || undefined,
384
400
  maxTokens: (v.maxTokens as number) || undefined,
385
401
  thinkingLevelMap: (v.thinkingLevelMap as Record<string, unknown> | null) ?? undefined,
402
+ compat: { ...(cur.compat ?? {}), supportsDeveloperRole: v.supportsDeveloperRole === "yes" },
386
403
  };
387
404
  const newModels = (prov.models ?? []).map((m) => (m.id === modelId ? next : m));
388
405
  const newProv: ProviderConfig = { ...prov, models: newModels };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchaozz/provider-manager",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A pi extension that manages custom providers and models in ~/.pi/agent/models.json via a TUI dashboard, /providers slash command, and remote model sync.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -17,7 +17,9 @@
17
17
  "ai"
18
18
  ],
19
19
  "pi": {
20
- "extensions": ["./"]
20
+ "extensions": [
21
+ "./"
22
+ ]
21
23
  },
22
24
  "license": "MIT",
23
25
  "author": "fanchaozz <fanchaozz@users.noreply.github.com>",
package/test.ts CHANGED
@@ -1,354 +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
- }
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
+ }
package/ui.ts CHANGED
@@ -35,6 +35,7 @@ type ModelRow = {
35
35
  // 详情面板需要从 raw ModelConfig 透传
36
36
  thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", string | null>>;
37
37
  cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
38
+ compat?: Record<string, unknown>;
38
39
  };
39
40
 
40
41
  type ProviderRow = {
@@ -209,6 +210,7 @@ function buildProviders(ctx: ExtensionCommandContext, json: ModelsJson): { provi
209
210
  // 详情面板需要这些字段
210
211
  thinkingLevelMap: m.thinkingLevelMap,
211
212
  cost: m.cost,
213
+ compat: m.compat,
212
214
  })),
213
215
  };
214
216
  });
@@ -414,9 +416,10 @@ class Dashboard {
414
416
  let okCount = 0;
415
417
  for (const r of results) {
416
418
  if (r.ok) okCount++;
417
- ctx.ui.notify(formatTestResult(r), r.ok ? "info" : "warning");
418
419
  }
419
- ctx.ui.notify(`${provider.id}: ${okCount}/${results.length} ok`, okCount === results.length ? "success" : "warning");
420
+ // 批量结果拼成一条 notify:逐条 notify 会被 showStatus 原地覆盖,只残留汇总行
421
+ const summary = results.map((r) => formatTestResult(r)).join("\n\n") + `\n${provider.id}: ${okCount}/${results.length} ok`;
422
+ ctx.ui.notify(summary, "info");
420
423
  } else {
421
424
  // t: 测当前 pane 的 model(provider pane 测第一个 model;model pane 测当前 model)
422
425
  let modelId: string | undefined;
@@ -428,7 +431,8 @@ class Dashboard {
428
431
  if (!modelId) { ctx.ui.notify(`${provider.id} 无 model`, "warning"); return; }
429
432
  ctx.ui.notify(`testing ${provider.id}/${modelId}...`, "info");
430
433
  const r = await testModel({ ctx, provider: provider.id, model: modelId, mode: "full" });
431
- ctx.ui.notify(formatTestResult(r), r.ok ? "success" : "warning");
434
+ // 同上:统一 info 避免滞留
435
+ ctx.ui.notify(formatTestResult(r), "info");
432
436
  }
433
437
  this.invalidate();
434
438
  }
@@ -660,6 +664,16 @@ class Dashboard {
660
664
  if (cost.cacheRead) lines.push(` cache read: $${cost.cacheRead}/M`);
661
665
  if (cost.cacheWrite) lines.push(` cache write: $${cost.cacheWrite}/M`);
662
666
  }
667
+ // compat:Zhipu GLM 等 OpenAI-compat 网关拒收 role:"developer"(会返 422)。为 false 时 pi 用 system role。
668
+ const compat = m.compat;
669
+ if (compat && typeof compat === "object") {
670
+ lines.push("");
671
+ lines.push(th.fg("muted", " Compat"));
672
+ if (typeof (compat as any).supportsDeveloperRole === "boolean") {
673
+ const sdr = (compat as any).supportsDeveloperRole;
674
+ lines.push(` supportsDeveloperRole: ${sdr ? th.fg("success", "yes") : th.fg("warning", "no")}`);
675
+ }
676
+ }
663
677
  }
664
678
  return lines.map((l) => truncateToWidth(l, width));
665
679
  }