@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.
- package/commands.ts +6 -4
- package/forms.ts +19 -2
- package/package.json +4 -2
- package/test.ts +354 -354
- 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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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))
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|