@wuyaos/pi-model-config 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +200 -2
- package/extensions/model-config.ts +27 -0
- package/model-config.schema.json +269 -0
- package/package.json +52 -4
- package/src/command.ts +1053 -0
- package/src/config.ts +438 -0
- package/src/context-probe.ts +90 -0
- package/src/discovery.ts +427 -0
- package/src/i18n.ts +824 -0
- package/src/model-editor.ts +326 -0
- package/src/model-test.ts +212 -0
- package/src/panel.ts +477 -0
- package/src/runtime.ts +298 -0
- package/src/selector.ts +433 -0
- package/src/thinking-defaults.ts +39 -0
- package/src/thinking-probe.ts +84 -0
- package/src/transport.ts +94 -0
- package/src/types.ts +150 -0
- package/src/ui-config.ts +44 -0
- package/src/ui.ts +179 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Input,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
type Component,
|
|
9
|
+
type Focusable,
|
|
10
|
+
type TUI,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import type { ManagedModel, ModelMetadataEntry, ThinkingMetadataSource } from "./types.ts";
|
|
13
|
+
import { MODEL_CONFIG_OVERLAY_OPTIONS, overlayViewportRows, renderActionFooter, renderPanelBox, showConfirmationPanel, type FooterAction } from "./panel.ts";
|
|
14
|
+
import { t, type UiLanguage } from "./i18n.ts";
|
|
15
|
+
|
|
16
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
17
|
+
export type ThinkingLevelName = typeof THINKING_LEVELS[number];
|
|
18
|
+
|
|
19
|
+
type EditorFieldId =
|
|
20
|
+
| "name" | "context" | "maxTokens" | "reasoning"
|
|
21
|
+
| `thinking:${ThinkingLevelName}`
|
|
22
|
+
| "input:text" | "input:image" | "output:text" | "output:image"
|
|
23
|
+
| "cost:input" | "cost:output" | "cost:cacheRead" | "cost:cacheWrite";
|
|
24
|
+
|
|
25
|
+
interface EditorField {
|
|
26
|
+
id: EditorFieldId;
|
|
27
|
+
label: string;
|
|
28
|
+
value: string;
|
|
29
|
+
editable: "input" | "toggle";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parsePositiveScaledInteger(value: string): number | undefined {
|
|
33
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*([kKmM])?$/u);
|
|
34
|
+
if (!match) return undefined;
|
|
35
|
+
const scale = !match[2] ? 1 : match[2].toLowerCase() === "k" ? 1_000 : 1_000_000;
|
|
36
|
+
const number = Number(match[1]) * scale;
|
|
37
|
+
return Number.isSafeInteger(number) && number > 0 ? number : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseNonNegative(value: string): number | undefined {
|
|
41
|
+
const normalized = value.trim();
|
|
42
|
+
if (!/^(?:\d+(?:\.\d*)?|\.\d+)$/u.test(normalized)) return undefined;
|
|
43
|
+
const number = Number(normalized);
|
|
44
|
+
return Number.isFinite(number) && number >= 0 ? number : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function defaultThinkingLevel(model: ManagedModel): ThinkingLevelName {
|
|
48
|
+
if (model.type !== "chat" || !model.reasoning) return "off";
|
|
49
|
+
const available = THINKING_LEVELS.filter((level) => {
|
|
50
|
+
const mapped = model.thinkingLevelMap?.[level];
|
|
51
|
+
if (mapped === null) return false;
|
|
52
|
+
return level === "xhigh" || level === "max" ? mapped !== undefined : true;
|
|
53
|
+
});
|
|
54
|
+
if (available.includes("medium")) return "medium";
|
|
55
|
+
const mediumIndex = THINKING_LEVELS.indexOf("medium");
|
|
56
|
+
for (let index = mediumIndex + 1; index < THINKING_LEVELS.length; index++) if (available.includes(THINKING_LEVELS[index]!)) return THINKING_LEVELS[index]!;
|
|
57
|
+
for (let index = mediumIndex - 1; index >= 0; index--) if (available.includes(THINKING_LEVELS[index]!)) return THINKING_LEVELS[index]!;
|
|
58
|
+
return available[0] ?? "off";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function thinkingSourceLabel(language: UiLanguage, source: ThinkingMetadataSource | undefined): string {
|
|
62
|
+
switch (source) {
|
|
63
|
+
case "existing": return t(language, "thinkingSourceExisting");
|
|
64
|
+
case "remote": return t(language, "thinkingSourceRemote");
|
|
65
|
+
case "catalog": return t(language, "thinkingSourceCatalog");
|
|
66
|
+
case "protocol": return t(language, "thinkingSourceProtocol");
|
|
67
|
+
case "user": return t(language, "thinkingSourceUser");
|
|
68
|
+
case "probed": return t(language, "thinkingSourceProbed");
|
|
69
|
+
default: return t(language, "thinkingSourceDefault");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function describeThinking(language: UiLanguage, model: ManagedModel, metadata: Pick<ModelMetadataEntry, "thinkingSource">): string {
|
|
74
|
+
return t(language, "editorThinkingSummary", {
|
|
75
|
+
level: defaultThinkingLevel(model),
|
|
76
|
+
source: thinkingSourceLabel(language, metadata.thinkingSource),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pad(line: string, width: number): string {
|
|
81
|
+
const clipped = truncateToWidth(line, Math.max(1, width), "");
|
|
82
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function joinColumns(left: string[], right: string[], leftWidth: number, rightWidth: number): string[] {
|
|
86
|
+
const height = Math.max(left.length, right.length);
|
|
87
|
+
return Array.from({ length: height }, (_, index) => `${pad(left[index] ?? "", leftWidth)} ${pad(right[index] ?? "", rightWidth)}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function mapValue(model: Extract<ManagedModel, { type: "chat" }>, level: ThinkingLevelName, language: UiLanguage): string {
|
|
91
|
+
const value = model.thinkingLevelMap?.[level];
|
|
92
|
+
return value === null ? t(language, "editorMapDisabled") : value === undefined ? t(language, "editorMapDefault") : value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function editableText(model: ManagedModel, id: EditorFieldId): string {
|
|
96
|
+
if (id === "name") return model.name;
|
|
97
|
+
if (id === "context" && model.type !== "image") return String(model.contextWindow);
|
|
98
|
+
if (id === "maxTokens" && model.type === "chat") return String(model.maxTokens);
|
|
99
|
+
if (id.startsWith("thinking:") && model.type === "chat") {
|
|
100
|
+
const value = model.thinkingLevelMap?.[id.slice("thinking:".length) as ThinkingLevelName];
|
|
101
|
+
return value ?? "";
|
|
102
|
+
}
|
|
103
|
+
if (id.startsWith("cost:")) return String(model.cost[id.slice("cost:".length) as keyof typeof model.cost]);
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface EditorResult {
|
|
108
|
+
action: "return" | "apply";
|
|
109
|
+
model: ManagedModel;
|
|
110
|
+
metadata: ModelMetadataEntry;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
class ModelParameterEditor implements Component, Focusable {
|
|
114
|
+
private index = 0;
|
|
115
|
+
private _focused = false;
|
|
116
|
+
private editing?: { id: EditorFieldId; input: Input };
|
|
117
|
+
|
|
118
|
+
constructor(
|
|
119
|
+
private readonly tui: TUI,
|
|
120
|
+
private readonly theme: Theme,
|
|
121
|
+
private readonly language: UiLanguage,
|
|
122
|
+
private readonly model: ManagedModel,
|
|
123
|
+
private readonly metadata: ModelMetadataEntry,
|
|
124
|
+
private readonly done: (result: EditorResult) => void,
|
|
125
|
+
) {}
|
|
126
|
+
|
|
127
|
+
get focused(): boolean { return this._focused; }
|
|
128
|
+
set focused(value: boolean) { this._focused = value; if (this.editing) this.editing.input.focused = value; }
|
|
129
|
+
|
|
130
|
+
private fields(): EditorField[] {
|
|
131
|
+
const fields: EditorField[] = [{ id: "name", label: t(this.language, "editorName"), value: this.model.name, editable: "input" }];
|
|
132
|
+
if (this.model.type !== "image") fields.push({ id: "context", label: t(this.language, "editorContext"), value: String(this.model.contextWindow), editable: "input" });
|
|
133
|
+
if (this.model.type === "chat") {
|
|
134
|
+
fields.push(
|
|
135
|
+
{ id: "maxTokens", label: t(this.language, "editorMaxTokens"), value: String(this.model.maxTokens), editable: "input" },
|
|
136
|
+
{ id: "reasoning", label: t(this.language, "editorReasoning"), value: this.model.reasoning ? t(this.language, "valueYes") : t(this.language, "valueNo"), editable: "toggle" },
|
|
137
|
+
...THINKING_LEVELS.map((level) => ({ id: `thinking:${level}` as const, label: `${t(this.language, "editorThinkingMap")} / ${level}`, value: mapValue(this.model as Extract<ManagedModel, { type: "chat" }>, level, this.language), editable: "input" as const })),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
fields.push(
|
|
141
|
+
{ id: "input:text", label: `${t(this.language, "editorInput")} / text`, value: this.model.input.includes("text") ? "✓" : "", editable: "toggle" },
|
|
142
|
+
{ id: "input:image", label: `${t(this.language, "editorInput")} / image`, value: this.model.input.includes("image") ? "✓" : "", editable: "toggle" },
|
|
143
|
+
);
|
|
144
|
+
if (this.model.type === "image") fields.push(
|
|
145
|
+
{ id: "output:text", label: `${t(this.language, "editorOutput")} / text`, value: this.model.output.includes("text") ? "✓" : "", editable: "toggle" },
|
|
146
|
+
{ id: "output:image", label: `${t(this.language, "editorOutput")} / image`, value: this.model.output.includes("image") ? "✓" : "", editable: "toggle" },
|
|
147
|
+
);
|
|
148
|
+
for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) fields.push({ id: `cost:${key}`, label: `${t(this.language, "editorCost")} / ${key}`, value: String(this.model.cost[key]), editable: "input" });
|
|
149
|
+
return fields;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private current(): EditorField | undefined { return this.fields()[this.index]; }
|
|
153
|
+
private request(): void { if (this.editing) this.editing.input.focused = this._focused; this.tui.requestRender(); }
|
|
154
|
+
|
|
155
|
+
private markUser(id: EditorFieldId): void {
|
|
156
|
+
if (id === "context") this.metadata.contextSource = "user";
|
|
157
|
+
if (id === "maxTokens") this.metadata.maxTokensSource = "user";
|
|
158
|
+
if (id === "reasoning" || id.startsWith("thinking:")) this.metadata.thinkingSource = "user";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private commit(id: EditorFieldId, raw: string): void {
|
|
162
|
+
if (id === "name") {
|
|
163
|
+
if (!raw.trim()) { this.tui.requestRender(); return; }
|
|
164
|
+
this.model.name = raw.trim();
|
|
165
|
+
} else if (id === "context" && this.model.type !== "image") {
|
|
166
|
+
const value = parsePositiveScaledInteger(raw);
|
|
167
|
+
if (!value) this.notify("editorPositiveError");
|
|
168
|
+
else if (this.model.type === "chat" && value < this.model.maxTokens) this.notify("editorMaxError");
|
|
169
|
+
else { this.model.contextWindow = value; this.markUser(id); }
|
|
170
|
+
} else if (id === "maxTokens" && this.model.type === "chat") {
|
|
171
|
+
const value = parsePositiveScaledInteger(raw);
|
|
172
|
+
if (!value) this.notify("editorPositiveError");
|
|
173
|
+
else if (value > this.model.contextWindow) this.notify("editorMaxError");
|
|
174
|
+
else { this.model.maxTokens = value; this.markUser(id); }
|
|
175
|
+
} else if (id.startsWith("thinking:") && this.model.type === "chat") {
|
|
176
|
+
const level = id.slice("thinking:".length) as ThinkingLevelName;
|
|
177
|
+
const value = raw.trim();
|
|
178
|
+
const map = { ...(this.model.thinkingLevelMap ?? {}) };
|
|
179
|
+
if (!value) delete map[level];
|
|
180
|
+
else if (value === "-" || value.toLowerCase() === "null") map[level] = null;
|
|
181
|
+
else map[level] = value;
|
|
182
|
+
this.model.thinkingLevelMap = map;
|
|
183
|
+
this.markUser(id);
|
|
184
|
+
} else if (id.startsWith("cost:")) {
|
|
185
|
+
const key = id.slice("cost:".length) as keyof typeof this.model.cost;
|
|
186
|
+
const value = parseNonNegative(raw);
|
|
187
|
+
if (value === undefined) this.notify("editorCostError");
|
|
188
|
+
else this.model.cost[key] = value;
|
|
189
|
+
}
|
|
190
|
+
this.request();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private notify(key: "editorPositiveError" | "editorMaxError" | "editorCostError" | "editorModalityEmpty" | "editorModalityRequired"): void {
|
|
194
|
+
// The command context owns notifications; model-editor is intentionally pure TUI.
|
|
195
|
+
this.status = t(this.language, key, key === "editorModalityRequired" ? { modality: "image" } : undefined);
|
|
196
|
+
this.tui.requestRender();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private status = "";
|
|
200
|
+
|
|
201
|
+
private toggle(id: EditorFieldId): void {
|
|
202
|
+
if (id === "reasoning" && this.model.type === "chat") {
|
|
203
|
+
this.model.reasoning = !this.model.reasoning;
|
|
204
|
+
this.markUser(id);
|
|
205
|
+
} else if (id.startsWith("input:")) {
|
|
206
|
+
const modality = id.slice("input:".length) as "text" | "image";
|
|
207
|
+
const next = new Set(this.model.input);
|
|
208
|
+
if (next.has(modality)) {
|
|
209
|
+
if (next.size === 1) { this.notify("editorModalityEmpty"); return; }
|
|
210
|
+
next.delete(modality);
|
|
211
|
+
} else next.add(modality);
|
|
212
|
+
this.model.input = [...next];
|
|
213
|
+
} else if (id.startsWith("output:") && this.model.type === "image") {
|
|
214
|
+
const modality = id.slice("output:".length) as "text" | "image";
|
|
215
|
+
const next = new Set(this.model.output);
|
|
216
|
+
if (next.has(modality)) {
|
|
217
|
+
if (modality === "image") { this.notify("editorModalityRequired"); return; }
|
|
218
|
+
next.delete(modality);
|
|
219
|
+
} else next.add(modality);
|
|
220
|
+
this.model.output = [...next];
|
|
221
|
+
}
|
|
222
|
+
this.status = "";
|
|
223
|
+
this.request();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private beginEdit(field: EditorField): void {
|
|
227
|
+
if (field.editable === "toggle") { this.toggle(field.id); return; }
|
|
228
|
+
const input = new Input({ prompt: "", placeholder: t(this.language, "editorInlineHint") });
|
|
229
|
+
input.setValue(editableText(this.model, field.id));
|
|
230
|
+
input.handleInput("\x1b[F"); // End: edit existing values in place instead of prepending.
|
|
231
|
+
input.onSubmit = () => {
|
|
232
|
+
const editing = this.editing;
|
|
233
|
+
if (!editing) return;
|
|
234
|
+
this.editing = undefined;
|
|
235
|
+
this.commit(editing.id, editing.input.getValue());
|
|
236
|
+
};
|
|
237
|
+
this.editing = { id: field.id, input };
|
|
238
|
+
input.focused = this._focused;
|
|
239
|
+
this.request();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private valid(): boolean {
|
|
243
|
+
if (this.model.type === "chat" && this.model.maxTokens > this.model.contextWindow) { this.notify("editorMaxError"); return false; }
|
|
244
|
+
if (!this.model.input.length) { this.notify("editorModalityEmpty"); return false; }
|
|
245
|
+
if (this.model.type === "image" && !this.model.output.includes("image")) { this.notify("editorModalityRequired"); return false; }
|
|
246
|
+
if (Object.values(this.model.cost).some((value) => !Number.isFinite(value) || value < 0)) { this.notify("editorCostError"); return false; }
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private apply(): void {
|
|
251
|
+
if (this.editing) {
|
|
252
|
+
const editing = this.editing;
|
|
253
|
+
this.editing = undefined;
|
|
254
|
+
this.commit(editing.id, editing.input.getValue());
|
|
255
|
+
}
|
|
256
|
+
if (this.valid()) this.done({ action: "apply", model: structuredClone(this.model), metadata: structuredClone(this.metadata) });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
handleInput(data: string): void {
|
|
260
|
+
if (matchesKey(data, Key.ctrl("s"))) { this.apply(); return; }
|
|
261
|
+
if (matchesKey(data, Key.escape)) {
|
|
262
|
+
if (this.editing) { this.editing = undefined; this.status = ""; this.request(); }
|
|
263
|
+
else this.done({ action: "return", model: structuredClone(this.model), metadata: structuredClone(this.metadata) });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (this.editing) { this.editing.input.handleInput(data); this.request(); return; }
|
|
267
|
+
const fields = this.fields();
|
|
268
|
+
if (matchesKey(data, Key.up)) { this.index = Math.max(0, this.index - 1); this.status = ""; this.request(); return; }
|
|
269
|
+
if (matchesKey(data, Key.down)) { this.index = Math.min(fields.length - 1, this.index + 1); this.status = ""; this.request(); return; }
|
|
270
|
+
if (matchesKey(data, Key.enter)) { const field = this.current(); if (field) this.beginEdit(field); }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
render(width: number): string[] {
|
|
274
|
+
const safeWidth = Math.max(4, width);
|
|
275
|
+
const inner = Math.max(1, safeWidth - 2);
|
|
276
|
+
const wide = inner >= 68;
|
|
277
|
+
const contentBudget = Math.max(2, overlayViewportRows(this.tui) - 2);
|
|
278
|
+
const actions: FooterAction[] = [
|
|
279
|
+
{ id: "edit", label: t(this.language, "editorModify"), key: Key.enter },
|
|
280
|
+
{ id: "apply", label: t(this.language, "editorApply"), key: Key.ctrl("s") },
|
|
281
|
+
{ id: "back", label: t(this.language, "editorReturn"), key: Key.escape },
|
|
282
|
+
];
|
|
283
|
+
const footer = renderActionFooter(actions, 0, false, inner, this.theme);
|
|
284
|
+
const heading = [this.theme.fg("muted", describeThinking(this.language, this.model, this.metadata)), this.theme.fg("muted", t(this.language, "editorMapHint"))];
|
|
285
|
+
const status = this.status ? [this.theme.fg("error", this.status)] : [];
|
|
286
|
+
const bodyBudget = Math.max(0, contentBudget - heading.length - status.length - footer.length);
|
|
287
|
+
const leftWidth = wide ? Math.max(26, Math.floor((inner - 2) * 0.43)) : inner;
|
|
288
|
+
const rightWidth = wide ? inner - leftWidth - 2 : inner;
|
|
289
|
+
const fields = this.fields();
|
|
290
|
+
this.index = Math.min(this.index, Math.max(0, fields.length - 1));
|
|
291
|
+
const visible = Math.max(1, bodyBudget);
|
|
292
|
+
const start = Math.max(0, Math.min(this.index - Math.floor(visible / 2), Math.max(0, fields.length - visible)));
|
|
293
|
+
const left: string[] = [];
|
|
294
|
+
const right: string[] = [];
|
|
295
|
+
for (let index = start; index < Math.min(start + visible, fields.length); index++) {
|
|
296
|
+
const field = fields[index]!;
|
|
297
|
+
const current = index === this.index;
|
|
298
|
+
const label = `${current ? "→ " : " "}${field.label}`;
|
|
299
|
+
left.push(current ? this.theme.fg("accent", this.theme.bold(truncateToWidth(label, leftWidth, "…"))) : truncateToWidth(label, leftWidth, "…"));
|
|
300
|
+
if (current && this.editing?.id === field.id) right.push(...this.editing.input.render(rightWidth));
|
|
301
|
+
else right.push(this.theme.fg(current ? "accent" : "muted", truncateToWidth(field.value || t(this.language, "valueUnset"), rightWidth, "…")));
|
|
302
|
+
}
|
|
303
|
+
const body = wide ? joinColumns(left, right, leftWidth, rightWidth) : left;
|
|
304
|
+
return renderPanelBox([...heading, ...status, ...body.slice(0, bodyBudget), ...footer].slice(0, contentBudget), safeWidth, t(this.language, "editorTitle", { model: this.model.id }), this.theme);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
invalidate(): void { this.editing?.input.invalidate(); }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function showModelParameterEditor(ctx: ExtensionCommandContext, language: UiLanguage, model: ManagedModel, metadata: ModelMetadataEntry): Promise<EditorResult> {
|
|
311
|
+
return ctx.ui.custom<EditorResult>((tui, theme, _keys, done) => new ModelParameterEditor(tui, theme, language, structuredClone(model), structuredClone(metadata), done), MODEL_CONFIG_OVERLAY_OPTIONS);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function editModelParameters(ctx: ExtensionCommandContext, language: UiLanguage, source: ManagedModel, metadata: ModelMetadataEntry): Promise<{ model: ManagedModel; metadata: ModelMetadataEntry } | undefined> {
|
|
315
|
+
const result = await showModelParameterEditor(ctx, language, source, metadata);
|
|
316
|
+
if (result.action !== "apply") return undefined;
|
|
317
|
+
const confirm = await showConfirmationPanel(ctx, {
|
|
318
|
+
title: t(language, "editorConfirmTitle"),
|
|
319
|
+
summary: [`${result.model.type}/${result.model.id}`, result.model.name, describeThinking(language, result.model, result.metadata)],
|
|
320
|
+
details: [JSON.stringify(result.model)],
|
|
321
|
+
defaultActionIndex: 0,
|
|
322
|
+
cancelActionId: "cancel",
|
|
323
|
+
actions: [{ id: "cancel", label: t(language, "confirmCancel") }, { id: "apply", label: t(language, "editorApply"), shortcut: "ctrl+s" }],
|
|
324
|
+
});
|
|
325
|
+
return confirm === "apply" ? { model: result.model, metadata: result.metadata } : undefined;
|
|
326
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { AnyModel } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Key, matchesKey, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
import { MODEL_CONFIG_OVERLAY_OPTIONS, overlayViewportRows, renderActionFooter, renderPanelBox, showConfirmationPanel, type FooterAction } from "./panel.ts";
|
|
5
|
+
import { sanitizeError } from "./config.ts";
|
|
6
|
+
import { t, type UiLanguage } from "./i18n.ts";
|
|
7
|
+
|
|
8
|
+
export type ModelTestStatus = "success" | "auth" | "rate-limit" | "timeout" | "unsupported" | "aborted" | "error";
|
|
9
|
+
|
|
10
|
+
export interface ModelTestResult {
|
|
11
|
+
model: AnyModel;
|
|
12
|
+
status: ModelTestStatus;
|
|
13
|
+
latencyMs: number;
|
|
14
|
+
usage?: unknown;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ModelTestTimeouts { chat: number; classifier: number; image: number }
|
|
19
|
+
export const MODEL_TEST_CONCURRENCY = 2;
|
|
20
|
+
export const MODEL_TEST_TIMEOUTS: ModelTestTimeouts = { chat: 15_000, classifier: 15_000, image: 120_000 };
|
|
21
|
+
|
|
22
|
+
function classifyFailure(error: unknown, signal: AbortSignal): ModelTestStatus {
|
|
23
|
+
if (signal.aborted) return signal.reason?.name === "TimeoutError" ? "timeout" : "aborted";
|
|
24
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25
|
+
if (/\b(?:401|403)\b|auth|credential|api.?key/iu.test(message)) return "auth";
|
|
26
|
+
if (/\b429\b|rate.?limit/iu.test(message)) return "rate-limit";
|
|
27
|
+
if (/unsupported|not implemented|unknown provider/iu.test(message)) return "unsupported";
|
|
28
|
+
return "error";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function testOne(model: AnyModel, registry: any, rootSignal: AbortSignal, timeouts: ModelTestTimeouts, activeSecrets: Array<string | undefined>): Promise<ModelTestResult> {
|
|
32
|
+
const started = performance.now();
|
|
33
|
+
const timeout = model.type === "image" ? timeouts.image : model.type === "classifier" ? timeouts.classifier : timeouts.chat;
|
|
34
|
+
const signal = AbortSignal.any([rootSignal, AbortSignal.timeout(timeout)]);
|
|
35
|
+
try {
|
|
36
|
+
let usage: unknown;
|
|
37
|
+
if (model.type === "classifier") {
|
|
38
|
+
const result = await registry.classify(model, {
|
|
39
|
+
state: { text: "ok" },
|
|
40
|
+
questions: { ok: { type: "bool", instructions: "Is the input ok?", criteria: { true: "yes", false: "no" } } },
|
|
41
|
+
}, { signal });
|
|
42
|
+
if (result.stopReason !== "stop") throw new Error(result.errorMessage ?? result.stopReason);
|
|
43
|
+
usage = result.usage;
|
|
44
|
+
} else if (model.type === "image") {
|
|
45
|
+
const result = await registry.generateImages(model, { input: [{ type: "text", text: "A single red dot" }] }, { signal });
|
|
46
|
+
if (result.stopReason !== "stop") throw new Error(result.errorMessage ?? result.stopReason);
|
|
47
|
+
usage = result.usage;
|
|
48
|
+
} else {
|
|
49
|
+
const stream = registry.streamSimple(model, {
|
|
50
|
+
messages: [{ role: "user", content: [{ type: "text", text: "ok" }], timestamp: Date.now() }],
|
|
51
|
+
}, { signal, maxTokens: 1 });
|
|
52
|
+
let terminal = false;
|
|
53
|
+
for await (const event of stream) {
|
|
54
|
+
if (event.type === "error") {
|
|
55
|
+
const reason = event.error?.errorMessage ?? event.errorMessage ?? event.reason ?? event.error?.stopReason ?? "stream error";
|
|
56
|
+
throw new Error(String(reason));
|
|
57
|
+
}
|
|
58
|
+
if (event.type === "done") {
|
|
59
|
+
terminal = true;
|
|
60
|
+
usage = event.message?.usage ?? event.usage;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!terminal) throw new Error("stream ended without terminal done event");
|
|
64
|
+
}
|
|
65
|
+
return { model, status: "success", latencyMs: Math.round(performance.now() - started), usage };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return {
|
|
68
|
+
model,
|
|
69
|
+
status: classifyFailure(error, signal),
|
|
70
|
+
latencyMs: Math.round(performance.now() - started),
|
|
71
|
+
error: sanitizeError(error, activeSecrets),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runModelAvailabilityTests(
|
|
77
|
+
models: AnyModel[],
|
|
78
|
+
registry: any,
|
|
79
|
+
signal: AbortSignal,
|
|
80
|
+
concurrency = MODEL_TEST_CONCURRENCY,
|
|
81
|
+
timeouts = MODEL_TEST_TIMEOUTS,
|
|
82
|
+
activeSecrets: Array<string | undefined> = [],
|
|
83
|
+
): Promise<ModelTestResult[]> {
|
|
84
|
+
const results = new Array<ModelTestResult>(models.length);
|
|
85
|
+
let next = 0;
|
|
86
|
+
const worker = async () => {
|
|
87
|
+
while (true) {
|
|
88
|
+
const index = next++;
|
|
89
|
+
if (index >= models.length) return;
|
|
90
|
+
if (signal.aborted) {
|
|
91
|
+
results[index] = { model: models[index]!, status: "aborted", latencyMs: 0 };
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
results[index] = await testOne(models[index]!, registry, signal, timeouts, activeSecrets);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, models.length || 1)) }, worker));
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class TestSelectionPanel implements Component {
|
|
102
|
+
private index = 0;
|
|
103
|
+
private selected = new Set<string>();
|
|
104
|
+
constructor(private tui: TUI, private theme: Theme, private language: UiLanguage, private models: AnyModel[], private done: (models: AnyModel[] | undefined) => void) {
|
|
105
|
+
for (const model of models) if (model.type !== "image") this.selected.add(`${model.type}\0${model.id}`);
|
|
106
|
+
}
|
|
107
|
+
private key(model: AnyModel) { return `${model.type}\0${model.id}`; }
|
|
108
|
+
handleInput(data: string): void {
|
|
109
|
+
if (matchesKey(data, Key.escape)) { this.done(undefined); return; }
|
|
110
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
|
|
111
|
+
const delta = matchesKey(data, Key.up) ? -1 : 1;
|
|
112
|
+
this.index = Math.max(0, Math.min(this.models.length - 1, this.index + delta));
|
|
113
|
+
this.tui.requestRender(); return;
|
|
114
|
+
}
|
|
115
|
+
if (matchesKey(data, Key.space) || matchesKey(data, Key.enter)) {
|
|
116
|
+
const model = this.models[this.index]; if (!model) return;
|
|
117
|
+
const key = this.key(model); if (this.selected.has(key)) this.selected.delete(key); else this.selected.add(key);
|
|
118
|
+
this.tui.requestRender(); return;
|
|
119
|
+
}
|
|
120
|
+
if (matchesKey(data, Key.ctrl("s"))) this.done(this.models.filter((model) => this.selected.has(this.key(model))));
|
|
121
|
+
}
|
|
122
|
+
render(width: number): string[] {
|
|
123
|
+
const inner = Math.max(1, width - 2);
|
|
124
|
+
const budget = Math.max(2, overlayViewportRows(this.tui) - 2);
|
|
125
|
+
const rows = this.models.slice(Math.max(0, this.index - Math.max(1, budget - 4)), this.index + 1).map((model) => {
|
|
126
|
+
const actual = this.models.indexOf(model);
|
|
127
|
+
return `${actual === this.index ? "→ " : " "}[${this.selected.has(this.key(model)) ? "✓" : " "}] [${model.type}] ${model.id}`;
|
|
128
|
+
});
|
|
129
|
+
const footer = [t(this.language, "testSelectionHelp")];
|
|
130
|
+
return renderPanelBox([...rows, ...footer].slice(0, budget), width, t(this.language, "testSelectionTitle"), this.theme).map((line) => truncateToWidth(line, width, ""));
|
|
131
|
+
}
|
|
132
|
+
invalidate() {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
class TestRunPanel implements Component {
|
|
136
|
+
private controller = new AbortController();
|
|
137
|
+
private results: ModelTestResult[] = [];
|
|
138
|
+
constructor(private tui: TUI, private theme: Theme, private language: UiLanguage, models: AnyModel[], registry: any, activeSecrets: Array<string | undefined>, done: (results: ModelTestResult[]) => void) {
|
|
139
|
+
void runModelAvailabilityTests(models, registry, this.controller.signal, MODEL_TEST_CONCURRENCY, MODEL_TEST_TIMEOUTS, activeSecrets).then((results) => { this.results = results; this.tui.requestRender(); done(results); });
|
|
140
|
+
}
|
|
141
|
+
handleInput(data: string): void { if (matchesKey(data, Key.escape)) this.controller.abort(new DOMException("Cancelled", "AbortError")); }
|
|
142
|
+
render(width: number): string[] {
|
|
143
|
+
const lines = this.results.length ? this.results.map((result) => `${result.status === "success" ? "✓" : "×"} [${result.model.type}] ${result.model.id} · ${result.status} · ${result.latencyMs}ms`) : [t(this.language, "testRunning")];
|
|
144
|
+
return renderPanelBox(lines.slice(0, Math.max(1, overlayViewportRows(this.tui) - 2)), width, t(this.language, "testResultsTitle"), this.theme);
|
|
145
|
+
}
|
|
146
|
+
invalidate() {}
|
|
147
|
+
dispose() { this.controller.abort(); }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
class TestResultsPanel implements Component {
|
|
151
|
+
private actionIndex = 0;
|
|
152
|
+
constructor(private tui: TUI, private theme: Theme, private language: UiLanguage, private results: ModelTestResult[], private done: (action: "retry" | "back") => void) {}
|
|
153
|
+
private actions(): FooterAction[] {
|
|
154
|
+
return [
|
|
155
|
+
{ id: "back", label: t(this.language, "testResultsBack"), key: Key.escape },
|
|
156
|
+
{ id: "retry", label: t(this.language, "testResultsRetry"), key: Key.enter, enabled: this.results.some((result) => result.status !== "success") },
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
private activate(): void {
|
|
160
|
+
const action = this.actions()[this.actionIndex];
|
|
161
|
+
if (action?.enabled === false) return;
|
|
162
|
+
if (action?.id === "retry" || action?.id === "back") this.done(action.id);
|
|
163
|
+
}
|
|
164
|
+
handleInput(data: string): void {
|
|
165
|
+
if (matchesKey(data, Key.escape)) { this.done("back"); return; }
|
|
166
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
|
|
167
|
+
const delta = matchesKey(data, Key.left) ? -1 : 1;
|
|
168
|
+
this.actionIndex = (this.actionIndex + delta + this.actions().length) % this.actions().length;
|
|
169
|
+
this.tui.requestRender();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) return;
|
|
173
|
+
if (matchesKey(data, Key.enter)) this.activate();
|
|
174
|
+
}
|
|
175
|
+
render(width: number): string[] {
|
|
176
|
+
const inner = Math.max(1, width - 2);
|
|
177
|
+
const actions = renderActionFooter(this.actions(), this.actionIndex, true, inner, this.theme);
|
|
178
|
+
const budget = Math.max(2, overlayViewportRows(this.tui) - 2);
|
|
179
|
+
const lines = this.results.map((result) => {
|
|
180
|
+
const usage = result.usage ? ` · usage=${JSON.stringify(result.usage)}` : "";
|
|
181
|
+
return `${result.status === "success" ? "✓" : "×"} [${result.model.type}] ${result.model.id} · ${result.status} · ${result.latencyMs}ms${usage}${result.error ? ` · ${result.error}` : ""}`;
|
|
182
|
+
});
|
|
183
|
+
const body = [...lines, t(this.language, "testResultsHelp")].slice(0, Math.max(0, budget - actions.length));
|
|
184
|
+
return renderPanelBox([...body, ...actions], width, t(this.language, "testResultsTitle"), this.theme);
|
|
185
|
+
}
|
|
186
|
+
invalidate() {}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function showModelTestFlow(ctx: ExtensionCommandContext, language: UiLanguage, models: AnyModel[], activeSecrets: Array<string | undefined> = []): Promise<void> {
|
|
190
|
+
let selected = await ctx.ui.custom<AnyModel[] | undefined>((tui, theme, _keys, done) => new TestSelectionPanel(tui, theme, language, models, done), MODEL_CONFIG_OVERLAY_OPTIONS);
|
|
191
|
+
while (selected?.length) {
|
|
192
|
+
const imageCount = selected.filter((model) => model.type === "image").length;
|
|
193
|
+
if (imageCount) {
|
|
194
|
+
const image = await showConfirmationPanel(ctx, {
|
|
195
|
+
title: t(language, "testImageConfirmTitle"), summary: [t(language, "testImageConfirmBody", { count: imageCount })], severity: "error",
|
|
196
|
+
defaultActionIndex: 0, cancelActionId: "cancel",
|
|
197
|
+
actions: [{ id: "cancel", label: t(language, "confirmCancel") }, { id: "run", label: t(language, "testImageRun"), shortcut: "ctrl+i", severity: "error" }],
|
|
198
|
+
});
|
|
199
|
+
if (image !== "run") return;
|
|
200
|
+
}
|
|
201
|
+
const paid = await showConfirmationPanel(ctx, {
|
|
202
|
+
title: t(language, "testRunConfirmTitle"), summary: [t(language, "testRunConfirmBody", { count: selected.length })], severity: "warning",
|
|
203
|
+
defaultActionIndex: 0, cancelActionId: "cancel",
|
|
204
|
+
actions: [{ id: "cancel", label: t(language, "confirmCancel") }, { id: "run", label: t(language, "testRunAction"), shortcut: "ctrl+s", severity: "warning" }],
|
|
205
|
+
});
|
|
206
|
+
if (paid !== "run") return;
|
|
207
|
+
const results = await ctx.ui.custom<ModelTestResult[]>((tui, theme, _keys, done) => new TestRunPanel(tui, theme, language, selected!, ctx.modelRegistry, activeSecrets, done), MODEL_CONFIG_OVERLAY_OPTIONS);
|
|
208
|
+
const action = await ctx.ui.custom<"retry" | "back">((tui, theme, _keys, done) => new TestResultsPanel(tui, theme, language, results, done), MODEL_CONFIG_OVERLAY_OPTIONS);
|
|
209
|
+
if (action === "back") return;
|
|
210
|
+
selected = results.filter((result) => result.status !== "success").map((result) => result.model);
|
|
211
|
+
}
|
|
212
|
+
}
|