pi-better-model-select 0.1.3
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 +122 -0
- package/extensions/bms.ts +214 -0
- package/extensions/lib/flows.ts +503 -0
- package/extensions/lib/meta.ts +171 -0
- package/extensions/lib/picker.ts +261 -0
- package/extensions/lib/store.ts +205 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 msenebald
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# pi-better-model-select
|
|
2
|
+
|
|
3
|
+
A searchable, usage-ranked model picker for [pi](https://pi.dev).
|
|
4
|
+
|
|
5
|
+
`/bms` opens the effective model list from `models.json` plus the optional
|
|
6
|
+
`models.local.json` overlay, sorted by how often you pick each model. Start
|
|
7
|
+
typing to filter; press enter to switch the session to the highlighted model.
|
|
8
|
+
Add, update and delete live on ctrl-chords so plain keystrokes always go to the
|
|
9
|
+
search box.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:pi-better-model-select
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
If you installed the package under its old name, migrate with:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi remove npm:@msenebald/better-model-select
|
|
21
|
+
pi install npm:pi-better-model-select
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
| Command | What it does |
|
|
27
|
+
| ---------- | --------------------------------------------------- |
|
|
28
|
+
| `/bms` | Open the searchable, usage-ranked picker |
|
|
29
|
+
| `/bms-add` | Jump straight to the add-a-model flow |
|
|
30
|
+
|
|
31
|
+
## Keys
|
|
32
|
+
|
|
33
|
+
| Key | Action |
|
|
34
|
+
| ---------- | --------------------------------------------------- |
|
|
35
|
+
| `↑` `↓` | Move through the list |
|
|
36
|
+
| *type* | Fuzzy filter by display name, model id or provider |
|
|
37
|
+
| `enter` | Load the highlighted model |
|
|
38
|
+
| `ctrl+a` | Add a model |
|
|
39
|
+
| `ctrl+u` | Update the highlighted model |
|
|
40
|
+
| `ctrl+x` | Delete the highlighted model |
|
|
41
|
+
| `ctrl+r` | Reset usage statistics |
|
|
42
|
+
| `esc` | Close |
|
|
43
|
+
|
|
44
|
+
## Ranking
|
|
45
|
+
|
|
46
|
+
Every model loaded through `/bms` increments a counter in
|
|
47
|
+
`<agent-dir>/bms-usage.json`. The list sorts by count, then by most recent use,
|
|
48
|
+
then alphabetically — so an unused config starts in plain alphabetical order and
|
|
49
|
+
drifts toward your habits.
|
|
50
|
+
|
|
51
|
+
Only `/bms` selections count. Switching via `/model`, `ctrl+p` cycling, or a
|
|
52
|
+
session restore does not, which keeps the ranking a record of deliberate choices
|
|
53
|
+
rather than of how often you restart pi.
|
|
54
|
+
|
|
55
|
+
Stats for models that disappear from the effective configuration are pruned
|
|
56
|
+
when the picker opens. `ctrl+r` clears everything.
|
|
57
|
+
|
|
58
|
+
## Metadata discovery
|
|
59
|
+
|
|
60
|
+
When adding or updating, the extension tries to fill in context window, output
|
|
61
|
+
cap, reasoning support and input modalities from, in order:
|
|
62
|
+
|
|
63
|
+
1. `GET {baseUrl}/model/info` — LiteLLM proxies
|
|
64
|
+
2. `GET {baseUrl}/models/{id}` — OpenAI-style, plus proxy-specific fields
|
|
65
|
+
3. The public LiteLLM model cost map on GitHub
|
|
66
|
+
|
|
67
|
+
Each step is best-effort. A plain OpenAI-compatible endpoint usually yields
|
|
68
|
+
nothing from steps 1 and 2, and the flow falls back to name-based heuristics
|
|
69
|
+
with editable defaults. Before a write, BMS asks you to choose
|
|
70
|
+
`models.local.json` or `models.json`. The local file is listed first and acts as
|
|
71
|
+
a personal BMS overlay. The selected file is re-read immediately before the
|
|
72
|
+
write so a concurrent edit in another pi session is not clobbered.
|
|
73
|
+
|
|
74
|
+
## Paths
|
|
75
|
+
|
|
76
|
+
All files resolve through pi's `getAgentDir()`, so `PI_CODING_AGENT_DIR` and
|
|
77
|
+
rebranded distributions work without special handling:
|
|
78
|
+
|
|
79
|
+
- `<agent-dir>/models.json` is Pi's documented compatible-endpoint configuration.
|
|
80
|
+
- `<agent-dir>/models.local.json` is an optional personal overlay managed by BMS.
|
|
81
|
+
- `<agent-dir>/bms-usage.json` stores selection counters.
|
|
82
|
+
|
|
83
|
+
Pi does not currently document or load `models.local.json` itself. BMS merges it
|
|
84
|
+
over `models.json` by provider and model id, then registers the merged providers
|
|
85
|
+
through Pi's extension API at session start and whenever `/bms` opens. If the
|
|
86
|
+
same model exists in both files, fields from `models.local.json` win.
|
|
87
|
+
|
|
88
|
+
Deleting a model that exists only in `models.json` leaves that file untouched.
|
|
89
|
+
BMS writes `{ "id": "...", "bmsDeleted": true }` to the local overlay instead.
|
|
90
|
+
This BMS-owned tombstone keeps generated or externally managed model entries
|
|
91
|
+
hidden across restarts. Delete the tombstone from `models.local.json` to restore
|
|
92
|
+
the inherited model.
|
|
93
|
+
|
|
94
|
+
Writes are atomic using a temporary file and rename, so an interrupted write
|
|
95
|
+
cannot truncate either configuration file.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npm install
|
|
101
|
+
npm run typecheck
|
|
102
|
+
node --experimental-strip-types --test extensions/lib/picker.test.ts
|
|
103
|
+
pi -e . -p "say OK" # load the extension without installing it
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Publishing
|
|
107
|
+
|
|
108
|
+
Set the new version in `package.json` and `package-lock.json`, then run:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
make publish
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The target runs the full package gate, checks npm authentication, opens npm's
|
|
115
|
+
browser login when needed, skips the upload if that exact version already
|
|
116
|
+
exists, publishes the public scoped package, and waits for npm to expose the
|
|
117
|
+
new version. npm can take several minutes to process a package. If verification
|
|
118
|
+
times out, rerun `make verify-publish`; do not publish the same version again.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// pi-better-model-select — /bms
|
|
2
|
+
//
|
|
3
|
+
// A searchable model picker ranked by how often you actually pick each model,
|
|
4
|
+
// with add / update / delete wired to ctrl-chords so plain typing always goes
|
|
5
|
+
// to the search box.
|
|
6
|
+
|
|
7
|
+
import { DynamicBorder, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
9
|
+
import { addFlow, deleteFlow, updateFlow, type FlowCtx } from "./lib/flows.ts";
|
|
10
|
+
import { ModelPicker, rankModels, type PickerAction } from "./lib/picker.ts";
|
|
11
|
+
import {
|
|
12
|
+
loadConfig,
|
|
13
|
+
loadConfigFile,
|
|
14
|
+
pruneUsage,
|
|
15
|
+
recordUsage,
|
|
16
|
+
saveUsage,
|
|
17
|
+
type ModelEntry,
|
|
18
|
+
type ModelsConfig,
|
|
19
|
+
type ProviderEntry,
|
|
20
|
+
} from "./lib/store.ts";
|
|
21
|
+
|
|
22
|
+
const registeredLocalProviders = new Set<string>();
|
|
23
|
+
|
|
24
|
+
function providerConfig(
|
|
25
|
+
ctx: any,
|
|
26
|
+
providerName: string,
|
|
27
|
+
provider: ProviderEntry,
|
|
28
|
+
): Record<string, unknown> {
|
|
29
|
+
const models = (provider.models ?? []).map((entry: ModelEntry) => {
|
|
30
|
+
const current = ctx.modelRegistry.find(providerName, entry.id);
|
|
31
|
+
return {
|
|
32
|
+
...entry,
|
|
33
|
+
name: entry.name ?? current?.name ?? entry.id,
|
|
34
|
+
api: entry.api ?? provider.api ?? current?.api,
|
|
35
|
+
baseUrl: entry.baseUrl ?? provider.baseUrl ?? current?.baseUrl,
|
|
36
|
+
reasoning: entry.reasoning ?? current?.reasoning ?? false,
|
|
37
|
+
input: entry.input ?? current?.input ?? ["text"],
|
|
38
|
+
cost: entry.cost ?? current?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
39
|
+
contextWindow: entry.contextWindow ?? current?.contextWindow ?? 128000,
|
|
40
|
+
maxTokens: entry.maxTokens ?? current?.maxTokens ?? 16384,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const { models: _models, ...settings } = provider;
|
|
45
|
+
return { ...settings, models };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Apply the personal BMS overlay as extension providers. */
|
|
49
|
+
function syncLocalConfig(pi: ExtensionAPI, ctx: any): ModelsConfig | null {
|
|
50
|
+
const local = loadConfigFile(ctx, "models.local.json", true);
|
|
51
|
+
const effective = loadConfig(ctx);
|
|
52
|
+
if (!local || !effective) return null;
|
|
53
|
+
|
|
54
|
+
const next = new Set(Object.keys(local.providers ?? {}));
|
|
55
|
+
for (const providerName of registeredLocalProviders) {
|
|
56
|
+
if (!next.has(providerName)) pi.unregisterProvider(providerName);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const providerName of next) {
|
|
60
|
+
const provider = effective.providers?.[providerName];
|
|
61
|
+
if (!provider) continue;
|
|
62
|
+
try {
|
|
63
|
+
pi.registerProvider(providerName, providerConfig(ctx, providerName, provider) as any);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
ctx.ui.notify(`Cannot apply ${providerName} from models.local.json: ${e}`, "error");
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
registeredLocalProviders.clear();
|
|
71
|
+
for (const providerName of next) registeredLocalProviders.add(providerName);
|
|
72
|
+
return effective;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Re-read model configuration from disk and switch the session to the given model. */
|
|
76
|
+
async function loadModel(
|
|
77
|
+
pi: ExtensionAPI,
|
|
78
|
+
ctx: any,
|
|
79
|
+
provider: string,
|
|
80
|
+
modelId: string,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
await ctx.modelRegistry.refresh();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
ctx.ui.notify(`Could not reload model configuration: ${e}`, "error");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const model = ctx.modelRegistry.find(provider, modelId);
|
|
90
|
+
if (!model) {
|
|
91
|
+
ctx.ui.notify(
|
|
92
|
+
`Model "${provider}/${modelId}" not found after reload — check the config.`,
|
|
93
|
+
"warning",
|
|
94
|
+
);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// pi.setModel lives on the ExtensionAPI, not on the command context.
|
|
99
|
+
const ok = await pi.setModel(model);
|
|
100
|
+
if (ok) {
|
|
101
|
+
// Counted here rather than in model_select so /bms picks are ranked but
|
|
102
|
+
// session restores are not, which would otherwise inflate every startup.
|
|
103
|
+
recordUsage(provider, modelId);
|
|
104
|
+
ctx.ui.notify(`Loaded model: ${provider}/${modelId}`, "info");
|
|
105
|
+
} else {
|
|
106
|
+
ctx.ui.notify(
|
|
107
|
+
`Could not switch to ${provider}/${modelId} (no API key?). It is still available via /model.`,
|
|
108
|
+
"warning",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default function (pi: ExtensionAPI) {
|
|
114
|
+
pi.on("session_start", async (_event, ctx: any) => {
|
|
115
|
+
syncLocalConfig(pi, ctx);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.registerCommand("bms", {
|
|
119
|
+
description: "Better model select — searchable, usage-ranked model picker",
|
|
120
|
+
handler: async (_args, ctx: any) => {
|
|
121
|
+
if (ctx.mode !== "tui") {
|
|
122
|
+
ctx.ui.notify("/bms needs an interactive terminal.", "warning");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Loop so the picker reappears after each add / update / delete.
|
|
127
|
+
for (;;) {
|
|
128
|
+
const config = syncLocalConfig(pi, ctx);
|
|
129
|
+
if (!config) return;
|
|
130
|
+
pruneUsage(config);
|
|
131
|
+
|
|
132
|
+
const rows = rankModels(config);
|
|
133
|
+
if (rows.length === 0) {
|
|
134
|
+
const add = await ctx.ui.confirm(
|
|
135
|
+
"No models configured",
|
|
136
|
+
"The effective model configuration has no models. Add one now?",
|
|
137
|
+
);
|
|
138
|
+
if (!add) return;
|
|
139
|
+
await addFlow(ctx as FlowCtx);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const action: PickerAction | undefined = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => {
|
|
144
|
+
const container = new Container();
|
|
145
|
+
const accent = (s: string) =>
|
|
146
|
+
typeof theme?.fg === "function" ? theme.fg("accent", s) : s;
|
|
147
|
+
|
|
148
|
+
container.addChild(new DynamicBorder(accent));
|
|
149
|
+
container.addChild(new Text(accent("Better Model Select"), 1, 0));
|
|
150
|
+
|
|
151
|
+
const picker = new ModelPicker(
|
|
152
|
+
rows,
|
|
153
|
+
theme,
|
|
154
|
+
() => tui.requestRender(),
|
|
155
|
+
(a: PickerAction) => done(a),
|
|
156
|
+
);
|
|
157
|
+
picker.focused = true;
|
|
158
|
+
container.addChild(picker);
|
|
159
|
+
container.addChild(new DynamicBorder(accent));
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
render: (w: number) => container.render(w),
|
|
163
|
+
invalidate: () => container.invalidate(),
|
|
164
|
+
handleInput: (data: string) => picker.handleInput(data),
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
switch (action?.kind) {
|
|
169
|
+
case "load":
|
|
170
|
+
await loadModel(pi, ctx, action.provider, action.modelId);
|
|
171
|
+
return;
|
|
172
|
+
case "add":
|
|
173
|
+
await addFlow(ctx as FlowCtx);
|
|
174
|
+
break;
|
|
175
|
+
case "update":
|
|
176
|
+
await updateFlow(
|
|
177
|
+
ctx as FlowCtx,
|
|
178
|
+
action.provider && action.modelId
|
|
179
|
+
? { provider: action.provider, modelId: action.modelId }
|
|
180
|
+
: undefined,
|
|
181
|
+
);
|
|
182
|
+
break;
|
|
183
|
+
case "delete":
|
|
184
|
+
await deleteFlow(
|
|
185
|
+
ctx as FlowCtx,
|
|
186
|
+
action.provider && action.modelId
|
|
187
|
+
? { provider: action.provider, modelId: action.modelId }
|
|
188
|
+
: undefined,
|
|
189
|
+
);
|
|
190
|
+
break;
|
|
191
|
+
case "reset": {
|
|
192
|
+
const ok = await ctx.ui.confirm(
|
|
193
|
+
"Reset usage statistics?",
|
|
194
|
+
"Model ranking falls back to alphabetical order.",
|
|
195
|
+
);
|
|
196
|
+
if (ok) {
|
|
197
|
+
saveUsage({ version: 1, counts: {}, lastUsed: {} });
|
|
198
|
+
ctx.ui.notify("Usage statistics cleared.", "info");
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
default:
|
|
203
|
+
return; // "close" or ESC
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Standalone add, kept for muscle memory and non-interactive convenience.
|
|
210
|
+
pi.registerCommand("bms-add", {
|
|
211
|
+
description: "Add a model from a provider's endpoint to a selected config file",
|
|
212
|
+
handler: async (_args, ctx: any) => addFlow(ctx as FlowCtx),
|
|
213
|
+
});
|
|
214
|
+
}
|