pi-better-model-select 0.2.0 → 0.3.1
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/CHANGELOG.md +63 -0
- package/README.md +17 -7
- package/docs/configuration.md +8 -2
- package/docs/demo/showcase.gif +0 -0
- package/extensions/bms.ts +62 -50
- package/extensions/lib/defaults.ts +146 -52
- package/extensions/lib/effort-editor.ts +35 -20
- package/extensions/lib/flows.ts +126 -496
- package/extensions/lib/picker-dialog.ts +11 -19
- package/extensions/lib/picker.ts +90 -162
- package/extensions/lib/store.ts +3 -0
- package/extensions/lib/tui.ts +320 -0
- package/package.json +3 -1
package/extensions/lib/flows.ts
CHANGED
|
@@ -1,34 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// Model definitions and effort preferences are drafts until the form's Save action.
|
|
2
|
+
import { editForm, tableSelect, confirmAction } from "./tui.ts";
|
|
3
|
+
import { editModelEfforts, modelEffortDraft, saveModelEfforts, type EffortDraft } from "./effort-editor.ts";
|
|
4
|
+
import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, fetchModelMeta, listEndpointModels, parsePositiveInt, providerEndpoint, resolveKey, type ModelMeta } from "./meta.ts";
|
|
5
|
+
import { configContainsModel, loadConfig, loadConfigFile, loadSettings, saveConfig, tombstoneModel, upsertModel, writeTarget, type ModelEntry, type ModelsConfig } from "./store.ts";
|
|
5
6
|
|
|
6
|
-
import { editModelEfforts, saveModelEfforts, type EffortDraft } from "./effort-editor.ts";
|
|
7
|
-
import {
|
|
8
|
-
DEFAULT_CONTEXT_WINDOW,
|
|
9
|
-
DEFAULT_MAX_TOKENS,
|
|
10
|
-
fetchModelMeta,
|
|
11
|
-
listEndpointModels,
|
|
12
|
-
parsePositiveInt,
|
|
13
|
-
providerEndpoint,
|
|
14
|
-
resolveKey,
|
|
15
|
-
type ModelMeta,
|
|
16
|
-
} from "./meta.ts";
|
|
17
|
-
import {
|
|
18
|
-
configContainsModel,
|
|
19
|
-
loadConfig,
|
|
20
|
-
loadConfigFile,
|
|
21
|
-
loadSettings,
|
|
22
|
-
saveConfig,
|
|
23
|
-
tombstoneModel,
|
|
24
|
-
upsertModel,
|
|
25
|
-
writeTarget,
|
|
26
|
-
type ConfigTarget,
|
|
27
|
-
type ModelEntry,
|
|
28
|
-
type ModelsConfig,
|
|
29
|
-
} from "./store.ts";
|
|
30
|
-
|
|
31
|
-
// Minimal shape of the pi extension command context we rely on.
|
|
32
7
|
export interface FlowCtx {
|
|
33
8
|
cwd?: string;
|
|
34
9
|
isProjectTrusted?: () => boolean;
|
|
@@ -39,489 +14,144 @@ export interface FlowCtx {
|
|
|
39
14
|
confirm: (title: string, message: string) => Promise<boolean>;
|
|
40
15
|
};
|
|
41
16
|
}
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return writeTarget(loadSettings());
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function providerOverlay(config: ModelsConfig, providerName: string): Record<string, unknown> {
|
|
50
|
-
const provider = config.providers?.[providerName] ?? {};
|
|
51
|
-
const { models: _models, ...settings } = provider;
|
|
52
|
-
return settings;
|
|
17
|
+
function ensureProviderSettings(target: ModelsConfig, effective: ModelsConfig, provider: string): void {
|
|
18
|
+
const { models: _models, ...settings } = effective.providers?.[provider] ?? {};
|
|
19
|
+
target.providers ??= {};
|
|
20
|
+
target.providers[provider] = { ...settings, ...target.providers[provider] };
|
|
53
21
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
function ensureProviderSettings(
|
|
61
|
-
targetConfig: ModelsConfig,
|
|
62
|
-
effectiveConfig: ModelsConfig,
|
|
63
|
-
providerName: string,
|
|
64
|
-
): void {
|
|
65
|
-
targetConfig.providers = targetConfig.providers ?? {};
|
|
66
|
-
targetConfig.providers[providerName] = {
|
|
67
|
-
...providerOverlay(effectiveConfig, providerName),
|
|
68
|
-
...(targetConfig.providers[providerName] ?? {}),
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Present a yes/no as a select so ESC can cancel — confirm() cannot. */
|
|
73
|
-
async function selectYesNo(
|
|
74
|
-
ctx: FlowCtx,
|
|
75
|
-
message: string,
|
|
76
|
-
defaultYes: boolean,
|
|
77
|
-
): Promise<boolean | undefined> {
|
|
78
|
-
const first = defaultYes ? "yes" : "no";
|
|
79
|
-
const pick = await ctx.ui.select(message, [first, defaultYes ? "no" : "yes"]);
|
|
80
|
-
return pick === undefined ? undefined : pick === "yes";
|
|
22
|
+
async function pickModel(ctx: FlowCtx, config: ModelsConfig, title: string) {
|
|
23
|
+
const models = Object.entries(config.providers ?? {}).flatMap(([provider, entry]) => (entry.models ?? []).map(model => ({ provider, model })));
|
|
24
|
+
if (!models.length) { ctx.ui.notify("No models configured.", "info"); return; }
|
|
25
|
+
const value = await tableSelect(ctx, { title, search: true, columns: [{ key: "model", label: "MODEL" }, { key: "provider", label: "PROVIDER" }], rows: models.map(({ provider, model }, index) => ({ value: String(index), label: model.name ?? model.id, cells: { model: model.id, provider } })) });
|
|
26
|
+
return value === undefined ? undefined : models[Number(value)];
|
|
81
27
|
}
|
|
82
28
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
29
|
+
/** Shared add/update form. Field dialogs only mutate this draft, never files.
|
|
30
|
+
* Text and numeric edits use Pi input dialogs until a safe inline editor exists.
|
|
31
|
+
*/
|
|
32
|
+
async function modelForm(ctx: FlowCtx, provider: string, initial: ModelEntry, meta: ModelMeta, adding: boolean): Promise<boolean> {
|
|
33
|
+
const draft = structuredClone(initial);
|
|
34
|
+
const changes = new Set<string>();
|
|
35
|
+
const key = `${provider}/${draft.id}`;
|
|
36
|
+
let efforts = modelEffortDraft(ctx, key, Boolean(draft.reasoning), meta.effortLevels);
|
|
37
|
+
let effortsChanged = adding;
|
|
38
|
+
const field = (name: string, label: string, value: string, edit: () => Promise<string | undefined>, description?: string) => ({ key: name, label, value, edit, description });
|
|
39
|
+
const numberField = (name: "contextWindow" | "maxTokens", label: string, fallback: number) => field(name, label, String(draft[name] ?? fallback), async () => {
|
|
40
|
+
const suggested = meta[name] ?? draft[name] ?? fallback;
|
|
41
|
+
const raw = await ctx.ui.input(label, String(suggested));
|
|
42
|
+
if (raw === undefined) return;
|
|
43
|
+
draft[name] = parsePositiveInt(raw, suggested);
|
|
44
|
+
changes.add(name);
|
|
45
|
+
return String(draft[name]);
|
|
46
|
+
}, meta[name] === undefined ? undefined : `Provider: ${meta[name]}`);
|
|
47
|
+
return editForm(ctx, {
|
|
48
|
+
title: `${adding ? "Add" : "Update"} ${key}`,
|
|
49
|
+
hint: `Save writes ${writeTarget(loadSettings())}. Text and numbers open input dialogs.`,
|
|
50
|
+
fields: [
|
|
51
|
+
field("identity", "Model", key, async () => undefined, "Identity is fixed; cancel to choose another model."),
|
|
52
|
+
field("name", "Display name", draft.name ?? draft.id, async () => {
|
|
53
|
+
const raw = await ctx.ui.input("Display name", draft.name ?? draft.id);
|
|
54
|
+
if (raw === undefined) return;
|
|
55
|
+
draft.name = raw || draft.id; changes.add("name"); return draft.name;
|
|
56
|
+
}),
|
|
57
|
+
numberField("contextWindow", "Context window", DEFAULT_CONTEXT_WINDOW),
|
|
58
|
+
numberField("maxTokens", "Max output tokens", DEFAULT_MAX_TOKENS),
|
|
59
|
+
field("reasoning", "Reasoning", draft.reasoning ? "yes" : "no", async () => {
|
|
60
|
+
const value = await tableSelect(ctx, { title: "Reasoning model?", initialValue: draft.reasoning ? "yes" : "no", rows: ["no", "yes"].map(value => ({ value, label: value })) });
|
|
61
|
+
if (value === undefined) return;
|
|
62
|
+
const reasoning = value === "yes";
|
|
63
|
+
// Do not change the visible field if its dependent effort draft is cancelled.
|
|
64
|
+
const initialEfforts = reasoning === Boolean(draft.reasoning) ? efforts : modelEffortDraft(ctx, key, reasoning, meta.effortLevels);
|
|
65
|
+
const next = await editModelEfforts(ctx, key, reasoning, meta.effortLevels, initialEfforts);
|
|
66
|
+
if (!next) return;
|
|
67
|
+
draft.reasoning = reasoning; efforts = next; effortsChanged = true; changes.add("reasoning"); return value;
|
|
68
|
+
}),
|
|
69
|
+
field("input", "Input", (draft.input ?? ["text"]).join("+"), async () => {
|
|
70
|
+
const value = await tableSelect(ctx, { title: "Input modalities", rows: ["text", "text+image"].map(value => ({ value, label: value })) });
|
|
71
|
+
if (value === undefined) return;
|
|
72
|
+
draft.input = value.split("+"); changes.add("input"); return value;
|
|
73
|
+
}),
|
|
74
|
+
field("efforts", "Available / preferred efforts", effortLabel(efforts), async () => {
|
|
75
|
+
const next = await editModelEfforts(ctx, key, Boolean(draft.reasoning), meta.effortLevels, efforts);
|
|
76
|
+
if (!next) return;
|
|
77
|
+
efforts = next; effortsChanged = true; return effortLabel(efforts);
|
|
78
|
+
}),
|
|
79
|
+
],
|
|
80
|
+
onSave: async () => {
|
|
81
|
+
const target = writeTarget(loadSettings());
|
|
82
|
+
const effective = loadConfig(ctx);
|
|
83
|
+
const config = loadConfigFile(ctx, target, target === "models.bms.json");
|
|
84
|
+
if (!effective || !config) return false;
|
|
85
|
+
if (!effective.providers?.[provider]) { ctx.ui.notify(`Provider "${provider}" no longer exists.`, "error"); return false; }
|
|
86
|
+
const current = effective.providers[provider]!.models?.find(model => model.id === draft.id);
|
|
87
|
+
if (adding ? !!current : !current) { ctx.ui.notify(adding ? "Model is already configured." : "Model no longer exists.", "warning"); return false; }
|
|
88
|
+
if (effortsChanged && !saveModelEfforts(ctx, key, efforts)) return false;
|
|
89
|
+
const entry = adding ? draft : structuredClone(current!);
|
|
90
|
+
if (!adding) for (const name of changes) (entry as any)[name] = (draft as any)[name];
|
|
91
|
+
ensureProviderSettings(config, effective, provider);
|
|
92
|
+
upsertModel(config, provider, entry);
|
|
93
|
+
saveConfig(config, target);
|
|
94
|
+
ctx.ui.notify(`${adding ? "Added" : "Updated"} ${key} in ${target}.`, "info");
|
|
95
|
+
return true;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
94
98
|
}
|
|
95
|
-
|
|
96
|
-
// ----- add -----------------------------------------------------------------
|
|
99
|
+
function effortLabel(draft: EffortDraft): string { return `${draft.capability.levels.join(", ")}; preferred ${draft.preferred ?? "automatic"}`; }
|
|
97
100
|
|
|
98
101
|
export async function addFlow(ctx: FlowCtx, onAdded?: (provider: string, modelId: string) => Promise<void>): Promise<void> {
|
|
99
102
|
const config = loadConfig(ctx);
|
|
100
103
|
if (!config) return;
|
|
101
104
|
const providers = config.providers ?? {};
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// Running breadcrumb of everything chosen so far, shown atop each prompt.
|
|
110
|
-
const crumbs: string[] = [];
|
|
111
|
-
const header = () =>
|
|
112
|
-
crumbs.length ? `Selected so far:\n${crumbs.map((c) => ` • ${c}`).join("\n")}\n\n` : "";
|
|
113
|
-
|
|
114
|
-
const providerName = await ctx.ui.select(`${header()}Select provider:`, names);
|
|
115
|
-
if (providerName === undefined) return;
|
|
116
|
-
const provider = providers[providerName]!;
|
|
117
|
-
crumbs.push(`Provider: ${providerName}`);
|
|
118
|
-
|
|
119
|
-
const base = String(provider.baseUrl).replace(/\/+$/, "");
|
|
120
|
-
const key = resolveKey(provider.apiKey);
|
|
121
|
-
|
|
105
|
+
const names = Object.keys(providers).filter(name => typeof providers[name]?.baseUrl === "string");
|
|
106
|
+
if (!names.length) { ctx.ui.notify("No providers with a baseUrl in the effective model config", "warning"); return; }
|
|
107
|
+
const provider = await tableSelect(ctx, { title: "Select provider", search: true, rows: names.map(value => ({ value, label: value })) });
|
|
108
|
+
if (provider === undefined) return;
|
|
109
|
+
const entry = providers[provider]!;
|
|
110
|
+
const base = String(entry.baseUrl).replace(/\/+$/, "");
|
|
111
|
+
const key = resolveKey(entry.apiKey);
|
|
122
112
|
let ids: string[];
|
|
123
|
-
try {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
ctx.ui.notify("All endpoint models are already configured", "info");
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const modelId = await ctx.ui.select(
|
|
138
|
-
`${header()}Add which model to "${providerName}"?`,
|
|
139
|
-
candidates,
|
|
140
|
-
);
|
|
141
|
-
if (modelId === undefined) return;
|
|
142
|
-
crumbs.push(`Model: ${modelId}`);
|
|
143
|
-
|
|
144
|
-
const meta = await fetchModelMeta(base, key, modelId);
|
|
145
|
-
|
|
146
|
-
const prefilled: string[] = [];
|
|
147
|
-
if (meta.contextWindow !== undefined) prefilled.push(`ctx=${meta.contextWindow}`);
|
|
148
|
-
if (meta.maxTokens !== undefined) prefilled.push(`max=${meta.maxTokens}`);
|
|
149
|
-
if (meta.reasoning !== undefined) prefilled.push(`reasoning=${meta.reasoning}`);
|
|
150
|
-
if (prefilled.length > 0) {
|
|
151
|
-
ctx.ui.notify(`Auto-filled from provider: ${prefilled.join(", ")}`, "info");
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const nameRaw = await ctx.ui.input(`${header()}Display name:`, modelId);
|
|
155
|
-
if (nameRaw === undefined) return;
|
|
156
|
-
const name = nameRaw || modelId;
|
|
157
|
-
crumbs.push(`Name: ${name}`);
|
|
158
|
-
|
|
159
|
-
const cwDefault = meta.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
160
|
-
const cwRaw = await ctx.ui.input(`${header()}Context window:`, String(cwDefault));
|
|
161
|
-
if (cwRaw === undefined) return;
|
|
162
|
-
const contextWindow = parsePositiveInt(cwRaw, cwDefault);
|
|
163
|
-
crumbs.push(`Context window: ${contextWindow}`);
|
|
164
|
-
|
|
165
|
-
const mtDefault = meta.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
166
|
-
const mtRaw = await ctx.ui.input(`${header()}Max output tokens:`, String(mtDefault));
|
|
167
|
-
if (mtRaw === undefined) return;
|
|
168
|
-
const maxTokens = parsePositiveInt(mtRaw, mtDefault);
|
|
169
|
-
crumbs.push(`Max output tokens: ${maxTokens}`);
|
|
170
|
-
|
|
171
|
-
const reasoning = await selectYesNo(
|
|
172
|
-
ctx,
|
|
173
|
-
`${header()}Reasoning model?`,
|
|
174
|
-
Boolean(meta.reasoning),
|
|
175
|
-
);
|
|
176
|
-
if (reasoning === undefined) return;
|
|
177
|
-
crumbs.push(`Reasoning: ${reasoning ? "yes" : "no"}`);
|
|
178
|
-
|
|
179
|
-
const input = await selectInput(ctx, `${header()}Input modalities:`, meta.input ?? ["text"]);
|
|
180
|
-
if (input === undefined) return;
|
|
181
|
-
crumbs.push(`Input: ${input.join("+")}`);
|
|
182
|
-
|
|
183
|
-
const effortDraft = await editModelEfforts(ctx, `${providerName}/${modelId}`, reasoning, meta.effortLevels);
|
|
184
|
-
if (!effortDraft) return;
|
|
185
|
-
|
|
186
|
-
const targetFile = selectedWriteTarget();
|
|
187
|
-
const ok = await ctx.ui.confirm(
|
|
188
|
-
`${header()}Add this model to "${providerName}"?`,
|
|
189
|
-
`Write the entry to ${targetFile}?`,
|
|
190
|
-
);
|
|
191
|
-
if (!ok) {
|
|
192
|
-
ctx.ui.notify("Cancelled. Nothing written.", "info");
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const freshEffective = loadConfig(ctx);
|
|
197
|
-
const freshTarget = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
|
|
198
|
-
if (!freshEffective || !freshTarget) return;
|
|
199
|
-
if (!freshEffective.providers?.[providerName]) {
|
|
200
|
-
ctx.ui.notify(`Provider "${providerName}" no longer exists.`, "error");
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
if (configContainsModel(freshEffective, providerName, modelId)) {
|
|
204
|
-
ctx.ui.notify(`Model "${modelId}" is already configured in ${providerName}.`, "warning");
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
if (!saveModelEfforts(ctx, `${providerName}/${modelId}`, effortDraft)) return;
|
|
208
|
-
ensureProviderSettings(freshTarget, freshEffective, providerName);
|
|
209
|
-
upsertModel(freshTarget, providerName, {
|
|
210
|
-
id: modelId,
|
|
211
|
-
name,
|
|
212
|
-
reasoning,
|
|
213
|
-
input,
|
|
214
|
-
contextWindow,
|
|
215
|
-
maxTokens,
|
|
216
|
-
});
|
|
217
|
-
saveConfig(freshTarget, targetFile);
|
|
218
|
-
ctx.ui.notify(`Added "${modelId}" to ${providerName} in ${targetFile}.`, "info");
|
|
219
|
-
if (onAdded && await ctx.ui.confirm("Set as Pi default?", "Configure this model and effort as the default for new sessions?")) await onAdded(providerName, modelId);
|
|
113
|
+
try { ids = await listEndpointModels(base, key); }
|
|
114
|
+
catch (error) { ctx.ui.notify(`Failed to list models: ${error}`, "error"); return; }
|
|
115
|
+
const existing = new Set((entry.models ?? []).map(model => model.id));
|
|
116
|
+
const candidates = ids.filter(id => !existing.has(id)).sort();
|
|
117
|
+
if (!candidates.length) { ctx.ui.notify("All endpoint models are already configured", "info"); return; }
|
|
118
|
+
const id = await tableSelect(ctx, { title: `Add model to ${provider}`, search: true, rows: candidates.map(value => ({ value, label: value })) });
|
|
119
|
+
if (id === undefined) return;
|
|
120
|
+
const meta = await fetchModelMeta(base, key, id);
|
|
121
|
+
const saved = await modelForm(ctx, provider, { id, name: id, contextWindow: meta.contextWindow ?? DEFAULT_CONTEXT_WINDOW, maxTokens: meta.maxTokens ?? DEFAULT_MAX_TOKENS, reasoning: Boolean(meta.reasoning), input: meta.input ?? ["text"] }, meta, true);
|
|
122
|
+
if (saved && onAdded && await confirmAction(ctx, { title: "Set as Pi default?", lines: [`Configure ${provider}/${id} for new sessions?`], confirmLabel: "Configure default" })) await onAdded(provider, id);
|
|
220
123
|
}
|
|
221
|
-
|
|
222
|
-
// ----- update --------------------------------------------------------------
|
|
223
|
-
|
|
224
|
-
interface Field {
|
|
225
|
-
key: string;
|
|
226
|
-
label: string;
|
|
227
|
-
current: string;
|
|
228
|
-
suggest?: string;
|
|
229
|
-
edit: (header: () => string) => Promise<unknown | undefined>;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export async function updateFlow(
|
|
233
|
-
ctx: FlowCtx,
|
|
234
|
-
preselect?: { provider: string; modelId: string },
|
|
235
|
-
): Promise<void> {
|
|
124
|
+
export async function updateFlow(ctx: FlowCtx, preselect?: { provider: string; modelId: string }): Promise<void> {
|
|
236
125
|
const config = loadConfig(ctx);
|
|
237
126
|
if (!config) return;
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
providerName = preselect.provider;
|
|
245
|
-
existing = providers[preselect.provider]!.models!.find((m) => m.id === preselect.modelId)!;
|
|
246
|
-
} else {
|
|
247
|
-
const map = new Map<string, { provider: string; model: ModelEntry }>();
|
|
248
|
-
for (const p of Object.keys(providers)) {
|
|
249
|
-
for (const m of providers[p]!.models ?? []) {
|
|
250
|
-
const label = `${m.name ?? m.id} [${p}] (ctx: ${m.contextWindow ?? "?"}, max: ${m.maxTokens ?? "?"}, reasoning: ${m.reasoning ? "yes" : "no"})`;
|
|
251
|
-
map.set(label, { provider: p, model: m });
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
if (map.size === 0) {
|
|
255
|
-
ctx.ui.notify("No models configured to update.", "info");
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
const label = await ctx.ui.select("Update which model?", [...map.keys()]);
|
|
259
|
-
if (label === undefined) return;
|
|
260
|
-
const hit = map.get(label)!;
|
|
261
|
-
providerName = hit.provider;
|
|
262
|
-
existing = hit.model;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Try to refresh metadata from the provider.
|
|
266
|
-
const ep = providerEndpoint(config, providerName);
|
|
267
|
-
let meta: ModelMeta = {};
|
|
268
|
-
if (ep) {
|
|
269
|
-
meta = await fetchModelMeta(ep.base, ep.key, existing.id);
|
|
270
|
-
const diffs: string[] = [];
|
|
271
|
-
if (meta.contextWindow !== undefined && meta.contextWindow !== existing.contextWindow)
|
|
272
|
-
diffs.push(`ctx: ${existing.contextWindow ?? "?"} → ${meta.contextWindow}`);
|
|
273
|
-
if (meta.maxTokens !== undefined && meta.maxTokens !== existing.maxTokens)
|
|
274
|
-
diffs.push(`max: ${existing.maxTokens ?? "?"} → ${meta.maxTokens}`);
|
|
275
|
-
if (meta.reasoning !== undefined && meta.reasoning !== (existing.reasoning ?? false))
|
|
276
|
-
diffs.push(`reasoning: ${existing.reasoning ?? false} → ${meta.reasoning}`);
|
|
277
|
-
if (meta.input !== undefined && meta.input.join("+") !== (existing.input ?? ["text"]).join("+"))
|
|
278
|
-
diffs.push(`input: ${(existing.input ?? ["text"]).join("+")} → ${meta.input.join("+")}`);
|
|
279
|
-
ctx.ui.notify(
|
|
280
|
-
diffs.length > 0
|
|
281
|
-
? `Provider suggests updates: ${diffs.join(", ")}`
|
|
282
|
-
: "Provider info matches current config — no changes suggested.",
|
|
283
|
-
"info",
|
|
284
|
-
);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
let effortDraft: EffortDraft | undefined;
|
|
288
|
-
const fields: Field[] = [
|
|
289
|
-
{
|
|
290
|
-
key: "bmsEfforts",
|
|
291
|
-
label: "Available / preferred efforts",
|
|
292
|
-
current: "edit capability levels and Pi per-model preference",
|
|
293
|
-
suggest: meta.effortLevels?.join(", "),
|
|
294
|
-
edit: async () => {
|
|
295
|
-
const draft = await editModelEfforts(ctx, `${providerName}/${existing.id}`, Boolean(changes.get("reasoning") ?? existing.reasoning), meta.effortLevels);
|
|
296
|
-
if (!draft) return;
|
|
297
|
-
effortDraft = draft;
|
|
298
|
-
return `${draft.capability.levels.join(", ")}; preferred ${draft.preferred ?? "automatic"}`;
|
|
299
|
-
},
|
|
300
|
-
},
|
|
301
|
-
{
|
|
302
|
-
key: "name",
|
|
303
|
-
label: "Display name",
|
|
304
|
-
current: existing.name ?? existing.id,
|
|
305
|
-
edit: async (header) => {
|
|
306
|
-
const raw = await ctx.ui.input(`${header()}Display name:`, existing.name ?? existing.id);
|
|
307
|
-
return raw === undefined ? undefined : raw || existing.id;
|
|
308
|
-
},
|
|
309
|
-
},
|
|
310
|
-
{
|
|
311
|
-
key: "contextWindow",
|
|
312
|
-
label: "Context window",
|
|
313
|
-
current: String(existing.contextWindow ?? DEFAULT_CONTEXT_WINDOW),
|
|
314
|
-
suggest: meta.contextWindow !== undefined ? String(meta.contextWindow) : undefined,
|
|
315
|
-
edit: async (header) => {
|
|
316
|
-
const cur = existing.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
317
|
-
const def = meta.contextWindow ?? cur;
|
|
318
|
-
const hint =
|
|
319
|
-
meta.contextWindow !== undefined && meta.contextWindow !== cur
|
|
320
|
-
? ` (provider: ${meta.contextWindow}, current: ${cur})`
|
|
321
|
-
: "";
|
|
322
|
-
const raw = await ctx.ui.input(`${header()}Context window${hint}:`, String(def));
|
|
323
|
-
return raw === undefined ? undefined : parsePositiveInt(raw, def);
|
|
324
|
-
},
|
|
325
|
-
},
|
|
326
|
-
{
|
|
327
|
-
key: "maxTokens",
|
|
328
|
-
label: "Max output tokens",
|
|
329
|
-
current: String(existing.maxTokens ?? DEFAULT_MAX_TOKENS),
|
|
330
|
-
suggest: meta.maxTokens !== undefined ? String(meta.maxTokens) : undefined,
|
|
331
|
-
edit: async (header) => {
|
|
332
|
-
const cur = existing.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
333
|
-
const def = meta.maxTokens ?? cur;
|
|
334
|
-
const hint =
|
|
335
|
-
meta.maxTokens !== undefined && meta.maxTokens !== cur
|
|
336
|
-
? ` (provider: ${meta.maxTokens}, current: ${cur})`
|
|
337
|
-
: "";
|
|
338
|
-
const raw = await ctx.ui.input(`${header()}Max output tokens${hint}:`, String(def));
|
|
339
|
-
return raw === undefined ? undefined : parsePositiveInt(raw, def);
|
|
340
|
-
},
|
|
341
|
-
},
|
|
342
|
-
{
|
|
343
|
-
key: "reasoning",
|
|
344
|
-
label: "Reasoning model",
|
|
345
|
-
current: existing.reasoning ? "yes" : "no",
|
|
346
|
-
suggest: meta.reasoning !== undefined ? (meta.reasoning ? "yes" : "no") : undefined,
|
|
347
|
-
edit: async (header) =>
|
|
348
|
-
selectYesNo(
|
|
349
|
-
ctx,
|
|
350
|
-
`${header()}Reasoning model?`,
|
|
351
|
-
meta.reasoning ?? existing.reasoning ?? false,
|
|
352
|
-
),
|
|
353
|
-
},
|
|
354
|
-
{
|
|
355
|
-
key: "input",
|
|
356
|
-
label: "Input modalities",
|
|
357
|
-
current: (existing.input ?? ["text"]).join("+"),
|
|
358
|
-
suggest: meta.input !== undefined ? meta.input.join("+") : undefined,
|
|
359
|
-
edit: async (header) =>
|
|
360
|
-
selectInput(
|
|
361
|
-
ctx,
|
|
362
|
-
`${header()}Input modalities:`,
|
|
363
|
-
meta.input ?? existing.input ?? ["text"],
|
|
364
|
-
),
|
|
365
|
-
},
|
|
366
|
-
];
|
|
367
|
-
|
|
368
|
-
const changes = new Map<string, unknown>();
|
|
369
|
-
for (;;) {
|
|
370
|
-
const options = fields.map(
|
|
371
|
-
(f) =>
|
|
372
|
-
`${changes.has(f.key) ? "•" : "·"} ${f.label}: ${f.current}${
|
|
373
|
-
f.suggest !== undefined && f.suggest !== f.current ? ` → provider: ${f.suggest}` : ""
|
|
374
|
-
}${changes.has(f.key) ? ` [changed: ${String(changes.get(f.key))}]` : ""}`,
|
|
375
|
-
);
|
|
376
|
-
const pick = await ctx.ui.select(
|
|
377
|
-
`Update which attribute of ${existing.id} [${providerName}]?\n(• = will be written, ESC to finish)`,
|
|
378
|
-
[...options, "Done — write changes"],
|
|
379
|
-
);
|
|
380
|
-
if (pick === undefined || pick.startsWith("Done")) break;
|
|
381
|
-
const idx = options.indexOf(pick);
|
|
382
|
-
const field = fields[idx];
|
|
383
|
-
if (!field) continue;
|
|
384
|
-
const value = await field.edit(() => `Model: ${existing.id} [${providerName}]\n`);
|
|
385
|
-
if (value === undefined) continue; // ESC in the field editor — back to list
|
|
386
|
-
changes.set(field.key, value);
|
|
387
|
-
if (field.key === "reasoning") {
|
|
388
|
-
effortDraft = undefined;
|
|
389
|
-
changes.delete("bmsEfforts");
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (changes.size === 0) {
|
|
394
|
-
ctx.ui.notify("No changes made.", "info");
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
const targetFile = selectedWriteTarget();
|
|
399
|
-
const ok = await ctx.ui.confirm(
|
|
400
|
-
`Update ${existing.id}?`,
|
|
401
|
-
`Write ${[...changes.keys()].join(", ")} to "${providerName}" in ${targetFile}?`,
|
|
402
|
-
);
|
|
403
|
-
if (!ok) {
|
|
404
|
-
ctx.ui.notify("Cancelled. Nothing updated.", "info");
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
const freshEffective = loadConfig(ctx);
|
|
409
|
-
const freshTarget = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
|
|
410
|
-
if (!freshEffective || !freshTarget) return;
|
|
411
|
-
const modelEntry = completeModel(freshEffective, providerName, existing.id);
|
|
412
|
-
if (!modelEntry) {
|
|
413
|
-
ctx.ui.notify(`Model "${existing.id}" no longer exists in ${providerName}.`, "error");
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
if (changes.has("reasoning") && !effortDraft) {
|
|
417
|
-
effortDraft = await editModelEfforts(ctx, `${providerName}/${existing.id}`, Boolean(changes.get("reasoning")), meta.effortLevels);
|
|
418
|
-
if (!effortDraft) return;
|
|
419
|
-
}
|
|
420
|
-
if (effortDraft && !saveModelEfforts(ctx, `${providerName}/${existing.id}`, effortDraft)) return;
|
|
421
|
-
Object.assign(modelEntry, Object.fromEntries([...changes].filter(([key]) => key !== "bmsEfforts")));
|
|
422
|
-
ensureProviderSettings(freshTarget, freshEffective, providerName);
|
|
423
|
-
upsertModel(freshTarget, providerName, modelEntry);
|
|
424
|
-
saveConfig(freshTarget, targetFile);
|
|
425
|
-
ctx.ui.notify(
|
|
426
|
-
`Updated "${existing.id}" in ${providerName} via ${targetFile}: ${[...changes.entries()]
|
|
427
|
-
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join("+") : String(v)}`)
|
|
428
|
-
.join(", ")}.`,
|
|
429
|
-
"info",
|
|
430
|
-
);
|
|
127
|
+
const found = preselect && config.providers?.[preselect.provider]?.models?.find(model => model.id === preselect.modelId);
|
|
128
|
+
const hit = found ? { provider: preselect!.provider, model: found } : await pickModel(ctx, config, "Update model");
|
|
129
|
+
if (!hit) return;
|
|
130
|
+
const endpoint = providerEndpoint(config, hit.provider);
|
|
131
|
+
const meta = endpoint ? await fetchModelMeta(endpoint.base, endpoint.key, hit.model.id) : {};
|
|
132
|
+
await modelForm(ctx, hit.provider, hit.model, meta, false);
|
|
431
133
|
}
|
|
432
|
-
|
|
433
|
-
// ----- delete --------------------------------------------------------------
|
|
434
|
-
|
|
435
|
-
export async function deleteFlow(
|
|
436
|
-
ctx: FlowCtx,
|
|
437
|
-
preselect?: { provider: string; modelId: string },
|
|
438
|
-
): Promise<void> {
|
|
134
|
+
export async function deleteFlow(ctx: FlowCtx, preselect?: { provider: string; modelId: string }): Promise<void> {
|
|
439
135
|
const config = loadConfig(ctx);
|
|
440
136
|
if (!config) return;
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (preselect && providers[preselect.provider]?.models?.some((m) => m.id === preselect.modelId)) {
|
|
447
|
-
provider = preselect.provider;
|
|
448
|
-
id = preselect.modelId;
|
|
449
|
-
} else {
|
|
450
|
-
const map = new Map<string, { provider: string; id: string }>();
|
|
451
|
-
for (const p of Object.keys(providers)) {
|
|
452
|
-
for (const m of providers[p]!.models ?? []) {
|
|
453
|
-
map.set(`${m.id} [${p}]`, { provider: p, id: m.id });
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
if (map.size === 0) {
|
|
457
|
-
ctx.ui.notify("No models configured to delete.", "info");
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
const label = await ctx.ui.select("Delete which model?", [...map.keys()]);
|
|
461
|
-
if (label === undefined) return;
|
|
462
|
-
({ provider, id } = map.get(label)!);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
const settings = loadSettings();
|
|
466
|
-
const targetFile = writeTarget(settings);
|
|
137
|
+
const found = preselect && config.providers?.[preselect.provider]?.models?.find(model => model.id === preselect.modelId);
|
|
138
|
+
const hit = found ? { provider: preselect!.provider, model: found } : await pickModel(ctx, config, "Delete model");
|
|
139
|
+
if (!hit) return;
|
|
140
|
+
const { provider, model: { id } } = hit;
|
|
141
|
+
const target = writeTarget(loadSettings());
|
|
467
142
|
const base = loadConfigFile(ctx, "models.json");
|
|
468
143
|
if (!base) return;
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const inherited = configContainsModel(base, provider, id);
|
|
474
|
-
const local = configContainsModel(overlay, provider, id);
|
|
475
|
-
if (!inherited && !local) {
|
|
476
|
-
ctx.ui.notify(`Model "${id}" is no longer present in either config file.`, "warning");
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const ok = await ctx.ui.confirm(
|
|
481
|
-
`${inherited && !local ? "Hide inherited model" : "Delete"} ${id}?`,
|
|
482
|
-
inherited && !local
|
|
483
|
-
? "Keep models.json unchanged and add a BMS tombstone to models.bms.json?"
|
|
484
|
-
: `Remove it from "${provider}" in models.bms.json?`,
|
|
485
|
-
);
|
|
486
|
-
if (!ok) {
|
|
487
|
-
ctx.ui.notify("Cancelled. Nothing deleted.", "info");
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
if (inherited) tombstoneModel(overlay, provider, id);
|
|
492
|
-
else {
|
|
493
|
-
const target = overlay.providers?.[provider];
|
|
494
|
-
if (target?.models) target.models = target.models.filter((model) => model.id !== id);
|
|
495
|
-
}
|
|
496
|
-
saveConfig(overlay, "models.bms.json");
|
|
497
|
-
ctx.ui.notify(
|
|
498
|
-
inherited
|
|
499
|
-
? `Hidden inherited model "${id}" via models.bms.json.`
|
|
500
|
-
: `Deleted "${id}" from ${provider} in models.bms.json.`,
|
|
501
|
-
"info",
|
|
502
|
-
);
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
if (!configContainsModel(base, provider, id)) {
|
|
507
|
-
ctx.ui.notify(`Model "${id}" is no longer present in models.json.`, "warning");
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
const ok = await ctx.ui.confirm(
|
|
511
|
-
`Delete ${id}?`,
|
|
512
|
-
`Remove it from "${provider}" in ${targetFile}?`,
|
|
513
|
-
);
|
|
514
|
-
if (!ok) {
|
|
515
|
-
ctx.ui.notify("Cancelled. Nothing deleted.", "info");
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const fresh = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
|
|
144
|
+
const inherited = target === "models.bms.json" && configContainsModel(base, provider, id);
|
|
145
|
+
if (!(await confirmAction(ctx, { title: `${inherited ? "Hide" : "Delete"} ${provider}/${id}?`, lines: [inherited ? "Keep models.json unchanged; save a tombstone." : "Remove this model entry.", `Scope: ${target}`], confirmLabel: inherited ? "Hide model" : "Delete model" }))) return;
|
|
146
|
+
// Re-read after confirmation so another session's changes survive.
|
|
147
|
+
const fresh = loadConfigFile(ctx, target, target === "models.bms.json");
|
|
520
148
|
if (!fresh) return;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
}
|
|
149
|
+
if (inherited) tombstoneModel(fresh, provider, id);
|
|
150
|
+
else {
|
|
151
|
+
const entry = fresh.providers?.[provider];
|
|
152
|
+
if (!entry?.models?.some(model => model.id === id)) { ctx.ui.notify("Model no longer exists in the selected scope.", "warning"); return; }
|
|
153
|
+
entry.models = entry.models.filter(model => model.id !== id);
|
|
154
|
+
}
|
|
155
|
+
saveConfig(fresh, target);
|
|
156
|
+
ctx.ui.notify(`${inherited ? "Hidden" : "Deleted"} ${provider}/${id} via ${target}.`, "info");
|
|
527
157
|
}
|