pi-scoped-models-extra-info 1.0.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.
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="CompilerConfiguration">
4
+ <option name="BUILD_PROCESS_HEAP_SIZE" value="940" />
5
+ </component>
6
+ </project>
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="Encoding" defaultCharsetForPropertiesFiles="UTF-8">
4
+ <file url="PROJECT" charset="UTF-8" />
5
+ </component>
6
+ </project>
package/.idea/misc.xml ADDED
@@ -0,0 +1,36 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectInspectionProfilesVisibleTreeState">
4
+ <entry key="Project Default">
5
+ <profile-state>
6
+ <expanded-state>
7
+ <State />
8
+ <State>
9
+ <id>Internationalization</id>
10
+ </State>
11
+ <State>
12
+ <id>InternationalizationJava</id>
13
+ </State>
14
+ <State>
15
+ <id>Java</id>
16
+ </State>
17
+ <State>
18
+ <id>Numeric issuesJava</id>
19
+ </State>
20
+ <State>
21
+ <id>WebSocket</id>
22
+ </State>
23
+ </expanded-state>
24
+ <selected-state>
25
+ <State>
26
+ <id>Angular</id>
27
+ </State>
28
+ </selected-state>
29
+ </profile-state>
30
+ </entry>
31
+ </component>
32
+ <component name="SshConsoleOptionsProvider">
33
+ <option name="myEncoding" value="UTF-8" />
34
+ <option name="myConnectionType" value="NONE" />
35
+ </component>
36
+ </project>
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@aliou/pi-guardrails@0.13.1/schema.json",
3
+ "pathAccess": {
4
+ "allowedPaths": [
5
+ "~/.nvm/versions/node/v24.16.0/lib/node_modules/@earendil-works/pi-coding-agent/docs/"
6
+ ]
7
+ }
8
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Koen de Jager
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,103 @@
1
+ # pi-scoped-models-extra-info
2
+
3
+ > Interactive table of your pi coding agent's scoped models — pricing, context window, thinking levels, modalities, and optional coding benchmarks.
4
+
5
+ ![Screenshot](screenshot.png)
6
+
7
+ ## Features
8
+
9
+ - **Rich model table** — shows all your enabled (scoped) models in one view
10
+ - **Columns**: model slug, input price, output price, context window, input modalities, thinking levels, and coding benchmarks
11
+ - **Sortable** — press `n` (name), `i` (input price), `o` (output price), `c` (coding index)
12
+ - **Model switching** — press Enter on any row to switch to that model
13
+ - **Keyboard navigation** — `↑↓/jk` to move, `Home`/`End` to jump, `q/Esc` to close
14
+ - **Optional coding benchmarks** — per-thinking-level coding index from [Artificial Analysis](https://artificialanalysis.ai) (if `AA_API_KEY` is set)
15
+
16
+ ## Installation
17
+
18
+ ### Via git (pi package manager)
19
+
20
+ ```bash
21
+ pi install git:github.com/kdejaeger/pi-scoped-models-extra-info
22
+ ```
23
+
24
+ Then reload pi (`/reload`) or restart.
25
+
26
+ ### Via local clone
27
+
28
+ Clone the repo and add to `~/.pi/agent/settings.json`:
29
+
30
+ ```json
31
+ {
32
+ "extensions": [
33
+ "~/pi-scoped-models-extra-info/index.ts"
34
+ ]
35
+ }
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ Run the command:
41
+
42
+ ```
43
+ /scoped-models-extra-info
44
+ ```
45
+
46
+ Or use the keyboard shortcut: **`Ctrl+Alt+F`**
47
+
48
+ ### Navigation
49
+
50
+ | Key | Action |
51
+ |-----|--------|
52
+ | `↑` / `k` | Move selection up |
53
+ | `↓` / `j` | Move selection down |
54
+ | `Home` / `Ctrl+A` | Jump to first model |
55
+ | `End` / `Ctrl+E` | Jump to last model |
56
+ | `Enter` / `Space` | Switch to selected model |
57
+ | `n` | Sort by model name |
58
+ | `i` | Sort by input price |
59
+ | `o` | Sort by output price |
60
+ | `c` | Sort by coding index |
61
+ | `q` / `Esc` | Close table |
62
+
63
+ ## Configuration
64
+
65
+ ### Coding benchmarks (optional)
66
+
67
+ The extension can show Artificial Analysis coding index scores per thinking level. To enable this:
68
+
69
+ 1. Get an API key from [artificialanalysis.ai](https://artificialanalysis.ai)
70
+ 2. Set the environment variable before starting pi:
71
+
72
+ ```bash
73
+ export AA_API_KEY="aa_your_key_here"
74
+ pi
75
+ ```
76
+
77
+ Or add to your `~/.bashrc` / `~/.zshrc`:
78
+
79
+ ```bash
80
+ export AA_API_KEY="aa_your_key_here"
81
+ ```
82
+
83
+ Without this variable, the extension works perfectly — it just omits the coding index column.
84
+
85
+ ### What it shows
86
+
87
+ The table displays only the models you have **enabled/scoped** in your pi `settings.json` (`enabledModels`). If you haven't scoped any models, it shows all available models.
88
+
89
+ **Pricing** comes from pi's built-in model registry — it's accurate for all providers pi supports (OpenAI, Anthropic, Google, OpenRouter, Groq, etc.).
90
+
91
+ **Thinking levels** are resolved from each model's capabilities, with smart fallback for OpenRouter-proxied models.
92
+
93
+ ## Package structure
94
+
95
+ ```
96
+ pi-scoped-models-extra-info/
97
+ ├── index.ts # Extension source
98
+ ├── package.json # Pi package manifest
99
+ ├── screenshot.png # Table screenshot
100
+ ├── LICENSE # MIT
101
+ └── README.md # This file
102
+ ```
103
+
package/index.ts ADDED
@@ -0,0 +1,875 @@
1
+ /**
2
+ * /scoped-models-extra-info — Pi coding agent extension
3
+ *
4
+ * Renders an interactive table of your enabled (scoped) models with:
5
+ * model slug, input price, output price, context window, input modalities,
6
+ * thinking levels, and Artificial Analysis coding benchmarks.
7
+ *
8
+ * Press Enter on a row to switch to that model.
9
+ * Shortcut: Ctrl+Alt+F
10
+ *
11
+ * Environment variables:
12
+ * AA_API_KEY — Artificial Analysis API key for coding index column (optional).
13
+ * Get one at https://artificialanalysis.ai
14
+ * Without it, the coding index column is omitted.
15
+ */
16
+
17
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
18
+ import { getSupportedThinkingLevels, getModels, getProviders } from "@earendil-works/pi-ai";
19
+ import { truncateToWidth, matchesKey, visibleWidth, Key } from "@earendil-works/pi-tui";
20
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
21
+ import { readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
22
+ import { homedir } from "os";
23
+
24
+ // ── Config ─────────────────────────────────────────────────────────────────
25
+
26
+ const AA_API_KEY = process.env.AA_API_KEY || "";
27
+ const AA_API_URL = "https://artificialanalysis.ai/api/v2/data/llms/models";
28
+ const AA_CACHE_DIR = homedir() + "/.cache/pi/scoped-models-extra-info";
29
+ const AA_CACHE_FILE = AA_CACHE_DIR + "/aa-models.json";
30
+ const AA_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
31
+
32
+ // ── Model data helpers ─────────────────────────────────────────────────────
33
+
34
+ interface ModelRow {
35
+ slug: string;
36
+ inputPrice: number;
37
+ outputPrice: number;
38
+ contextWindow: number;
39
+ codingIndex: string;
40
+ codingSortValue: number;
41
+ thinkingLevels: string;
42
+ inputModalities: string;
43
+ provider: string;
44
+ modelId: string;
45
+ }
46
+
47
+ function getSlug(model: { provider: string; id: string; name: string }): string {
48
+ const shortId = model.id.startsWith("~") ? model.id.slice(1) : model.id;
49
+ return `${model.provider}/${shortId}`;
50
+ }
51
+
52
+ const EXTENDED_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
53
+
54
+ /**
55
+ * Normalize a provider name by stripping common artifacts (hyphens, "-" at end, etc.)
56
+ * so we can match across pi's internal provider naming and OpenRouter sub-provider prefixes.
57
+ */
58
+ function normalizeProvider(name: string): string {
59
+ return name.replace(/-/g, "").toLowerCase();
60
+ }
61
+
62
+ /**
63
+ * Resolve thinking levels for a model, falling back to the native model's
64
+ * thinkingLevelMap when the model itself doesn't have one (common for
65
+ * OpenRouter proxies that inherit the native model's thinking capabilities).
66
+ */
67
+ function resolveThinkingLevels(model: { reasoning: boolean; id: string; thinkingLevelMap?: Record<string, string | null> }): string[] {
68
+ // If the model already has a thinkingLevelMap, use it directly
69
+ if (model.thinkingLevelMap) {
70
+ return getSupportedThinkingLevels(model as any);
71
+ }
72
+ // Try to find the native model from the model ID
73
+ // e.g., model.id = "google/gemma-4-31b-it" → native provider "google", short name "gemma-4-31b-it"
74
+ const slashIdx = model.id.indexOf("/");
75
+ if (slashIdx > 0) {
76
+ const rawProvider = model.id.slice(0, slashIdx);
77
+ const shortName = model.id.slice(slashIdx + 1);
78
+
79
+ // Helper: try to find a native model with thinkingLevelMap in a given provider
80
+ const findInProvider = (provider: string) => {
81
+ const nativeModels = getModels(provider);
82
+ return nativeModels.find((m: any) => m.id === shortName && m.thinkingLevelMap);
83
+ };
84
+
85
+ // 1. Try the raw provider name directly
86
+ let nativeModel = findInProvider(rawProvider);
87
+ if (nativeModel) {
88
+ const merged = { ...model, thinkingLevelMap: nativeModel.thinkingLevelMap };
89
+ return getSupportedThinkingLevels(merged as any);
90
+ }
91
+
92
+ // 2. Try normalized provider (strip hyphens, lowercase)
93
+ const normProvider = normalizeProvider(rawProvider);
94
+ if (normProvider !== rawProvider.toLowerCase()) {
95
+ nativeModel = findInProvider(normProvider);
96
+ if (nativeModel) {
97
+ const merged = { ...model, thinkingLevelMap: nativeModel.thinkingLevelMap };
98
+ return getSupportedThinkingLevels(merged as any);
99
+ }
100
+ }
101
+
102
+ // 3. Fall back to scanning all providers for the short name
103
+ const providers = getProviders();
104
+ for (const prov of providers) {
105
+ if (prov === rawProvider || prov === normProvider) continue; // already tried
106
+ nativeModel = findInProvider(prov);
107
+ if (nativeModel) {
108
+ const merged = { ...model, thinkingLevelMap: nativeModel.thinkingLevelMap };
109
+ return getSupportedThinkingLevels(merged as any);
110
+ }
111
+ }
112
+ }
113
+ return getSupportedThinkingLevels(model as any);
114
+ }
115
+
116
+ const THINKING_LEVEL_SLOTS: ReadonlyArray<[string, number]> = [
117
+ ["off", 4],
118
+ ["minimal", 8],
119
+ ["low", 4],
120
+ ["medium", 7],
121
+ ["high", 5],
122
+ ["xhigh", 5],
123
+ ];
124
+
125
+ function getThinkingLevelsLabel(model: { reasoning: boolean }, levels: string[]): string {
126
+ if (!model.reasoning) return "—";
127
+ const levelSet = new Set(levels);
128
+ let result = "";
129
+ for (const [level, width] of THINKING_LEVEL_SLOTS) {
130
+ if (levelSet.has(level)) {
131
+ result += level.padEnd(width);
132
+ } else {
133
+ result += " ".repeat(width);
134
+ }
135
+ }
136
+ return result;
137
+ }
138
+
139
+ function getInputModalitiesLabel(modalities: string[]): string {
140
+ if (modalities.includes("image")) return "text+img";
141
+ return "text";
142
+ }
143
+
144
+ function formatPrice(price: number): string {
145
+ if (price >= 10) {
146
+ return `$${Math.round(price)}`;
147
+ }
148
+ return `$${price.toFixed(2)}`;
149
+ }
150
+
151
+ function formatContextWindow(window: number): string {
152
+ if (window >= 1_000_000) {
153
+ return `${(window / 1_000_000).toFixed(1)}M`;
154
+ }
155
+ return `${Math.round(window / 1000)}K`;
156
+ }
157
+
158
+ /** Pad a plain text string to a target visible width by appending spaces. */
159
+ function padVisible(text: string, width: number): string {
160
+ const cur = visibleWidth(text);
161
+ const needed = width - cur;
162
+ if (needed > 0) return text + " ".repeat(needed);
163
+ return text;
164
+ }
165
+
166
+ /** Fit plain text into a fixed-width cell (truncate then pad). */
167
+ function padVisibleLeft(text: string, width: number): string {
168
+ const cur = visibleWidth(text);
169
+ const needed = width - cur;
170
+ if (needed > 0) return " ".repeat(needed) + text;
171
+ return text;
172
+ }
173
+
174
+ function padVisibleRight(text: string, width: number): string {
175
+ const cur = visibleWidth(text);
176
+ const needed = width - cur;
177
+ if (needed > 0) return text + " ".repeat(needed);
178
+ return text;
179
+ }
180
+
181
+ /** Simple truncation that cuts text at maxWidth visible columns without adding "...". */
182
+ function truncatePlain(text: string, maxWidth: number): string {
183
+ if (visibleWidth(text) <= maxWidth) return text;
184
+ let result = "";
185
+ let w = 0;
186
+ for (const ch of text) {
187
+ const cw = visibleWidth(ch);
188
+ if (w + cw > maxWidth) break;
189
+ result += ch;
190
+ w += cw;
191
+ }
192
+ return result;
193
+ }
194
+
195
+ function fitCell(text: string, width: number, align: "left" | "right" = "left"): string {
196
+ const fitted = truncatePlain(text, width);
197
+ return align === "right" ? padVisibleLeft(fitted, width) : padVisibleRight(fitted, width);
198
+ }
199
+
200
+ // ── Artificial Analysis data ───────────────────────────────────────────────
201
+
202
+ interface AAModelEntry {
203
+ name: string;
204
+ slug: string;
205
+ model_creator: { name: string; slug?: string };
206
+ evaluations: { artificial_analysis_coding_index?: number };
207
+ }
208
+
209
+ /** Slug suffix → pi thinking level mapping (longest-first). */
210
+ const SUFFIX_TO_LEVEL: ReadonlyArray<{ suffix: string; level: string }> = [
211
+ { suffix: "-non-reasoning-low-effort", level: "off" },
212
+ { suffix: "-non-reasoning-high-effort", level: "off" },
213
+ { suffix: "-non-reasoning", level: "off" },
214
+ { suffix: "-low-effort", level: "low" },
215
+ { suffix: "-low", level: "low" },
216
+ { suffix: "-medium", level: "medium" },
217
+ { suffix: "-high-effort", level: "high" },
218
+ { suffix: "-high", level: "high" },
219
+ { suffix: "-minimal", level: "minimal" },
220
+ { suffix: "-adaptive", level: "xhigh" },
221
+ ];
222
+
223
+ /** Wider slots for coding column: "level(99.9)" per slot, +1 for spacing */
224
+ const CODING_SLOTS: ReadonlyArray<[string, number]> = [
225
+ ["off", 10],
226
+ ["minimal", 14],
227
+ ["low", 10],
228
+ ["medium", 13],
229
+ ["high", 11],
230
+ ["xhigh", 12],
231
+ ];
232
+
233
+ /** per-model: baseKey → Map<thinkingLevel, codingIndex> */
234
+ type AAModelLevels = Map<string, Map<string, number>>;
235
+
236
+ let aaModelData: AAModelLevels | null = null;
237
+ let aaReady: Promise<void> | null = null;
238
+
239
+ /** Extract base slug and pi thinking level from an AA entry slug. */
240
+ function parseEntryLevel(slug: string, name: string): { baseSlug: string; level: string } | null {
241
+ const s = slug.toLowerCase();
242
+ for (const { suffix, level } of SUFFIX_TO_LEVEL) {
243
+ if (s.endsWith(suffix)) {
244
+ const base = s.slice(0, -suffix.length);
245
+ if (base.length > 0) return { baseSlug: base, level };
246
+ }
247
+ }
248
+ // No known slug suffix — check name for reasoning-mode hints
249
+ const n = name.toLowerCase();
250
+ const nameLevelRe = /\(\s*(minimal|low|medium|high|xhigh|non.reasoning|non reasoning|reasoning)\s*\)/;
251
+ const nameMatch = n.match(nameLevelRe);
252
+ if (nameMatch) {
253
+ const raw = nameMatch[1].replace(/[^a-z]/g, "");
254
+ const level = raw === "nonreasoning" ? "off" : raw === "reasoning" ? "high" : raw;
255
+ return { baseSlug: s, level };
256
+ }
257
+ // Check for "Max Effort" (maps to xhigh)
258
+ if (n.includes("max effort")) {
259
+ return { baseSlug: s, level: "xhigh" };
260
+ }
261
+ // Final fallback: high — most reasoning models default here, xhigh only when specified
262
+ return { baseSlug: s, level: "high" };
263
+ }
264
+
265
+ function buildAAModelData(data: AAModelEntry[]): AAModelLevels {
266
+ const map: AAModelLevels = new Map();
267
+ for (const entry of data) {
268
+ const ci = entry.evaluations?.artificial_analysis_coding_index;
269
+ if (ci == null) continue;
270
+ const parsed = parseEntryLevel(entry.slug, entry.name);
271
+ if (!parsed) continue;
272
+ const key = `${(entry.model_creator.slug ?? entry.model_creator.name).toLowerCase()}/${parsed.baseSlug}`;
273
+ let levelMap = map.get(key);
274
+ if (!levelMap) {
275
+ levelMap = new Map();
276
+ map.set(key, levelMap);
277
+ }
278
+ // Keep highest CI when the same base+level appears in multiple AA entries
279
+ const existing = levelMap.get(parsed.level);
280
+ if (existing === undefined || ci > existing) {
281
+ levelMap.set(parsed.level, ci);
282
+ }
283
+ }
284
+ return map;
285
+ }
286
+
287
+ function findLevelMap(
288
+ provider: string,
289
+ modelId: string,
290
+ data: AAModelLevels,
291
+ ): Map<string, number> | undefined {
292
+ const p = provider.toLowerCase();
293
+ const mid = modelId.toLowerCase().replace(/\./g, "-");
294
+
295
+ /** Collect normalized forms (with date stripping) for a model name. */
296
+ const normalForms = (name: string): string[] => {
297
+ const r = [name];
298
+ const s1 = name.replace(/-\d{4}-\d{2}-\d{2}(?:-\d+)?$/, "");
299
+ if (s1 !== name) r.push(s1);
300
+ const s2 = name.replace(/-\d{8}$/, "");
301
+ if (s2 !== name && s2 !== s1) r.push(s2);
302
+ return r;
303
+ };
304
+
305
+ /** Try exact lookup with a given (provider, model) pair. */
306
+ const tryLookup = (pr: string, md: string): Map<string, number> | undefined => {
307
+ for (const c of normalForms(md)) {
308
+ const found = data.get(`${pr}/${c}`);
309
+ if (found) return found;
310
+ }
311
+ return undefined;
312
+ };
313
+
314
+ /** Try prefix matching with a given (provider, model) pair. */
315
+ const tryPrefix = (pr: string, md: string): Map<string, number> | undefined => {
316
+ for (const [key, val] of data) {
317
+ if (key.startsWith(pr + "/") && md.startsWith(key.slice(pr.length + 1))) return val;
318
+ }
319
+ const norm = normalForms(md);
320
+ for (const [key, val] of data) {
321
+ if (key.startsWith(pr + "/")) {
322
+ const aaSlug = key.slice(pr.length + 1);
323
+ for (const c of norm) {
324
+ if (aaSlug.startsWith(c)) return val;
325
+ }
326
+ }
327
+ }
328
+ return undefined;
329
+ };
330
+
331
+ // 1. Try pi's provider directly (e.g. openrouter/deepseek-v4-flash)
332
+ const hit1 = tryLookup(p, mid);
333
+ if (hit1) return hit1;
334
+
335
+ // 2. ModelId may contain a sub-provider, e.g. "deepseek/deepseek-v4-flash"
336
+ const slashIdx = mid.indexOf("/");
337
+ if (slashIdx >= 0) {
338
+ const rawProvider = mid.slice(0, slashIdx);
339
+ const rawModel = mid.slice(slashIdx + 1);
340
+ const realProvider = rawProvider.replace(/^~/, "");
341
+ const hit2 = tryLookup(realProvider, rawModel);
342
+ if (hit2) return hit2;
343
+ const hit3 = tryPrefix(realProvider, rawModel);
344
+ if (hit3) return hit3;
345
+ }
346
+
347
+ // 3. Fallback: prefix matching with pi's provider
348
+ const hit4 = tryPrefix(p, mid);
349
+ if (hit4) return hit4;
350
+
351
+ // 4. Try common provider aliases (e.g. openai-codex → openai)
352
+ const aliasMap: Record<string, string> = {
353
+ "openai-codex": "openai",
354
+ "qwen": "alibaba",
355
+ "moonshotai": "kimi",
356
+ "arcee-ai": "arcee ai",
357
+ "z-ai": "zai",
358
+ };
359
+ const alias = aliasMap[p];
360
+ if (alias) {
361
+ const hit5 = tryLookup(alias, mid);
362
+ if (hit5) return hit5;
363
+ }
364
+ // Also try sub-provider aliased (e.g. qwen → alibaba)
365
+ if (slashIdx >= 0) {
366
+ const rawProvider = mid.slice(0, slashIdx);
367
+ const rawModel = mid.slice(slashIdx + 1);
368
+ const realProvider = rawProvider.replace(/^~/, "");
369
+ const subAlias = aliasMap[realProvider];
370
+ if (subAlias) {
371
+ const hit6 = tryLookup(subAlias, rawModel);
372
+ if (hit6) return hit6;
373
+ const hit6b = tryPrefix(subAlias, rawModel);
374
+ if (hit6b) return hit6b;
375
+ }
376
+ // Also try stripped-hyphen provider (e.g. "z-ai" → "zai")
377
+ const strippedProvider = realProvider.replace(/-/g, "");
378
+ if (strippedProvider !== realProvider) {
379
+ const hit6c = tryLookup(strippedProvider, rawModel);
380
+ if (hit6c) return hit6c;
381
+ const hit6d = tryPrefix(strippedProvider, rawModel);
382
+ if (hit6d) return hit6d;
383
+ }
384
+ }
385
+
386
+ // 5. Clean model name (strip ":free", "-latest" etc.) and retry
387
+ const cleanMid = mid.replace(/:.*$/, "").replace(/-latest$/, "");
388
+ if (cleanMid !== mid) {
389
+ const hit7 = tryLookup(p, cleanMid);
390
+ if (hit7) return hit7;
391
+ if (slashIdx >= 0) {
392
+ const rawProvider = mid.slice(0, slashIdx);
393
+ const rawModel = mid.slice(slashIdx + 1);
394
+ const cleanModel = rawModel.replace(/:.*$/, "").replace(/-latest$/, "");
395
+ const realProvider = rawProvider.replace(/^~/, "");
396
+ const hit8 = tryLookup(realProvider, cleanModel);
397
+ if (hit8) return hit8;
398
+ const hit9 = tryPrefix(realProvider, cleanModel);
399
+ if (hit9) return hit9;
400
+ const subAlias = aliasMap[realProvider];
401
+ if (subAlias) {
402
+ const hit10 = tryLookup(subAlias, cleanModel);
403
+ if (hit10) return hit10;
404
+ }
405
+ }
406
+ }
407
+
408
+ return undefined;
409
+ }
410
+
411
+ /** Format level→CI map as display string + sort value (by "high" level CI). */
412
+ function formatCodingData(levelMap: Map<string, number>): { display: string; sortValue: number } {
413
+ let display = "";
414
+ let sortValue = -1;
415
+ for (const [level, width] of CODING_SLOTS) {
416
+ const ci = levelMap.get(level);
417
+ if (ci != null) {
418
+ display += `${level}(${ci.toFixed(1)})`.padEnd(width);
419
+ if (level === "high") sortValue = ci;
420
+ } else {
421
+ display += " ".repeat(width);
422
+ }
423
+ }
424
+ if (sortValue < 0) {
425
+ for (const level of ["xhigh", "medium", "low", "minimal", "off"]) {
426
+ const ci = levelMap.get(level);
427
+ if (ci != null) { sortValue = ci; break; }
428
+ }
429
+ }
430
+ return { display, sortValue };
431
+ }
432
+
433
+ async function fetchAAData(): Promise<void> {
434
+ if (!AA_API_KEY) return;
435
+ try {
436
+ const resp = await fetch(AA_API_URL, { headers: { "x-api-key": AA_API_KEY } });
437
+ if (!resp.ok) {
438
+ try {
439
+ const raw = readFileSync(AA_CACHE_FILE, "utf-8");
440
+ aaModelData = buildAAModelData(JSON.parse(raw) as AAModelEntry[]);
441
+ } catch { /* no stale cache */ }
442
+ return;
443
+ }
444
+ const json = await resp.json();
445
+ const data = json.data as AAModelEntry[];
446
+ aaModelData = buildAAModelData(data);
447
+ try {
448
+ mkdirSync(AA_CACHE_DIR, { recursive: true });
449
+ writeFileSync(AA_CACHE_FILE, JSON.stringify(data));
450
+ } catch { /* cache write failure is non-fatal */ }
451
+ } catch {
452
+ try {
453
+ const raw = readFileSync(AA_CACHE_FILE, "utf-8");
454
+ aaModelData = buildAAModelData(JSON.parse(raw) as AAModelEntry[]);
455
+ } catch { /* no stale cache either */ }
456
+ }
457
+ }
458
+
459
+ function initAAData(): boolean {
460
+ if (!AA_API_KEY) return false;
461
+ // Try to load cache regardless of age — stale data is better than blocking.
462
+ try {
463
+ const raw = readFileSync(AA_CACHE_FILE, "utf-8");
464
+ aaModelData = buildAAModelData(JSON.parse(raw) as AAModelEntry[]);
465
+ // If cache is stale, fire a background refresh (no await).
466
+ try {
467
+ const s = statSync(AA_CACHE_FILE);
468
+ if (Date.now() - s.mtimeMs >= AA_CACHE_TTL_MS) {
469
+ aaReady = fetchAAData(); // background — never awaited
470
+ }
471
+ } catch { /* stat failed, ignore */ }
472
+ return true; // data loaded from cache (possibly stale)
473
+ } catch { /* no cache file */ }
474
+ // No cache at all — fire background fetch, return false.
475
+ aaReady = fetchAAData();
476
+ return false;
477
+ }
478
+
479
+ // ── Terminal table component ───────────────────────────────────────────────
480
+
481
+ type SortColumn = "name" | "input" | "output" | "coding";
482
+
483
+ class ExtraInfoTable {
484
+ private rows: ModelRow[];
485
+ private pi: ExtensionAPI;
486
+ private ctx: ExtensionCommandContext;
487
+ private theme: Theme;
488
+ private done: (value: string | undefined) => void;
489
+ private scrollOffset = 0;
490
+ selectedIndex = 0;
491
+ private sortColumn: SortColumn = "output";
492
+ private sortDirection: "asc" | "desc" = "asc";
493
+ private cachedLines: string[] | undefined;
494
+
495
+ constructor(
496
+ rows: ModelRow[],
497
+ pi: ExtensionAPI,
498
+ ctx: ExtensionCommandContext,
499
+ theme: Theme,
500
+ done: (value: string | undefined) => void,
501
+ ) {
502
+ this.rows = rows;
503
+ this.pi = pi;
504
+ this.ctx = ctx;
505
+ this.theme = theme;
506
+ this.done = done;
507
+ }
508
+
509
+ handleInput(data: string): void {
510
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
511
+ this.done(undefined);
512
+ return;
513
+ }
514
+
515
+ // ── Sort shortcuts ──
516
+ if (matchesKey(data, "n")) {
517
+ this.sortBy("name");
518
+ return;
519
+ }
520
+ if (matchesKey(data, "i")) {
521
+ this.sortBy("input");
522
+ return;
523
+ }
524
+ if (matchesKey(data, "o")) {
525
+ this.sortBy("output");
526
+ return;
527
+ }
528
+ if (matchesKey(data, "c")) {
529
+ this.sortBy("coding");
530
+ return;
531
+ }
532
+
533
+ // ── Navigation ──
534
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
535
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
536
+ this.ensureVisible();
537
+ this.invalidate();
538
+ return;
539
+ }
540
+
541
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
542
+ this.selectedIndex = Math.min(this.rows.length - 1, this.selectedIndex + 1);
543
+ this.ensureVisible();
544
+ this.invalidate();
545
+ return;
546
+ }
547
+
548
+ if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
549
+ this.selectedIndex = 0;
550
+ this.scrollOffset = 0;
551
+ this.invalidate();
552
+ return;
553
+ }
554
+
555
+ if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
556
+ this.selectedIndex = this.rows.length - 1;
557
+ this.scrollOffset = Math.max(0, this.rows.length - this.maxVisibleRows());
558
+ this.invalidate();
559
+ return;
560
+ }
561
+
562
+ if (matchesKey(data, "return") || matchesKey(data, "space")) {
563
+ const row = this.rows[this.selectedIndex];
564
+ if (!row) return;
565
+ this.done(`${row.provider}/${row.modelId}`);
566
+ return;
567
+ }
568
+ }
569
+
570
+ private sortBy(column: SortColumn): void {
571
+ if (this.sortColumn === column) {
572
+ // Toggle direction
573
+ this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc";
574
+ } else {
575
+ this.sortColumn = column;
576
+ this.sortDirection = "asc";
577
+ }
578
+
579
+ const dir = this.sortDirection === "asc" ? 1 : -1;
580
+ this.rows.sort((a, b) => {
581
+ let cmp: number;
582
+ switch (column) {
583
+ case "name":
584
+ cmp = a.slug.localeCompare(b.slug);
585
+ break;
586
+ case "input":
587
+ cmp = a.inputPrice - b.inputPrice;
588
+ break;
589
+ case "output":
590
+ cmp = a.outputPrice - b.outputPrice;
591
+ break;
592
+ case "coding": {
593
+ const aVal = a.codingSortValue;
594
+ const bVal = b.codingSortValue;
595
+ cmp = aVal - bVal;
596
+ break;
597
+ }
598
+ }
599
+ return cmp * dir;
600
+ });
601
+
602
+ this.selectedIndex = 0;
603
+ this.scrollOffset = 0;
604
+ this.invalidate();
605
+ }
606
+
607
+ private maxVisibleRows(): number {
608
+ return Math.min(this.rows.length, 20);
609
+ }
610
+
611
+ ensureVisible(): void {
612
+ const maxVis = this.maxVisibleRows();
613
+ if (this.selectedIndex < this.scrollOffset) {
614
+ this.scrollOffset = this.selectedIndex;
615
+ } else if (this.selectedIndex >= this.scrollOffset + maxVis) {
616
+ this.scrollOffset = this.selectedIndex - maxVis + 1;
617
+ }
618
+ }
619
+
620
+ invalidate(): void {
621
+ this.cachedLines = undefined;
622
+ }
623
+
624
+ render(width: number): string[] {
625
+ if (this.cachedLines) return this.cachedLines;
626
+
627
+ const t = this.theme;
628
+ const accent = (s: string) => t.fg("accent", s);
629
+ const text = (s: string) => t.fg("text", s);
630
+ const muted = (s: string) => t.fg("muted", s);
631
+ const dim = (s: string) => t.fg("dim", s);
632
+ const success = (s: string) => t.fg("success", s);
633
+
634
+ const arrow = this.sortDirection === "asc" ? ">" : "<";
635
+ const sortMarker = (col: SortColumn): string =>
636
+ this.sortColumn === col ? success(arrow) : "";
637
+
638
+ const lines: string[] = [];
639
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
640
+
641
+ // Column widths (visible character counts)
642
+ const colSlug = 50;
643
+ const colIn = 7;
644
+ const colOut = 8;
645
+ const colCtx = 7;
646
+ const colCode = 70;
647
+ const colThink = 33;
648
+ const colMod = 10;
649
+
650
+ // Build separator AFTER deciding column widths (so we can compute total)
651
+ const sep = " | ";
652
+ const rowWidth = colSlug + colIn + colOut + colCtx + colCode + colThink + colMod + sep.length * 6;
653
+
654
+ // ── Header ──
655
+ add("");
656
+
657
+ const headerCells = [
658
+ fitCell(`Model${sortMarker("name")}`, colSlug, "left"),
659
+ fitCell(`Input$${sortMarker("input")}`, colIn, "right"),
660
+ fitCell(`Output$${sortMarker("output")}`, colOut, "right"),
661
+ fitCell("Context", colCtx, "right"),
662
+ fitCell("Modalities", colMod, "left"),
663
+ fitCell("Thinking", colThink, "left"),
664
+ fitCell(`Coding index${sortMarker("coding")}`, colCode, "left"),
665
+ ];
666
+ add(accent(" " + headerCells.join(sep)));
667
+
668
+ // Separator line
669
+ add(dim(" " + "-".repeat(Math.min(width, rowWidth))));
670
+
671
+ // ── Data rows ──
672
+ for (let i = this.scrollOffset; i < this.rows.length; i++) {
673
+ const row = this.rows[i];
674
+ const isSelected = i === this.selectedIndex;
675
+
676
+ const slugCell = fitCell(row.slug, colSlug, "left");
677
+ const inCell = fitCell(formatPrice(row.inputPrice), colIn, "right");
678
+ const outCell = fitCell(formatPrice(row.outputPrice), colOut, "right");
679
+ const ctxCell = fitCell(formatContextWindow(row.contextWindow), colCtx, "right");
680
+ const modCell = fitCell(row.inputModalities, colMod, "left");
681
+ const thinkCell = fitCell(row.thinkingLevels, colThink, "left");
682
+ const codeCell = fitCell(row.codingIndex, colCode, "left");
683
+
684
+ let plainRow = [slugCell, inCell, outCell, ctxCell, modCell, thinkCell, codeCell].join(sep);
685
+ plainRow = truncateToWidth(plainRow, rowWidth);
686
+
687
+ if (isSelected) {
688
+ add(accent("> " + plainRow));
689
+ } else {
690
+ add(" " + text(plainRow));
691
+ }
692
+ }
693
+
694
+ // ── Footer ──
695
+ add("");
696
+ const scrollInfo =
697
+ this.rows.length > this.maxVisibleRows()
698
+ ? `${this.selectedIndex + 1}/${this.rows.length}`
699
+ : `${this.rows.length} models`;
700
+ const footerText = ` ↑↓/jk navigate • n/i/o/c sort • Enter select • q/Esc • ${scrollInfo}`;
701
+ add(dim(footerText));
702
+ add(accent(" " + "-".repeat(Math.min(width, rowWidth))));
703
+
704
+ this.cachedLines = lines;
705
+ return lines;
706
+ }
707
+ }
708
+
709
+ // ── Extension registration ─────────────────────────────────────────────────
710
+
711
+ export default function (pi: ExtensionAPI) {
712
+ pi.registerCommand("scoped-models-extra-info", {
713
+ description:
714
+ "Render table of scoped models with prices, context window, thinking levels, and modalities",
715
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
716
+ await showScopedModelsTable(pi, ctx);
717
+ },
718
+ });
719
+
720
+ pi.registerShortcut(Key.ctrlAlt("f"), {
721
+ description: "Open scoped models table",
722
+ handler: async (ctx: ExtensionContext) => {
723
+ await showScopedModelsTable(pi, ctx);
724
+ },
725
+ });
726
+ }
727
+
728
+ async function showScopedModelsTable(
729
+ pi: ExtensionAPI,
730
+ ctx: ExtensionContext,
731
+ ): Promise<void> {
732
+ if (!ctx.hasUI) {
733
+ ctx.ui.notify("Command requires interactive mode", "error");
734
+ return;
735
+ }
736
+
737
+ // Read enabled models from settings
738
+ const sm = SettingsManager.create(ctx.cwd);
739
+ const allModels = ctx.modelRegistry.getAvailable();
740
+
741
+ // Build a lookup by "provider/id"
742
+ const modelLookup = new Map<string, (typeof allModels)[0]>();
743
+ for (const m of allModels) {
744
+ modelLookup.set(`${m.provider}/${m.id}`, m);
745
+ }
746
+
747
+ const globalSettings = sm.getGlobalSettings();
748
+ const enabledPatterns = globalSettings.enabledModels;
749
+
750
+ let matchedModels: typeof allModels;
751
+
752
+ if (!enabledPatterns || enabledPatterns.length === 0) {
753
+ matchedModels = allModels;
754
+ } else {
755
+ matchedModels = [];
756
+
757
+ for (const pattern of enabledPatterns) {
758
+ const firstSlash = pattern.indexOf("/");
759
+ if (firstSlash === -1) continue;
760
+
761
+ const provider = pattern.slice(0, firstSlash);
762
+ const modelId = pattern.slice(firstSlash + 1);
763
+
764
+ const key = `${provider}/${modelId}`;
765
+ let model = modelLookup.get(key);
766
+
767
+ if (!model) {
768
+ model = allModels.find((m) => m.provider === provider && m.id === modelId);
769
+ }
770
+
771
+ if (model) {
772
+ matchedModels.push(model);
773
+ }
774
+ }
775
+ }
776
+
777
+ if (matchedModels.length === 0) {
778
+ ctx.ui.notify("No available models found", "warning");
779
+ return;
780
+ }
781
+
782
+ // Initialize AA data (sync from cache, never blocks on HTTP)
783
+ // If cache is empty or AA_API_KEY unset, data shows without coding index on first load.
784
+ const hasAA = initAAData();
785
+
786
+ // Build rows
787
+ const rows = buildRows(matchedModels, aaModelData);
788
+
789
+ // Pre-select the currently active model, if it's in the list
790
+ let initialIndex = 0;
791
+ if (ctx.model) {
792
+ const currentSlug = `${ctx.model.provider}/${ctx.model.id}`;
793
+ const found = rows.findIndex((r) => r.slug === currentSlug);
794
+ if (found >= 0) initialIndex = found;
795
+ }
796
+
797
+ // Show interactive table
798
+ const selectedPath = await ctx.ui.custom<string | undefined>(
799
+ (_tui, theme, _kb, done) => {
800
+ const table = new ExtraInfoTable(rows, pi, ctx, theme, done);
801
+ table.selectedIndex = initialIndex;
802
+ table.ensureVisible();
803
+ return table;
804
+ },
805
+ );
806
+
807
+ // If user selected a model (Enter/Space), switch to it
808
+ if (selectedPath) {
809
+ const firstSlash = selectedPath.indexOf("/");
810
+ const provider = firstSlash >= 0 ? selectedPath.slice(0, firstSlash) : "";
811
+ const modelId = firstSlash >= 0 ? selectedPath.slice(firstSlash + 1) : selectedPath;
812
+
813
+ const model = ctx.modelRegistry.find(provider, modelId);
814
+ if (model) {
815
+ const ok = await pi.setModel(model);
816
+ if (ok) {
817
+ ctx.ui.notify(`✓ Switched to ${provider}/${modelId}`, "info");
818
+ } else {
819
+ ctx.ui.notify(`✗ No API key available for ${provider}/${modelId}`, "error");
820
+ }
821
+ } else {
822
+ ctx.ui.notify(`✗ Model ${selectedPath} not found in registry`, "error");
823
+ }
824
+ }
825
+ }
826
+
827
+ // ── Row building ───────────────────────────────────────────────────────────
828
+
829
+ function buildRows(
830
+ models: {
831
+ provider: string;
832
+ id: string;
833
+ name: string;
834
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
835
+ contextWindow: number;
836
+ input: string[];
837
+ reasoning: boolean;
838
+ }[],
839
+ aaModelData: AAModelLevels | null,
840
+ ): ModelRow[] {
841
+ const rows: ModelRow[] = [];
842
+
843
+ for (const model of models) {
844
+ const levels = resolveThinkingLevels(model as any);
845
+
846
+ // Look up artificial analysis coding index (across all thinking levels)
847
+ let codingIndex = "—";
848
+ let codingSortValue = -1;
849
+ if (aaModelData) {
850
+ const levelMap = findLevelMap(model.provider, model.id, aaModelData);
851
+ if (levelMap) {
852
+ const fmt = formatCodingData(levelMap);
853
+ codingIndex = fmt.display;
854
+ codingSortValue = fmt.sortValue;
855
+ }
856
+ }
857
+
858
+ rows.push({
859
+ slug: getSlug(model),
860
+ inputPrice: model.cost.input,
861
+ outputPrice: model.cost.output,
862
+ contextWindow: model.contextWindow,
863
+ codingIndex,
864
+ codingSortValue,
865
+ thinkingLevels: getThinkingLevelsLabel(model, levels),
866
+ inputModalities: getInputModalitiesLabel(model.input),
867
+ provider: model.provider,
868
+ modelId: model.id,
869
+ });
870
+ }
871
+
872
+ // Sort by output price ascending
873
+ rows.sort((a, b) => a.outputPrice - b.outputPrice);
874
+ return rows;
875
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "pi-scoped-models-extra-info",
3
+ "version": "1.0.0",
4
+ "description": "Pi coding agent extension — interactive table of your scoped models with pricing, context window, thinking levels, modalities, and coding benchmarks",
5
+ "license": "MIT",
6
+ "author": "Koen de Jager",
7
+ "keywords": ["pi-package"],
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kdejaeger/pi-scoped-models-extra-info.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/kdejaeger/pi-scoped-models-extra-info/issues"
14
+ },
15
+ "homepage": "https://github.com/kdejaeger/pi-scoped-models-extra-info#readme",
16
+ "pi": {
17
+ "extensions": ["index.ts"],
18
+ "image": "https://raw.githubusercontent.com/kdejaeger/pi-scoped-models-extra-info/main/screenshot.png"
19
+ },
20
+ "peerDependencies": {
21
+ "@earendil-works/pi-coding-agent": "*",
22
+ "@earendil-works/pi-ai": "*",
23
+ "@earendil-works/pi-tui": "*"
24
+ },
25
+ "devDependencies": {
26
+ "@earendil-works/pi-coding-agent": "^0.74.0",
27
+ "@earendil-works/pi-ai": "^0.74.0",
28
+ "@earendil-works/pi-tui": "^0.74.0"
29
+ }
30
+ }
package/screenshot.png ADDED
Binary file