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/meta.ts
CHANGED
|
@@ -5,11 +5,20 @@
|
|
|
5
5
|
// endpoint yields nothing and the caller falls back to explicit defaults.
|
|
6
6
|
|
|
7
7
|
import type { ModelsConfig } from "./store.ts";
|
|
8
|
+
import { normalizeEfforts, type Effort } from "./defaults-store.ts";
|
|
9
|
+
|
|
10
|
+
/** Only explicit enumerations count. supported_parameters alone is not an effort list. */
|
|
11
|
+
export function metadataEfforts(metadata: Record<string, any>): Effort[] | undefined {
|
|
12
|
+
return normalizeEfforts(metadata.supported_reasoning_efforts)
|
|
13
|
+
?? normalizeEfforts(metadata.supported_thinking_levels)
|
|
14
|
+
?? normalizeEfforts(metadata.reasoning_efforts);
|
|
15
|
+
}
|
|
8
16
|
|
|
9
17
|
export const DEFAULT_CONTEXT_WINDOW = 128000;
|
|
10
18
|
export const DEFAULT_MAX_TOKENS = 32768;
|
|
11
19
|
|
|
12
20
|
export interface ModelMeta {
|
|
21
|
+
effortLevels?: Effort[];
|
|
13
22
|
contextWindow?: number;
|
|
14
23
|
maxTokens?: number;
|
|
15
24
|
reasoning?: boolean;
|
|
@@ -55,6 +64,7 @@ export async function fetchModelMeta(
|
|
|
55
64
|
key: string,
|
|
56
65
|
modelId: string,
|
|
57
66
|
): Promise<ModelMeta> {
|
|
67
|
+
let effortLevels: Effort[] | undefined;
|
|
58
68
|
let metaContext: number | undefined;
|
|
59
69
|
let metaMaxTokens: number | undefined;
|
|
60
70
|
let metaReasoning: boolean | undefined;
|
|
@@ -87,6 +97,8 @@ export async function fetchModelMeta(
|
|
|
87
97
|
);
|
|
88
98
|
if (match?.model_info) {
|
|
89
99
|
const mi = match.model_info as Record<string, any>;
|
|
100
|
+
effortLevels = metadataEfforts(mi);
|
|
101
|
+
if (typeof mi.supports_reasoning === "boolean") metaReasoning = mi.supports_reasoning;
|
|
90
102
|
metaContext = mi.max_input_tokens ?? mi.max_tokens;
|
|
91
103
|
// Only trust an explicit output cap — LiteLLM's generic `max_tokens`
|
|
92
104
|
// is often a default (e.g. 4096/128000), not the model's real cap.
|
|
@@ -106,8 +118,8 @@ export async function fetchModelMeta(
|
|
|
106
118
|
// Not a LiteLLM proxy or endpoint unavailable — ignore.
|
|
107
119
|
}
|
|
108
120
|
|
|
109
|
-
// 2) OpenAI-style per-model GET
|
|
110
|
-
if (metaContext === undefined) {
|
|
121
|
+
// 2) OpenAI-style per-model GET, also used when token metadata lacks effort information.
|
|
122
|
+
if (metaContext === undefined || effortLevels === undefined) {
|
|
111
123
|
try {
|
|
112
124
|
const modelRes = await fetch(`${base}/models/${encodeURIComponent(modelId)}`, {
|
|
113
125
|
headers: authHeaders,
|
|
@@ -117,9 +129,10 @@ export async function fetchModelMeta(
|
|
|
117
129
|
const b = (await modelRes.json()) as Record<string, any>;
|
|
118
130
|
// OpenAI /v1/models/{id} does not return token limits, but some proxies
|
|
119
131
|
// add custom fields like max_context_length or max_model_tokens.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
132
|
+
effortLevels ??= metadataEfforts(b);
|
|
133
|
+
if (typeof b.reasoning === "boolean") metaReasoning ??= b.reasoning;
|
|
134
|
+
metaContext ??= b.max_context_length ?? b.max_input_tokens ?? b.context_window ?? b.max_tokens;
|
|
135
|
+
metaMaxTokens ??= b.max_output_tokens ?? b.max_completion_tokens;
|
|
123
136
|
}
|
|
124
137
|
} catch {
|
|
125
138
|
// Endpoint not supported — ignore.
|
|
@@ -140,7 +153,7 @@ export async function fetchModelMeta(
|
|
|
140
153
|
if (!entry) continue;
|
|
141
154
|
metaContext = entry.max_input_tokens ?? entry.max_tokens;
|
|
142
155
|
metaMaxTokens = entry.max_output_tokens ?? entry.max_tokens;
|
|
143
|
-
metaReasoning
|
|
156
|
+
metaReasoning ??=
|
|
144
157
|
entry.mode === "reasoning" || entry.reasoning === true || heuristicReasoning;
|
|
145
158
|
if (entry.input_cost_per_token > 0 || entry.output_cost_per_token > 0) {
|
|
146
159
|
metaInput = entry.supports_vision ? ["text", "image"] : ["text"];
|
|
@@ -165,7 +178,8 @@ export async function fetchModelMeta(
|
|
|
165
178
|
return {
|
|
166
179
|
contextWindow: metaContext,
|
|
167
180
|
maxTokens: metaMaxTokens,
|
|
168
|
-
reasoning: metaReasoning,
|
|
181
|
+
reasoning: effortLevels?.some(level => level !== "off") ? true : metaReasoning,
|
|
182
|
+
effortLevels,
|
|
169
183
|
input: metaInput,
|
|
170
184
|
};
|
|
171
185
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ModelPicker, type PickerAction, type PickerOptions, type RankedModel } from "./picker.ts";
|
|
2
|
+
import { resultPlacement } from "./tui.ts";
|
|
3
|
+
|
|
4
|
+
export async function pickerDialog(
|
|
5
|
+
ctx: any, rows: RankedModel[], title = "Better Model Select", assignment = false,
|
|
6
|
+
options: PickerOptions = {},
|
|
7
|
+
): Promise<PickerAction | undefined> {
|
|
8
|
+
// Return the Focusable itself. Pi's custom UI installs it in the prompt area,
|
|
9
|
+
// calls setFocus on it and restores the previous editor when done is called.
|
|
10
|
+
return ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) =>
|
|
11
|
+
new ModelPicker(rows, theme, () => tui.requestRender(), done, assignment,
|
|
12
|
+
{ ...options, placement: options.placement ?? resultPlacement() }, title));
|
|
13
|
+
}
|
package/extensions/lib/picker.ts
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
|
-
// The /bms picker
|
|
2
|
-
//
|
|
3
|
-
// Typing always goes to the search box, so plain letters can never be stolen
|
|
4
|
-
// as action shortcuts. Actions are therefore ctrl-chords, which stay reachable
|
|
5
|
-
// while the cursor sits in the search field.
|
|
1
|
+
// The /bms prompt-area picker. Typing goes to search; ctrl-chords perform
|
|
2
|
+
// actions. The action menu shares this dialog and never replaces its input.
|
|
6
3
|
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
fuzzyFilter,
|
|
10
|
-
Input,
|
|
11
|
-
matchesKey,
|
|
12
|
-
SelectList,
|
|
13
|
-
type Focusable,
|
|
14
|
-
type SelectItem,
|
|
15
|
-
type SelectListTheme,
|
|
16
|
-
Text,
|
|
17
|
-
} from "@earendil-works/pi-tui";
|
|
4
|
+
import { matchesKey, type Focusable, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui";
|
|
5
|
+
import { TableSelect, type Placement, type SelectionState, type TableColumn } from "./tui.ts";
|
|
18
6
|
import { loadUsage, usageKey, type ModelsConfig } from "./store.ts";
|
|
19
7
|
|
|
20
8
|
export type PickerAction =
|
|
21
9
|
| { kind: "load"; provider: string; modelId: string }
|
|
10
|
+
| { kind: "set-default"; provider: string; modelId: string }
|
|
11
|
+
| { kind: "defaults" }
|
|
22
12
|
| { kind: "add" }
|
|
23
13
|
| { kind: "update"; provider?: string; modelId?: string }
|
|
24
14
|
| { kind: "delete"; provider?: string; modelId?: string }
|
|
@@ -34,6 +24,8 @@ export interface RankedModel {
|
|
|
34
24
|
lastUsed: number;
|
|
35
25
|
contextWindow?: number;
|
|
36
26
|
reasoning: boolean;
|
|
27
|
+
current?: boolean;
|
|
28
|
+
default?: boolean;
|
|
37
29
|
}
|
|
38
30
|
|
|
39
31
|
/**
|
|
@@ -77,6 +69,8 @@ function formatContext(n?: number): string {
|
|
|
77
69
|
|
|
78
70
|
export function toSelectItem(row: RankedModel): SelectItem {
|
|
79
71
|
const bits: string[] = [row.modelId, `[${row.provider}]`];
|
|
72
|
+
if (row.current) bits.push("[current]");
|
|
73
|
+
if (row.default) bits.push("[default]");
|
|
80
74
|
const ctxLabel = formatContext(row.contextWindow);
|
|
81
75
|
if (ctxLabel) bits.push(`ctx ${ctxLabel}`);
|
|
82
76
|
if (row.reasoning) bits.push("reasoning");
|
|
@@ -108,161 +102,110 @@ export function selectListTheme(theme: any): SelectListTheme {
|
|
|
108
102
|
};
|
|
109
103
|
}
|
|
110
104
|
|
|
111
|
-
const HINT =
|
|
112
|
-
"↑↓ move • enter load • ctrl+a add • ctrl+u update • ctrl+x delete • ctrl+o options • ctrl+r reset • esc close";
|
|
105
|
+
const HINT = "↑↓ move · enter load · ctrl+a add · ctrl+o actions · esc close";
|
|
113
106
|
|
|
114
|
-
export
|
|
115
|
-
|
|
116
|
-
|
|
107
|
+
export interface PickerOptions {
|
|
108
|
+
placement?: Placement;
|
|
109
|
+
/** Pass the same state object when reopening after an action. Updated in place. */
|
|
110
|
+
state?: SelectionState & { menuValue?: string };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const MODEL_COLUMNS: TableColumn[] = [
|
|
114
|
+
{ key: "model", label: "MODEL" }, { key: "provider", label: "PROVIDER", width: 14 },
|
|
115
|
+
{ key: "context", label: "CONTEXT", width: 7 }, { key: "uses", label: "USES", width: 5 },
|
|
116
|
+
{ key: "status", label: "STATUS", width: 17 },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
export class ModelPicker implements Focusable {
|
|
120
|
+
private table: TableSelect;
|
|
121
|
+
private menu?: TableSelect;
|
|
117
122
|
private rows: RankedModel[];
|
|
118
|
-
private filtered: RankedModel[];
|
|
119
|
-
private hintText: Text;
|
|
120
|
-
private countText: Text;
|
|
121
|
-
// Plain fields rather than constructor parameter properties, so the source
|
|
122
|
-
// also loads under type-stripping runtimes that reject that syntax.
|
|
123
123
|
private theme: any;
|
|
124
|
-
private
|
|
124
|
+
private changed: () => void;
|
|
125
125
|
private done: (action: PickerAction) => void;
|
|
126
|
-
|
|
126
|
+
private assignment: boolean;
|
|
127
|
+
private options: PickerOptions;
|
|
127
128
|
private _focused = false;
|
|
128
|
-
get focused(): boolean {
|
|
129
|
-
return this._focused;
|
|
130
|
-
}
|
|
131
|
-
// Propagate focus to the Input so the hardware cursor lands correctly for IME.
|
|
129
|
+
get focused(): boolean { return this._focused; }
|
|
132
130
|
set focused(value: boolean) {
|
|
133
131
|
this._focused = value;
|
|
134
|
-
this.
|
|
132
|
+
this.table.focused = value && !this.menu;
|
|
133
|
+
if (this.menu) this.menu.focused = value;
|
|
135
134
|
}
|
|
136
135
|
|
|
137
|
-
constructor(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
done: (action: PickerAction) => void,
|
|
142
|
-
) {
|
|
143
|
-
super();
|
|
136
|
+
constructor(rows: RankedModel[], theme: any, changed: () => void,
|
|
137
|
+
done: (action: PickerAction) => void, assignment = false, options: PickerOptions = {},
|
|
138
|
+
title = "Better Model Select") {
|
|
139
|
+
this.rows = rows;
|
|
144
140
|
this.theme = theme;
|
|
145
|
-
this.
|
|
141
|
+
this.changed = changed;
|
|
146
142
|
this.done = done;
|
|
147
|
-
this.
|
|
148
|
-
this.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
this.list = this.buildList(rows);
|
|
164
|
-
|
|
165
|
-
this.countText = new Text(fg("dim", this.countLabel()), 1, 0);
|
|
166
|
-
this.hintText = new Text(fg("dim", HINT), 1, 0);
|
|
167
|
-
|
|
168
|
-
this.addChild(this.search);
|
|
169
|
-
this.addChild(this.list);
|
|
170
|
-
this.addChild(this.countText);
|
|
171
|
-
this.addChild(this.hintText);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** SelectList has no public API to replace its items, so rebuild it. */
|
|
175
|
-
private buildList(rows: RankedModel[]): SelectList {
|
|
176
|
-
const list = new SelectList(
|
|
177
|
-
rows.map(toSelectItem),
|
|
178
|
-
Math.min(Math.max(rows.length, 1), 12),
|
|
179
|
-
selectListTheme(this.theme),
|
|
180
|
-
);
|
|
181
|
-
list.onSelect = () => this.loadSelected();
|
|
182
|
-
list.onCancel = () => this.done({ kind: "close" });
|
|
183
|
-
return list;
|
|
143
|
+
this.assignment = assignment;
|
|
144
|
+
this.options = options;
|
|
145
|
+
this.table = new TableSelect({ title, columns: MODEL_COLUMNS,
|
|
146
|
+
rows: rows.map(row => ({ value: toSelectItem(row).value, label: row.label,
|
|
147
|
+
// IDs remain searchable but are not repeated under the display name.
|
|
148
|
+
description: row.modelId,
|
|
149
|
+
cells: { model: row.label, provider: row.provider, context: formatContext(row.contextWindow),
|
|
150
|
+
uses: String(row.count), status: [row.current ? "current" : "", row.default ? "default" : ""].filter(Boolean).join(" · ") },
|
|
151
|
+
})), placement: options.placement,
|
|
152
|
+
hint: assignment ? "↑↓ move · enter assign · esc back" : HINT,
|
|
153
|
+
}, theme, () => { this.saveState(); changed(); }, value => {
|
|
154
|
+
this.saveState();
|
|
155
|
+
if (value === undefined) done({ kind: "close" });
|
|
156
|
+
else this.action("load");
|
|
157
|
+
}, options.state);
|
|
158
|
+
if (options.state?.menuValue) this.openMenu();
|
|
184
159
|
}
|
|
185
|
-
|
|
186
|
-
private
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
return shown === total ? `${total} models` : `${shown} of ${total} models`;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
private currentRow(): RankedModel | null {
|
|
193
|
-
const item = this.list.getSelectedItem();
|
|
194
|
-
if (!item) return null;
|
|
195
|
-
const decoded = decodeValue(item.value);
|
|
196
|
-
if (!decoded) return null;
|
|
197
|
-
return (
|
|
198
|
-
this.filtered.find(
|
|
199
|
-
(r) => r.provider === decoded.provider && r.modelId === decoded.modelId,
|
|
200
|
-
) ?? null
|
|
201
|
-
);
|
|
160
|
+
private saveState(): void { if (this.options.state) Object.assign(this.options.state, this.table.state); }
|
|
161
|
+
private currentRow(): RankedModel | undefined {
|
|
162
|
+
const value = this.table.currentItem()?.value;
|
|
163
|
+
return this.rows.find(row => toSelectItem(row).value === value);
|
|
202
164
|
}
|
|
203
|
-
|
|
204
|
-
|
|
165
|
+
private action(kind: PickerAction["kind"]): void {
|
|
166
|
+
this.saveState();
|
|
205
167
|
const row = this.currentRow();
|
|
206
|
-
if (
|
|
207
|
-
|
|
168
|
+
if (kind === "load" || kind === "set-default") {
|
|
169
|
+
if (row) this.done({ kind, provider: row.provider, modelId: row.modelId });
|
|
170
|
+
} else if (kind === "update" || kind === "delete") {
|
|
171
|
+
this.done({ kind, provider: row?.provider, modelId: row?.modelId });
|
|
172
|
+
} else this.done({ kind });
|
|
208
173
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
this.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
: (_role: string, t: string) => t;
|
|
232
|
-
this.countText.setText(fg("dim", this.countLabel()));
|
|
174
|
+
private openMenu(): void {
|
|
175
|
+
const row = this.currentRow();
|
|
176
|
+
const actions: { value: PickerAction["kind"]; label: string }[] = [
|
|
177
|
+
...(row ? [{ value: "load" as const, label: "Load selected model" },
|
|
178
|
+
{ value: "set-default" as const, label: "Set selected model as default" }] : []),
|
|
179
|
+
{ value: "add", label: "Add model" }, { value: "update", label: "Update model" },
|
|
180
|
+
{ value: "delete", label: "Delete model" }, { value: "defaults", label: "Model and agent defaults" },
|
|
181
|
+
{ value: "options", label: "Options" }, { value: "reset", label: "Reset usage ranking" },
|
|
182
|
+
];
|
|
183
|
+
this.menu = new TableSelect({ title: "Models / Actions", rows: actions, search: false, initialValue: this.options.state?.menuValue,
|
|
184
|
+
hint: "↑↓ move · enter choose · esc back" }, this.theme, this.changed, value => {
|
|
185
|
+
if (this.options.state) {
|
|
186
|
+
if (value === undefined) delete this.options.state.menuValue;
|
|
187
|
+
else this.options.state.menuValue = value;
|
|
188
|
+
}
|
|
189
|
+
this.menu = undefined;
|
|
190
|
+
this.focused = this._focused;
|
|
191
|
+
if (value !== undefined) this.action(value as PickerAction["kind"]);
|
|
192
|
+
this.changed();
|
|
193
|
+
});
|
|
194
|
+
this.focused = this._focused;
|
|
195
|
+
this.changed();
|
|
233
196
|
}
|
|
234
|
-
|
|
235
197
|
handleInput(data: string): void {
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (matchesKey(data, "ctrl+
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
if (matchesKey(data, "ctrl+x")) {
|
|
246
|
-
const row = this.currentRow();
|
|
247
|
-
return this.done({ kind: "delete", provider: row?.provider, modelId: row?.modelId });
|
|
198
|
+
if (this.menu) { this.menu.handleInput(data); return; }
|
|
199
|
+
const shortcuts: [string, PickerAction["kind"]][] = [
|
|
200
|
+
["ctrl+a", "add"], ["ctrl+u", "update"], ["ctrl+x", "delete"], ["ctrl+d", "set-default"],
|
|
201
|
+
["ctrl+g", "defaults"], ["ctrl+r", "reset"],
|
|
202
|
+
];
|
|
203
|
+
if (matchesKey(data, "ctrl+o")) { if (!this.assignment) this.openMenu(); return; }
|
|
204
|
+
for (const [key, kind] of shortcuts) {
|
|
205
|
+
if (matchesKey(data, key as any)) { if (!this.assignment) this.action(kind); return; }
|
|
248
206
|
}
|
|
249
|
-
|
|
250
|
-
// Navigation and submit belong to the list; everything else is search text.
|
|
251
|
-
if (
|
|
252
|
-
matchesKey(data, "up") ||
|
|
253
|
-
matchesKey(data, "down") ||
|
|
254
|
-
matchesKey(data, "pageUp") ||
|
|
255
|
-
matchesKey(data, "pageDown") ||
|
|
256
|
-
matchesKey(data, "enter")
|
|
257
|
-
) {
|
|
258
|
-
this.list.handleInput(data);
|
|
259
|
-
this.onChange();
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const before = this.search.getValue();
|
|
264
|
-
this.search.handleInput(data);
|
|
265
|
-
if (this.search.getValue() !== before) this.applyFilter();
|
|
266
|
-
this.onChange();
|
|
207
|
+
this.table.handleInput(data);
|
|
267
208
|
}
|
|
209
|
+
render(width: number): string[] { return (this.menu ?? this.table).render(width); }
|
|
210
|
+
invalidate(): void { this.table.invalidate(); this.menu?.invalidate(); }
|
|
268
211
|
}
|
package/extensions/lib/store.ts
CHANGED
|
@@ -20,12 +20,14 @@ export const USAGE_PATH = (): string => join(getAgentDir(), "bms-usage.json");
|
|
|
20
20
|
export interface BmsSettings {
|
|
21
21
|
version: 1;
|
|
22
22
|
writeMode: WriteMode;
|
|
23
|
+
resultPlacement: "above" | "below";
|
|
23
24
|
disabledProviders: string[];
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const DEFAULT_SETTINGS: BmsSettings = {
|
|
27
28
|
version: 1,
|
|
28
29
|
writeMode: "overlay",
|
|
30
|
+
resultPlacement: "below",
|
|
29
31
|
disabledProviders: [],
|
|
30
32
|
};
|
|
31
33
|
|
|
@@ -72,6 +74,7 @@ export function loadSettings(): BmsSettings {
|
|
|
72
74
|
return {
|
|
73
75
|
version: 1,
|
|
74
76
|
writeMode: raw.writeMode === "direct" ? "direct" : "overlay",
|
|
77
|
+
resultPlacement: raw.resultPlacement === "above" ? "above" : "below",
|
|
75
78
|
disabledProviders: Array.isArray(raw.disabledProviders)
|
|
76
79
|
? raw.disabledProviders.filter((value): value is string => typeof value === "string")
|
|
77
80
|
: [],
|
|
@@ -146,7 +149,7 @@ export function mergeConfigs(base: ModelsConfig, local: ModelsConfig): ModelsCon
|
|
|
146
149
|
export function loadConfig(ctx: {
|
|
147
150
|
ui: { notify: (m: string, l: string) => void };
|
|
148
151
|
}): ModelsConfig | null {
|
|
149
|
-
const base = loadConfigFile(ctx, "models.json");
|
|
152
|
+
const base = loadConfigFile(ctx, "models.json", true);
|
|
150
153
|
if (!base) return null;
|
|
151
154
|
if (loadSettings().writeMode === "direct") return base;
|
|
152
155
|
migrateLegacyOverlay();
|