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