@badliveware/pi-model-catalog 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -0
  3. package/index.ts +485 -0
  4. package/package.json +44 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BadLiveware
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,102 @@
1
+ # pi-model-catalog
2
+
3
+ Exposes Pi's model registry to the agent as a tool so model choice can be based on the same data behind `pi --list-models`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@badliveware/pi-model-catalog
9
+ ```
10
+
11
+ For local testing from this repository:
12
+
13
+ ```bash
14
+ pi -e /path/to/pi/agent/extensions/public/model-catalog
15
+ ```
16
+
17
+ ## Tool
18
+
19
+ Registers:
20
+
21
+ - `list_pi_models`
22
+
23
+ Parameters:
24
+
25
+ - `query` — optional substring filter such as `mini`, `codex`, or `sonnet`
26
+ - `includeUnavailable` — include models without configured auth; defaults to `false`
27
+ - `includeDetails` — include verbose use/avoid guidance; defaults to `false`
28
+ - `includePricing` — include numeric registry prices in $/million tokens; defaults to `false`
29
+ - `relativeTo` — optional baseline model id such as `openai-codex/gpt-5.4`; with `includePricing`, adds relative input/output/blended ratios
30
+ - `unsupported` — how to handle locally unsupported models: `exclude` (default), `include`, or `only`
31
+
32
+ Default returned columns are intentionally concise:
33
+
34
+ - full model id (`provider/model`, with `*` marking the current model)
35
+ - `auth`, `support`, and `enabled` status
36
+ - context and max output tokens
37
+ - compact capability marker (`text`, `think`, `img`, or `think+img`)
38
+ - price guidance tier (`price-tier`)
39
+ - combined relative cost ratio (`rel-cost`) against the current model by default, or against `relativeTo` / `--relative-to` when supplied
40
+ - quota guidance tier
41
+
42
+ Optional columns/details include:
43
+
44
+ - numeric pricing columns (`in$/M`, `out$/M`) and detailed relative ratios (`rel-in`, `rel-out`, `rel-blend`) with `includePricing`
45
+ - verbose use/avoid guidance with `includeDetails`
46
+
47
+ For model overrides, agents should choose rows with both `support: yes` and `enabled: yes` unless the user explicitly authorizes configuration changes. `auth: yes` only means credentials exist.
48
+
49
+ The `price-tier` and `quota` columns are guidance tiers, not live billing or remaining-quota data. They are meant to help the agent choose between cheap/lower-scarcity, premium low-latency, default, and scarce/strong models. `price-tier` is computed from local registry rates as `input $/M + output $/M`:
50
+
51
+ | Tier | Rule |
52
+ | --- | ---: |
53
+ | `free/local` | locally run model; no metered API cost |
54
+ | `unknown/sub` | no numeric input/output price |
55
+ | `low` | `<= $1/M` blended |
56
+ | `medium` | `<= $8/M` blended |
57
+ | `high` | `<= $30/M` blended |
58
+ | `premium` | `> $30/M` blended |
59
+ | `premium-speed` | `-spark` models, special-cased |
60
+
61
+ Local models are usually free from API billing but can be much slower than hosted models. Treat local-model capability as installation-specific, use them mainly for non-interactive/background work when latency is acceptable, and avoid assuming high concurrency on a single local backend. `-spark` models are treated as premium very-low-latency options, not cheap defaults.
62
+
63
+ Numeric pricing comes from Pi's local model registry (`model.cost`) and is expressed in dollars per million tokens. For direct API providers this usually mirrors provider pricing; for subscription-backed providers such as Codex or Copilot, treat it as nominal cost-weight data rather than a guarantee of live billing or quota burn. A zero/blank price outside the `free/local` tier can mean unknown, bundled, or non-metered rather than free.
64
+
65
+ Example:
66
+
67
+ ```json
68
+ {
69
+ "query": "openai-codex gpt-5.4",
70
+ "includePricing": true,
71
+ "relativeTo": "openai-codex/gpt-5.4"
72
+ }
73
+ ```
74
+
75
+ ## Locally unsupported models
76
+
77
+ Some models can appear in Pi's registry and pass auth checks but still fail for a specific account/provider pairing. Public package defaults do not mark any account-specific model as unsupported; add local entries when your provider/account has known incompatibilities.
78
+
79
+ Locally unsupported models are excluded by default so agents do not choose them accidentally. Call `list_pi_models` with `unsupported: "include"` to show them with a `support: no` column and reason, or `unsupported: "only"` to inspect only unsupported entries.
80
+
81
+ You can add local unsupported entries in `~/.pi/agent/model-catalog.json`:
82
+
83
+ ```json
84
+ {
85
+ "unsupportedModels": [
86
+ {
87
+ "model": "provider/model-id",
88
+ "reason": "short reason shown to the agent"
89
+ }
90
+ ]
91
+ }
92
+ ```
93
+
94
+ ## Command
95
+
96
+ - `/models-guide [query]` — show the concise available-model table in the UI
97
+ - `/models-guide --verbose [query]` — include verbose use/avoid guidance
98
+ - `/models-guide --pricing --relative-to openai-codex/gpt-5.4 openai-codex gpt-5` — include numeric pricing and ratios in the UI table
99
+
100
+ ## Intended use
101
+
102
+ Agents should call `list_pi_models` before choosing or recommending a model when availability, cost, quota, or capability matters. This is especially useful for subagent delegation and deciding whether to downshift routine work or upshift difficult bounded work.
package/index.ts ADDED
@@ -0,0 +1,485 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { Api, Model } from "@mariozechner/pi-ai";
5
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ // @ts-ignore The Pi extension runtime provides typebox, but this package does not ship declarations in current Pi installs.
7
+ import { Type } from "typebox";
8
+
9
+ type UnsupportedMode = "exclude" | "include" | "only";
10
+
11
+ interface ListPiModelsParams {
12
+ query?: string;
13
+ includeUnavailable?: boolean;
14
+ includeDetails?: boolean;
15
+ includePricing?: boolean;
16
+ relativeTo?: string;
17
+ unsupported?: UnsupportedMode;
18
+ }
19
+
20
+ interface ModelCatalogSettings {
21
+ enabledModels?: string[];
22
+ }
23
+
24
+ interface ModelCatalogConfig {
25
+ unsupportedModels?: Array<string | { model?: string; fullId?: string; reason?: string }>;
26
+ }
27
+
28
+ interface UnsupportedModelInfo {
29
+ reason: string;
30
+ }
31
+
32
+ interface ModelPricing {
33
+ inputPerMillion: number;
34
+ outputPerMillion: number;
35
+ cacheReadPerMillion: number;
36
+ cacheWritePerMillion: number;
37
+ known: boolean;
38
+ relativeTo?: string;
39
+ relativeInput?: number;
40
+ relativeOutput?: number;
41
+ relativeCacheRead?: number;
42
+ relativeBlended?: number;
43
+ }
44
+
45
+ interface ModelCatalogRow {
46
+ provider: string;
47
+ model: string;
48
+ fullId: string;
49
+ current: boolean;
50
+ available: boolean;
51
+ cycleEnabled: boolean;
52
+ context: string;
53
+ maxOut: string;
54
+ thinking: string;
55
+ images: string;
56
+ cost: string;
57
+ quota: string;
58
+ pricing: ModelPricing;
59
+ supported: boolean;
60
+ unsupportedReason?: string;
61
+ useFor: string;
62
+ avoidFor: string;
63
+ }
64
+
65
+ interface ModelCatalogResult {
66
+ rows: ModelCatalogRow[];
67
+ excludedUnsupportedRows: ModelCatalogRow[];
68
+ pricingBaseline?: string;
69
+ pricingBaselineMissing?: string;
70
+ }
71
+
72
+ function agentDir(): string {
73
+ return process.env.PI_CODING_AGENT_DIR ?? process.env.PI_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
74
+ }
75
+
76
+ function readSettings(): ModelCatalogSettings {
77
+ try {
78
+ const settingsPath = path.join(agentDir(), "settings.json");
79
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf-8")) as ModelCatalogSettings;
80
+ return parsed && typeof parsed === "object" ? parsed : {};
81
+ } catch {
82
+ return {};
83
+ }
84
+ }
85
+
86
+ function readModelCatalogConfig(): ModelCatalogConfig {
87
+ try {
88
+ const configPath = path.join(agentDir(), "model-catalog.json");
89
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")) as ModelCatalogConfig;
90
+ return parsed && typeof parsed === "object" ? parsed : {};
91
+ } catch {
92
+ return {};
93
+ }
94
+ }
95
+
96
+ function formatTokenCount(count: number): string {
97
+ if (count >= 1_000_000) {
98
+ const millions = count / 1_000_000;
99
+ return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`;
100
+ }
101
+ if (count >= 1_000) {
102
+ const thousands = count / 1_000;
103
+ return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`;
104
+ }
105
+ return count.toString();
106
+ }
107
+
108
+ function formatPricePerMillion(value: number, known: boolean): string {
109
+ if (!known) return "—";
110
+ if (value === 0) return "$0";
111
+ return `$${value < 1 ? value.toFixed(3) : value.toFixed(2)}`;
112
+ }
113
+
114
+ function formatRatio(value: number | undefined): string {
115
+ if (value === undefined || !Number.isFinite(value)) return "—";
116
+ return `${value.toFixed(2)}×`;
117
+ }
118
+
119
+ function formatCapabilities(row: ModelCatalogRow): string {
120
+ const caps: string[] = [];
121
+ if (row.thinking === "yes") caps.push("think");
122
+ if (row.images === "yes") caps.push("img");
123
+ return caps.length > 0 ? caps.join("+") : "text";
124
+ }
125
+
126
+ function shellWords(input: string): string[] {
127
+ const words: string[] = [];
128
+ let current = "";
129
+ let quote: '"' | "'" | undefined;
130
+ let escaping = false;
131
+ for (const char of input) {
132
+ if (escaping) {
133
+ current += char;
134
+ escaping = false;
135
+ continue;
136
+ }
137
+ if (char === "\\" && quote !== "'") {
138
+ escaping = true;
139
+ continue;
140
+ }
141
+ if ((char === '"' || char === "'") && (!quote || quote === char)) {
142
+ quote = quote ? undefined : char;
143
+ continue;
144
+ }
145
+ if (!quote && /\s/.test(char)) {
146
+ if (current) words.push(current);
147
+ current = "";
148
+ continue;
149
+ }
150
+ current += char;
151
+ }
152
+ if (current) words.push(current);
153
+ return words;
154
+ }
155
+
156
+ function parseModelsGuideArgs(args: string): Pick<ListPiModelsParams, "query" | "includeDetails" | "includePricing" | "relativeTo"> {
157
+ const queryParts: string[] = [];
158
+ let includeDetails = false;
159
+ let includePricing = false;
160
+ let relativeTo: string | undefined;
161
+ const words = shellWords(args);
162
+ for (let index = 0; index < words.length; index += 1) {
163
+ const word = words[index];
164
+ if (word === "--verbose" || word === "--details" || word === "-v") {
165
+ includeDetails = true;
166
+ continue;
167
+ }
168
+ if (word === "--pricing" || word === "--prices" || word === "-p") {
169
+ includePricing = true;
170
+ continue;
171
+ }
172
+ if (word === "--relative-to" || word === "--relative" || word === "--baseline") {
173
+ relativeTo = words[index + 1];
174
+ index += 1;
175
+ continue;
176
+ }
177
+ const relativeMatch = word.match(/^--(?:relative-to|relative|baseline)=(.+)$/);
178
+ if (relativeMatch) {
179
+ relativeTo = relativeMatch[1];
180
+ continue;
181
+ }
182
+ queryParts.push(word);
183
+ }
184
+ return {
185
+ query: queryParts.join(" ") || undefined,
186
+ includeDetails,
187
+ includePricing,
188
+ relativeTo,
189
+ };
190
+ }
191
+
192
+ function matchesModel(model: Model<Api>, query: string | undefined): boolean {
193
+ if (!query?.trim()) return true;
194
+ const needle = query.trim().toLowerCase();
195
+ return `${model.provider} ${model.id} ${model.name}`.toLowerCase().includes(needle);
196
+ }
197
+
198
+ function isSparkModel(model: Model<Api>): boolean {
199
+ return /(?:^|[-_])spark(?:$|[-_])/.test(model.id.toLowerCase());
200
+ }
201
+
202
+ function isLocalModel(model: Model<Api>): boolean {
203
+ const provider = String(model.provider).toLowerCase();
204
+ return provider.startsWith("local-") || provider.includes("llamaswap");
205
+ }
206
+
207
+ function classifyCost(model: Model<Api>): string {
208
+ if (isLocalModel(model)) return "free/local";
209
+ if (isSparkModel(model)) return "premium-speed";
210
+ const cost = model.cost;
211
+ const output = cost?.output ?? 0;
212
+ const input = cost?.input ?? 0;
213
+ const blended = input + output;
214
+ if (blended <= 0) return "unknown/sub";
215
+ if (blended <= 1) return "low";
216
+ if (blended <= 8) return "medium";
217
+ if (blended <= 30) return "high";
218
+ return "premium";
219
+ }
220
+
221
+ function classifyQuota(model: Model<Api>): string {
222
+ const id = model.id.toLowerCase();
223
+ const provider = String(model.provider).toLowerCase();
224
+ if (isLocalModel(model)) return "local-serial";
225
+ if (isSparkModel(model)) return "very-fast";
226
+ if (/mini|flash|lite|haiku|small/.test(id)) return "fast/limited";
227
+ if (/opus|pro|gpt-5\.5|o3|sonnet|grok-4/.test(id)) return "scarce";
228
+ if (provider.includes("codex") || provider.includes("copilot")) return "subscription";
229
+ return "standard";
230
+ }
231
+
232
+ function modelProfile(model: Model<Api>): "local" | "latency" | "fast" | "standard" | "strong" {
233
+ const id = model.id.toLowerCase();
234
+ if (isLocalModel(model)) return "local";
235
+ if (isSparkModel(model)) return "latency";
236
+ if (/mini|flash|lite|haiku|small/.test(id)) return "fast";
237
+ if (/opus|pro|gpt-5\.5|gpt-5\.4(?!-mini)|o3|sonnet|grok-4/.test(id)) return "strong";
238
+ return "standard";
239
+ }
240
+
241
+ function usageGuidance(model: Model<Api>): Pick<ModelCatalogRow, "useFor" | "avoidFor"> {
242
+ const profile = modelProfile(model);
243
+ if (profile === "local") {
244
+ return {
245
+ useFor: "local background work when latency is acceptable, concurrency is low, and the configured model's capability is sufficient",
246
+ avoidFor: "interactive work, latency-sensitive tasks, tasks above the configured local model's capability, final review, or parallel/concurrent local-model tasks unless explicitly chosen",
247
+ };
248
+ }
249
+ if (profile === "latency") {
250
+ return {
251
+ useFor: "latency-critical bounded tasks and quick scouts when very high speed is worth premium cost",
252
+ avoidFor: "cheap routine delegation, bulk mechanical work, cost-sensitive tasks, risky architecture, final review",
253
+ };
254
+ }
255
+ if (profile === "fast") {
256
+ return {
257
+ useFor: "routine edits, search, summaries, tests, bounded subagent tasks when low cost or lower scarcity matters",
258
+ avoidFor: "risky architecture, subtle debugging, final review of high-impact changes",
259
+ };
260
+ }
261
+ if (profile === "strong") {
262
+ return {
263
+ useFor: "hard reasoning, risky refactors, architecture, adversarial review",
264
+ avoidFor: "mechanical searches or easy-to-verify chores when faster models suffice",
265
+ };
266
+ }
267
+ return {
268
+ useFor: "default implementation, debugging, review, moderate planning",
269
+ avoidFor: "very mechanical chores if a faster model is available; highest-risk work if a stronger model is available",
270
+ };
271
+ }
272
+
273
+ function unsupportedModels(): Map<string, UnsupportedModelInfo> {
274
+ const unsupported = new Map<string, UnsupportedModelInfo>();
275
+ for (const entry of readModelCatalogConfig().unsupportedModels ?? []) {
276
+ if (typeof entry === "string") {
277
+ unsupported.set(entry, { reason: "marked unsupported in model-catalog.json" });
278
+ continue;
279
+ }
280
+ const fullId = entry.fullId ?? entry.model;
281
+ if (!fullId) continue;
282
+ unsupported.set(fullId, { reason: entry.reason ?? "marked unsupported in model-catalog.json" });
283
+ }
284
+ return unsupported;
285
+ }
286
+
287
+ function findModelById(ctx: ExtensionContext, id: string | undefined): Model<Api> | undefined {
288
+ const needle = id?.trim();
289
+ if (!needle) return undefined;
290
+ for (const model of ctx.modelRegistry.getAll()) {
291
+ const fullId = `${model.provider}/${model.id}`;
292
+ if (fullId === needle || model.id === needle) return model;
293
+ }
294
+ return undefined;
295
+ }
296
+
297
+ function hasKnownPricing(model: Model<Api>): boolean {
298
+ return isLocalModel(model) || model.cost.input > 0 || model.cost.output > 0 || model.cost.cacheRead > 0 || model.cost.cacheWrite > 0;
299
+ }
300
+
301
+ function pricingForModel(model: Model<Api>, baseline: Model<Api> | undefined): ModelPricing {
302
+ const known = hasKnownPricing(model);
303
+ const baselineKnown = baseline ? hasKnownPricing(baseline) : false;
304
+ const baselineCost = baselineKnown ? baseline?.cost : undefined;
305
+ const modelBlend = model.cost.input + model.cost.output;
306
+ const baselineBlend = baselineCost ? baselineCost.input + baselineCost.output : 0;
307
+ return {
308
+ inputPerMillion: model.cost.input,
309
+ outputPerMillion: model.cost.output,
310
+ cacheReadPerMillion: model.cost.cacheRead,
311
+ cacheWritePerMillion: model.cost.cacheWrite,
312
+ known,
313
+ relativeTo: baseline && baselineKnown ? `${baseline.provider}/${baseline.id}` : undefined,
314
+ relativeInput: known && baselineCost && baselineCost.input > 0 ? model.cost.input / baselineCost.input : undefined,
315
+ relativeOutput: known && baselineCost && baselineCost.output > 0 ? model.cost.output / baselineCost.output : undefined,
316
+ relativeCacheRead: known && baselineCost && baselineCost.cacheRead > 0 ? model.cost.cacheRead / baselineCost.cacheRead : undefined,
317
+ relativeBlended: known && baselineCost && baselineBlend > 0 ? modelBlend / baselineBlend : undefined,
318
+ };
319
+ }
320
+
321
+ function toRows(ctx: ExtensionContext, includeUnavailable: boolean, unsupportedMode: UnsupportedMode, query?: string, relativeTo?: string): ModelCatalogResult {
322
+ const availableIds = new Set(ctx.modelRegistry.getAvailable().map((model) => `${model.provider}/${model.id}`));
323
+ const enabledIds = new Set(readSettings().enabledModels ?? []);
324
+ const unsupportedById = unsupportedModels();
325
+ const models = includeUnavailable ? ctx.modelRegistry.getAll() : ctx.modelRegistry.getAvailable();
326
+ const requestedBaselineId = relativeTo?.trim();
327
+ const requestedBaseline = findModelById(ctx, requestedBaselineId);
328
+ const baseline = requestedBaselineId ? requestedBaseline : ctx.model;
329
+ const pricingBaseline = baseline && hasKnownPricing(baseline) ? `${baseline.provider}/${baseline.id}` : undefined;
330
+ const pricingBaselineMissing = requestedBaselineId && !pricingBaseline ? requestedBaselineId : undefined;
331
+ const allRows = models
332
+ .filter((model) => matchesModel(model, query))
333
+ .sort((a, b) => {
334
+ const providerCmp = String(a.provider).localeCompare(String(b.provider));
335
+ return providerCmp || a.id.localeCompare(b.id);
336
+ })
337
+ .map((model) => {
338
+ const fullId = `${model.provider}/${model.id}`;
339
+ const guidance = usageGuidance(model);
340
+ const unsupported = unsupportedById.get(fullId);
341
+ return {
342
+ provider: String(model.provider),
343
+ model: model.id,
344
+ fullId,
345
+ current: ctx.model ? ctx.model.provider === model.provider && ctx.model.id === model.id : false,
346
+ available: availableIds.has(fullId),
347
+ cycleEnabled: enabledIds.has(fullId),
348
+ context: formatTokenCount(model.contextWindow),
349
+ maxOut: formatTokenCount(model.maxTokens),
350
+ thinking: model.reasoning ? "yes" : "no",
351
+ images: model.input.includes("image") ? "yes" : "no",
352
+ cost: classifyCost(model),
353
+ quota: classifyQuota(model),
354
+ pricing: pricingForModel(model, baseline),
355
+ supported: unsupported === undefined,
356
+ unsupportedReason: unsupported?.reason,
357
+ useFor: guidance.useFor,
358
+ avoidFor: guidance.avoidFor,
359
+ };
360
+ });
361
+ const excludedUnsupportedRows = allRows.filter((row) => !row.supported);
362
+ const rows = unsupportedMode === "only"
363
+ ? excludedUnsupportedRows
364
+ : unsupportedMode === "include"
365
+ ? allRows
366
+ : allRows.filter((row) => row.supported);
367
+ return { rows, excludedUnsupportedRows, pricingBaseline, pricingBaselineMissing };
368
+ }
369
+
370
+ function table(result: ModelCatalogResult, includeDetails: boolean, unsupportedMode: UnsupportedMode, includePricing: boolean): string {
371
+ const rows = result.rows;
372
+ if (rows.length === 0) return result.excludedUnsupportedRows.length > 0 ? "No matching supported models. Call with unsupported: 'include' to show locally unsupported matches." : "No matching models.";
373
+ const headers = includePricing
374
+ ? ["model", "auth", "support", "enabled", "ctx", "out", "caps", "price-tier", "quota", "in$/M", "out$/M", "rel-in", "rel-out", "rel-blend"]
375
+ : ["model", "auth", "support", "enabled", "ctx", "out", "caps", "price-tier", "rel-cost", "quota"];
376
+ const body = rows.map((row) => {
377
+ const cells = [
378
+ `${row.current ? "*" : ""}${row.fullId}`,
379
+ row.available ? "yes" : "no",
380
+ row.supported ? "yes" : "no",
381
+ row.cycleEnabled ? "yes" : "no",
382
+ row.context,
383
+ row.maxOut,
384
+ formatCapabilities(row),
385
+ row.cost,
386
+ ];
387
+ if (!includePricing) {
388
+ cells.push(formatRatio(row.pricing.relativeBlended));
389
+ }
390
+ cells.push(row.quota);
391
+ if (includePricing) {
392
+ cells.push(
393
+ formatPricePerMillion(row.pricing.inputPerMillion, row.pricing.known),
394
+ formatPricePerMillion(row.pricing.outputPerMillion, row.pricing.known),
395
+ formatRatio(row.pricing.relativeInput),
396
+ formatRatio(row.pricing.relativeOutput),
397
+ formatRatio(row.pricing.relativeBlended),
398
+ );
399
+ }
400
+ return cells;
401
+ });
402
+ const widths = headers.map((header, index) => Math.max(header.length, ...body.map((cells) => cells[index].length)));
403
+ const lines = [headers.map((header, index) => header.padEnd(widths[index])).join(" ")];
404
+ for (const cells of body) {
405
+ lines.push(cells.map((cell, index) => cell.padEnd(widths[index])).join(" "));
406
+ }
407
+ if (result.pricingBaseline) {
408
+ lines.push("", includePricing
409
+ ? `Pricing: $/million tokens. Relative columns compare against ${result.pricingBaseline}; rel-blend uses input+output rates as a rough 1:1 token-mix weight.`
410
+ : `rel-cost compares input+output rates against ${result.pricingBaseline}; pass relativeTo/--relative-to to choose a different baseline.`);
411
+ } else if (result.pricingBaselineMissing) {
412
+ lines.push("", `Pricing: relative baseline '${result.pricingBaselineMissing}' was not found or has no numeric pricing.`);
413
+ } else if (includePricing) {
414
+ lines.push("", "Pricing: $/million tokens. Pass relativeTo: 'provider/model-id' to include relative cost ratios.");
415
+ }
416
+ if (includeDetails) {
417
+ lines.push("", "Usage guidance:");
418
+ for (const row of rows) {
419
+ const support = row.supported ? "" : ` Unsupported locally: ${row.unsupportedReason}.`;
420
+ const pricing = includePricing
421
+ ? ` Pricing: input ${formatPricePerMillion(row.pricing.inputPerMillion, row.pricing.known)}/M, output ${formatPricePerMillion(row.pricing.outputPerMillion, row.pricing.known)}/M${row.pricing.relativeTo ? `, relative to ${row.pricing.relativeTo}: input ${formatRatio(row.pricing.relativeInput)}, output ${formatRatio(row.pricing.relativeOutput)}, blended ${formatRatio(row.pricing.relativeBlended)}` : ""}.`
422
+ : "";
423
+ lines.push(`- ${row.fullId}: use for ${row.useFor}; avoid for ${row.avoidFor}.${pricing}${support}`);
424
+ }
425
+ }
426
+ if (unsupportedMode === "exclude" && result.excludedUnsupportedRows.length > 0) {
427
+ lines.push("", `Excluded ${result.excludedUnsupportedRows.length} locally unsupported model(s). Call with unsupported: 'include' to show them.`);
428
+ }
429
+ lines.push("", "Notes: price-tier uses input+output $/million-token rates from Pi's local model registry: low ≤ $1, medium ≤ $8, high ≤ $30, premium > $30; local models are free/local and spark models are premium-speed. Local models are usually free from API billing but can be slow and effectively serial/concurrency-constrained: avoid using multiple local models or many same-local-model tasks at once unless your local backend supports it. Numeric prices may be nominal weights for subscription-backed providers. Zero/blank pricing outside free/local can mean unknown, bundled, or non-metered rather than free. Quota is guidance, not live remaining quota. Support is a local compatibility hint, not provider live availability.");
430
+ return lines.join("\n");
431
+ }
432
+
433
+ const listPiModelsParameters = Type.Object({
434
+ query: Type.Optional(Type.String({ description: "Optional substring filter, e.g. 'mini', 'codex', 'sonnet'." })),
435
+ includeUnavailable: Type.Optional(Type.Boolean({ description: "Include models without configured auth. Default false." })),
436
+ includeDetails: Type.Optional(Type.Boolean({ description: "Include verbose use/avoid guidance for each returned model. Default false." })),
437
+ includePricing: Type.Optional(Type.Boolean({ description: "Include numeric pricing columns from Pi's model registry. Prices are $/million tokens. Default false." })),
438
+ relativeTo: Type.Optional(Type.String({ description: "Optional baseline model for relative pricing, e.g. 'openai-codex/gpt-5.4'. Use with includePricing." })),
439
+ unsupported: Type.Optional(Type.Union([
440
+ Type.Literal("exclude"),
441
+ Type.Literal("include"),
442
+ Type.Literal("only"),
443
+ ], { description: "How to handle locally unsupported models. Default 'exclude'." })),
444
+ });
445
+
446
+ export default function modelCatalog(pi: ExtensionAPI): void {
447
+ pi.registerTool({
448
+ name: "list_pi_models",
449
+ label: "List Pi Models",
450
+ description: "List available Pi models with concise decision fields by default, plus optional verbose guidance and numeric pricing.",
451
+ promptSnippet: "List/query Pi models, model-selection guidance, and optional pricing.",
452
+ promptGuidelines: [
453
+ "Use list_pi_models before choosing or recommending a model when current model availability, local support status, cost, quota, or capability matters.",
454
+ "For model overrides, choose rows with support yes and enabled yes unless the user explicitly authorizes configuration changes; auth yes alone only means credentials exist.",
455
+ "list_pi_models excludes locally unsupported models by default; use unsupported: 'include' or 'only' only for diagnostics.",
456
+ "list_pi_models price-tier/quota fields are guidance tiers, not live remaining quota.",
457
+ "Default output is intentionally concise; request includeDetails: true only when use/avoid prose would materially help selection.",
458
+ "When precise cost comparisons matter, pass includePricing: true and relativeTo: 'provider/model-id'. Treat numeric prices as local registry data, not guaranteed live billing for subscription-backed providers.",
459
+ ],
460
+ parameters: listPiModelsParameters,
461
+ async execute(_toolCallId: string, params: ListPiModelsParams, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
462
+ const includeUnavailable = params.includeUnavailable === true;
463
+ const includeDetails = params.includeDetails === true;
464
+ const unsupportedMode = params.unsupported ?? "exclude";
465
+ const includePricing = params.includePricing === true;
466
+ const result = toRows(ctx, includeUnavailable, unsupportedMode, params.query, params.relativeTo);
467
+ return {
468
+ content: [{ type: "text", text: table(result, includeDetails, unsupportedMode, includePricing) }],
469
+ details: {
470
+ models: result.rows,
471
+ excludedUnsupportedModels: result.excludedUnsupportedRows,
472
+ },
473
+ };
474
+ },
475
+ });
476
+
477
+ pi.registerCommand("models-guide", {
478
+ description: "Show available Pi models with concise defaults. Use --verbose for details, or --pricing and --relative-to provider/model-id for numeric ratios.",
479
+ handler: async (args: string, ctx: ExtensionContext) => {
480
+ const parsed = parseModelsGuideArgs(args);
481
+ const result = toRows(ctx, false, "exclude", parsed.query, parsed.relativeTo);
482
+ ctx.ui.notify(table(result, parsed.includeDetails === true, "exclude", parsed.includePricing === true), "info");
483
+ },
484
+ });
485
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@badliveware/pi-model-catalog",
3
+ "version": "0.1.0",
4
+ "description": "Expose Pi model listings and selection guidance as an agent tool.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "models"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "BadLiveware",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/BadLiveware/pi.git",
16
+ "directory": "agent/extensions/public/model-catalog"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/BadLiveware/pi/issues"
20
+ },
21
+ "homepage": "https://github.com/BadLiveware/pi/tree/main/agent/extensions/public/model-catalog#readme",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "files": [
26
+ "README.md",
27
+ "LICENSE",
28
+ "index.ts",
29
+ "package.json"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ]
35
+ },
36
+ "peerDependencies": {
37
+ "@mariozechner/pi-ai": "*",
38
+ "@mariozechner/pi-coding-agent": "*",
39
+ "typebox": "*"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ }
44
+ }