@badliveware/pi-model-catalog 0.1.0 → 0.2.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 +11 -0
- package/README.md +49 -59
- package/catalog.ts +447 -0
- package/index.ts +11 -436
- package/package.json +5 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Expose per-model supported Pi thinking levels in `list_pi_models` output and details.
|
|
6
|
+
- Shorten repeated `list_pi_models` output caveats and keep detailed interpretation guidance in the tool prompt guidelines.
|
|
7
|
+
- Update package imports and peer dependencies for the `@earendil-works/*` Pi packages.
|
|
8
|
+
|
|
9
|
+
## 0.1.1
|
|
10
|
+
|
|
11
|
+
- Initial public package release for Pi model catalog inspection.
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# pi-model-catalog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Adds a `list_pi_models` tool that lets agents inspect Pi's current model registry before choosing or recommending a model.
|
|
4
|
+
|
|
5
|
+
Use it when model choice should depend on what is actually available in your Pi setup: auth status, local support notes, enabled/cycling preferences, context size, capabilities, supported thinking levels, quota guidance, and optional price data.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -8,77 +10,69 @@ Exposes Pi's model registry to the agent as a tool so model choice can be based
|
|
|
8
10
|
pi install npm:@badliveware/pi-model-catalog
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
No external services or credentials are required beyond the model credentials already configured in Pi.
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
pi -e /path/to/pi/agent/extensions/public/model-catalog
|
|
15
|
-
```
|
|
15
|
+
## Quick use
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Ask the agent to call `list_pi_models` before choosing a model, or use the UI command:
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
```text
|
|
20
|
+
/models-guide mini
|
|
21
|
+
/models-guide --pricing --relative-to openai-codex/gpt-5.4-mini codex
|
|
22
|
+
```
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
Tool example:
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"query": "sonnet",
|
|
29
|
+
"includePricing": true,
|
|
30
|
+
"relativeTo": "openai-codex/gpt-5.4-mini"
|
|
31
|
+
}
|
|
32
|
+
```
|
|
24
33
|
|
|
25
|
-
|
|
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`
|
|
34
|
+
## What it returns
|
|
31
35
|
|
|
32
|
-
Default
|
|
36
|
+
Default output is intentionally compact:
|
|
33
37
|
|
|
34
|
-
- full model id
|
|
38
|
+
- full model id, with `*` on the current model
|
|
35
39
|
- `auth`, `support`, and `enabled` status
|
|
36
40
|
- context and max output tokens
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
- quota guidance
|
|
41
|
+
- capability marker: `text`, `think`, `img`, or `think+img`
|
|
42
|
+
- supported Pi thinking levels, shown as `off`, `min`, `low`, `med`, `high`, and `xhi` in the compact table
|
|
43
|
+
- price tier and rough relative cost
|
|
44
|
+
- quota/scarcity guidance
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
Pass `includeDetails: true` for verbose use/avoid guidance and full thinking level names. Pass `includePricing: true` for numeric input/output prices and relative ratios. Structured tool details include `thinkingLevels` and any model-specific `thinkingLevelMap`; Pi uses `off` for provider no/none thinking.
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
- verbose use/avoid guidance with `includeDetails`
|
|
48
|
+
## Tool parameters
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
| Parameter | What it does |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `query` | Optional substring filter such as `mini`, `codex`, or `sonnet`. |
|
|
53
|
+
| `includeUnavailable` | Include models without configured auth. Default: `false`. |
|
|
54
|
+
| `includeDetails` | Include verbose use/avoid guidance. Default: `false`. |
|
|
55
|
+
| `includePricing` | Include numeric registry prices in $/million tokens. Default: `false`. |
|
|
56
|
+
| `relativeTo` | Baseline model id for relative price ratios. |
|
|
57
|
+
| `unsupported` | `exclude`, `include`, or `only` locally unsupported models. Default: `exclude`. |
|
|
48
58
|
|
|
49
|
-
|
|
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 |
|
|
59
|
+
## Command
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
| Command | What it does |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `/models-guide [query]` | Show the concise available-model table. |
|
|
64
|
+
| `/models-guide --verbose [query]` | Include verbose use/avoid guidance. |
|
|
65
|
+
| `/models-guide --pricing --relative-to <model> [query]` | Include numeric pricing and ratios. |
|
|
62
66
|
|
|
63
|
-
|
|
67
|
+
## Local support notes
|
|
64
68
|
|
|
65
|
-
|
|
69
|
+
Some providers report a model as authenticated even when a specific account cannot use it. Add local unsupported-model notes in:
|
|
66
70
|
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
"query": "openai-codex gpt-5.4",
|
|
70
|
-
"includePricing": true,
|
|
71
|
-
"relativeTo": "openai-codex/gpt-5.4"
|
|
72
|
-
}
|
|
71
|
+
```text
|
|
72
|
+
~/.pi/agent/model-catalog.json
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
|
|
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`:
|
|
75
|
+
Example:
|
|
82
76
|
|
|
83
77
|
```json
|
|
84
78
|
{
|
|
@@ -91,12 +85,8 @@ You can add local unsupported entries in `~/.pi/agent/model-catalog.json`:
|
|
|
91
85
|
}
|
|
92
86
|
```
|
|
93
87
|
|
|
94
|
-
|
|
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
|
|
88
|
+
Unsupported models are excluded by default so agents do not choose them accidentally.
|
|
99
89
|
|
|
100
|
-
##
|
|
90
|
+
## Price and quota caveats
|
|
101
91
|
|
|
102
|
-
|
|
92
|
+
`price-tier`, numeric prices, and quota labels are guidance from Pi's local model registry, not live billing or remaining quota. For subscription-backed providers, numeric prices may be nominal weights rather than direct billing.
|
package/catalog.ts
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
export type UnsupportedMode = "exclude" | "include" | "only";
|
|
8
|
+
|
|
9
|
+
export interface ListPiModelsParams {
|
|
10
|
+
query?: string;
|
|
11
|
+
includeUnavailable?: boolean;
|
|
12
|
+
includeDetails?: boolean;
|
|
13
|
+
includePricing?: boolean;
|
|
14
|
+
relativeTo?: string;
|
|
15
|
+
unsupported?: UnsupportedMode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ModelCatalogSettings {
|
|
19
|
+
enabledModels?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ModelCatalogConfig {
|
|
23
|
+
unsupportedModels?: Array<string | { model?: string; fullId?: string; reason?: string }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface UnsupportedModelInfo {
|
|
27
|
+
reason: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ModelPricing {
|
|
31
|
+
inputPerMillion: number;
|
|
32
|
+
outputPerMillion: number;
|
|
33
|
+
cacheReadPerMillion: number;
|
|
34
|
+
cacheWritePerMillion: number;
|
|
35
|
+
known: boolean;
|
|
36
|
+
relativeTo?: string;
|
|
37
|
+
relativeInput?: number;
|
|
38
|
+
relativeOutput?: number;
|
|
39
|
+
relativeCacheRead?: number;
|
|
40
|
+
relativeBlended?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ModelCatalogRow {
|
|
44
|
+
provider: string;
|
|
45
|
+
model: string;
|
|
46
|
+
fullId: string;
|
|
47
|
+
current: boolean;
|
|
48
|
+
available: boolean;
|
|
49
|
+
cycleEnabled: boolean;
|
|
50
|
+
context: string;
|
|
51
|
+
maxOut: string;
|
|
52
|
+
thinking: string;
|
|
53
|
+
thinkingLevels: string[];
|
|
54
|
+
thinkingLevelMap?: Partial<Record<string, string | null>>;
|
|
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 formatThinkingLevels(levels: string[]): string {
|
|
127
|
+
const labels: Record<string, string> = {
|
|
128
|
+
off: "off",
|
|
129
|
+
minimal: "min",
|
|
130
|
+
low: "low",
|
|
131
|
+
medium: "med",
|
|
132
|
+
high: "high",
|
|
133
|
+
xhigh: "xhi",
|
|
134
|
+
};
|
|
135
|
+
return levels.map((level) => labels[level] ?? level).join(",") || "—";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function shellWords(input: string): string[] {
|
|
139
|
+
const words: string[] = [];
|
|
140
|
+
let current = "";
|
|
141
|
+
let quote: '"' | "'" | undefined;
|
|
142
|
+
let escaping = false;
|
|
143
|
+
for (const char of input) {
|
|
144
|
+
if (escaping) {
|
|
145
|
+
current += char;
|
|
146
|
+
escaping = false;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (char === "\\" && quote !== "'") {
|
|
150
|
+
escaping = true;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if ((char === '"' || char === "'") && (!quote || quote === char)) {
|
|
154
|
+
quote = quote ? undefined : char;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (!quote && /\s/.test(char)) {
|
|
158
|
+
if (current) words.push(current);
|
|
159
|
+
current = "";
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
current += char;
|
|
163
|
+
}
|
|
164
|
+
if (current) words.push(current);
|
|
165
|
+
return words;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function parseModelsGuideArgs(args: string): Pick<ListPiModelsParams, "query" | "includeDetails" | "includePricing" | "relativeTo"> {
|
|
169
|
+
const queryParts: string[] = [];
|
|
170
|
+
let includeDetails = false;
|
|
171
|
+
let includePricing = false;
|
|
172
|
+
let relativeTo: string | undefined;
|
|
173
|
+
const words = shellWords(args);
|
|
174
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
175
|
+
const word = words[index];
|
|
176
|
+
if (word === "--verbose" || word === "--details" || word === "-v") {
|
|
177
|
+
includeDetails = true;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (word === "--pricing" || word === "--prices" || word === "-p") {
|
|
181
|
+
includePricing = true;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (word === "--relative-to" || word === "--relative" || word === "--baseline") {
|
|
185
|
+
relativeTo = words[index + 1];
|
|
186
|
+
index += 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const relativeMatch = word.match(/^--(?:relative-to|relative|baseline)=(.+)$/);
|
|
190
|
+
if (relativeMatch) {
|
|
191
|
+
relativeTo = relativeMatch[1];
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
queryParts.push(word);
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
query: queryParts.join(" ") || undefined,
|
|
198
|
+
includeDetails,
|
|
199
|
+
includePricing,
|
|
200
|
+
relativeTo,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function matchesModel(model: Model<Api>, query: string | undefined): boolean {
|
|
205
|
+
if (!query?.trim()) return true;
|
|
206
|
+
const needle = query.trim().toLowerCase();
|
|
207
|
+
return `${model.provider} ${model.id} ${model.name}`.toLowerCase().includes(needle);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isSparkModel(model: Model<Api>): boolean {
|
|
211
|
+
return /(?:^|[-_])spark(?:$|[-_])/.test(model.id.toLowerCase());
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isLocalModel(model: Model<Api>): boolean {
|
|
215
|
+
const provider = String(model.provider).toLowerCase();
|
|
216
|
+
return provider.startsWith("local-") || provider.includes("llamaswap");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function classifyCost(model: Model<Api>): string {
|
|
220
|
+
if (isLocalModel(model)) return "free/local";
|
|
221
|
+
if (isSparkModel(model)) return "premium-speed";
|
|
222
|
+
const cost = model.cost;
|
|
223
|
+
const output = cost?.output ?? 0;
|
|
224
|
+
const input = cost?.input ?? 0;
|
|
225
|
+
const blended = input + output;
|
|
226
|
+
if (blended <= 0) return "unknown/sub";
|
|
227
|
+
if (blended <= 1) return "low";
|
|
228
|
+
if (blended <= 8) return "medium";
|
|
229
|
+
if (blended <= 30) return "high";
|
|
230
|
+
return "premium";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function classifyQuota(model: Model<Api>): string {
|
|
234
|
+
const id = model.id.toLowerCase();
|
|
235
|
+
const provider = String(model.provider).toLowerCase();
|
|
236
|
+
if (isLocalModel(model)) return "local-serial";
|
|
237
|
+
if (isSparkModel(model)) return "very-fast";
|
|
238
|
+
if (/mini|flash|lite|haiku|small/.test(id)) return "fast/limited";
|
|
239
|
+
if (/opus|pro|gpt-5\.5|o3|sonnet|grok-4/.test(id)) return "scarce";
|
|
240
|
+
if (provider.includes("codex") || provider.includes("copilot")) return "subscription";
|
|
241
|
+
return "standard";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function modelProfile(model: Model<Api>): "local" | "latency" | "fast" | "standard" | "strong" {
|
|
245
|
+
const id = model.id.toLowerCase();
|
|
246
|
+
if (isLocalModel(model)) return "local";
|
|
247
|
+
if (isSparkModel(model)) return "latency";
|
|
248
|
+
if (/mini|flash|lite|haiku|small/.test(id)) return "fast";
|
|
249
|
+
if (/opus|pro|gpt-5\.5|gpt-5\.4(?!-mini)|o3|sonnet|grok-4/.test(id)) return "strong";
|
|
250
|
+
return "standard";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function usageGuidance(model: Model<Api>): Pick<ModelCatalogRow, "useFor" | "avoidFor"> {
|
|
254
|
+
const profile = modelProfile(model);
|
|
255
|
+
if (profile === "local") {
|
|
256
|
+
return {
|
|
257
|
+
useFor: "local background work when latency is acceptable, concurrency is low, and the configured model's capability is sufficient",
|
|
258
|
+
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",
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (profile === "latency") {
|
|
262
|
+
return {
|
|
263
|
+
useFor: "latency-critical bounded tasks and quick scouts when very high speed is worth premium cost",
|
|
264
|
+
avoidFor: "cheap routine delegation, bulk mechanical work, cost-sensitive tasks, risky architecture, final review",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (profile === "fast") {
|
|
268
|
+
return {
|
|
269
|
+
useFor: "routine edits, search, summaries, tests, bounded subagent tasks when low cost or lower scarcity matters",
|
|
270
|
+
avoidFor: "risky architecture, subtle debugging, final review of high-impact changes",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (profile === "strong") {
|
|
274
|
+
return {
|
|
275
|
+
useFor: "hard reasoning, risky refactors, architecture, adversarial review",
|
|
276
|
+
avoidFor: "mechanical searches or easy-to-verify chores when faster models suffice",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
useFor: "default implementation, debugging, review, moderate planning",
|
|
281
|
+
avoidFor: "very mechanical chores if a faster model is available; highest-risk work if a stronger model is available",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function unsupportedModels(): Map<string, UnsupportedModelInfo> {
|
|
286
|
+
const unsupported = new Map<string, UnsupportedModelInfo>();
|
|
287
|
+
for (const entry of readModelCatalogConfig().unsupportedModels ?? []) {
|
|
288
|
+
if (typeof entry === "string") {
|
|
289
|
+
unsupported.set(entry, { reason: "marked unsupported in model-catalog.json" });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const fullId = entry.fullId ?? entry.model;
|
|
293
|
+
if (!fullId) continue;
|
|
294
|
+
unsupported.set(fullId, { reason: entry.reason ?? "marked unsupported in model-catalog.json" });
|
|
295
|
+
}
|
|
296
|
+
return unsupported;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function findModelById(ctx: ExtensionContext, id: string | undefined): Model<Api> | undefined {
|
|
300
|
+
const needle = id?.trim();
|
|
301
|
+
if (!needle) return undefined;
|
|
302
|
+
for (const model of ctx.modelRegistry.getAll()) {
|
|
303
|
+
const fullId = `${model.provider}/${model.id}`;
|
|
304
|
+
if (fullId === needle || model.id === needle) return model;
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function hasKnownPricing(model: Model<Api>): boolean {
|
|
310
|
+
return isLocalModel(model) || model.cost.input > 0 || model.cost.output > 0 || model.cost.cacheRead > 0 || model.cost.cacheWrite > 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function pricingForModel(model: Model<Api>, baseline: Model<Api> | undefined): ModelPricing {
|
|
314
|
+
const known = hasKnownPricing(model);
|
|
315
|
+
const baselineKnown = baseline ? hasKnownPricing(baseline) : false;
|
|
316
|
+
const baselineCost = baselineKnown ? baseline?.cost : undefined;
|
|
317
|
+
const modelBlend = model.cost.input + model.cost.output;
|
|
318
|
+
const baselineBlend = baselineCost ? baselineCost.input + baselineCost.output : 0;
|
|
319
|
+
return {
|
|
320
|
+
inputPerMillion: model.cost.input,
|
|
321
|
+
outputPerMillion: model.cost.output,
|
|
322
|
+
cacheReadPerMillion: model.cost.cacheRead,
|
|
323
|
+
cacheWritePerMillion: model.cost.cacheWrite,
|
|
324
|
+
known,
|
|
325
|
+
relativeTo: baseline && baselineKnown ? `${baseline.provider}/${baseline.id}` : undefined,
|
|
326
|
+
relativeInput: known && baselineCost && baselineCost.input > 0 ? model.cost.input / baselineCost.input : undefined,
|
|
327
|
+
relativeOutput: known && baselineCost && baselineCost.output > 0 ? model.cost.output / baselineCost.output : undefined,
|
|
328
|
+
relativeCacheRead: known && baselineCost && baselineCost.cacheRead > 0 ? model.cost.cacheRead / baselineCost.cacheRead : undefined,
|
|
329
|
+
relativeBlended: known && baselineCost && baselineBlend > 0 ? modelBlend / baselineBlend : undefined,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function toRows(ctx: ExtensionContext, includeUnavailable: boolean, unsupportedMode: UnsupportedMode, query?: string, relativeTo?: string): ModelCatalogResult {
|
|
334
|
+
const availableIds = new Set(ctx.modelRegistry.getAvailable().map((model) => `${model.provider}/${model.id}`));
|
|
335
|
+
const enabledIds = new Set(readSettings().enabledModels ?? []);
|
|
336
|
+
const unsupportedById = unsupportedModels();
|
|
337
|
+
const models = includeUnavailable ? ctx.modelRegistry.getAll() : ctx.modelRegistry.getAvailable();
|
|
338
|
+
const requestedBaselineId = relativeTo?.trim();
|
|
339
|
+
const requestedBaseline = findModelById(ctx, requestedBaselineId);
|
|
340
|
+
const baseline = requestedBaselineId ? requestedBaseline : ctx.model;
|
|
341
|
+
const pricingBaseline = baseline && hasKnownPricing(baseline) ? `${baseline.provider}/${baseline.id}` : undefined;
|
|
342
|
+
const pricingBaselineMissing = requestedBaselineId && !pricingBaseline ? requestedBaselineId : undefined;
|
|
343
|
+
const allRows = models
|
|
344
|
+
.filter((model) => matchesModel(model, query))
|
|
345
|
+
.sort((a, b) => {
|
|
346
|
+
const providerCmp = String(a.provider).localeCompare(String(b.provider));
|
|
347
|
+
return providerCmp || a.id.localeCompare(b.id);
|
|
348
|
+
})
|
|
349
|
+
.map((model) => {
|
|
350
|
+
const fullId = `${model.provider}/${model.id}`;
|
|
351
|
+
const guidance = usageGuidance(model);
|
|
352
|
+
const unsupported = unsupportedById.get(fullId);
|
|
353
|
+
const thinkingLevels = getSupportedThinkingLevels(model);
|
|
354
|
+
return {
|
|
355
|
+
provider: String(model.provider),
|
|
356
|
+
model: model.id,
|
|
357
|
+
fullId,
|
|
358
|
+
current: ctx.model ? ctx.model.provider === model.provider && ctx.model.id === model.id : false,
|
|
359
|
+
available: availableIds.has(fullId),
|
|
360
|
+
cycleEnabled: enabledIds.has(fullId),
|
|
361
|
+
context: formatTokenCount(model.contextWindow),
|
|
362
|
+
maxOut: formatTokenCount(model.maxTokens),
|
|
363
|
+
thinking: model.reasoning ? "yes" : "no",
|
|
364
|
+
thinkingLevels,
|
|
365
|
+
thinkingLevelMap: model.thinkingLevelMap ? { ...model.thinkingLevelMap } : undefined,
|
|
366
|
+
images: model.input.includes("image") ? "yes" : "no",
|
|
367
|
+
cost: classifyCost(model),
|
|
368
|
+
quota: classifyQuota(model),
|
|
369
|
+
pricing: pricingForModel(model, baseline),
|
|
370
|
+
supported: unsupported === undefined,
|
|
371
|
+
unsupportedReason: unsupported?.reason,
|
|
372
|
+
useFor: guidance.useFor,
|
|
373
|
+
avoidFor: guidance.avoidFor,
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
const excludedUnsupportedRows = allRows.filter((row) => !row.supported);
|
|
377
|
+
const rows = unsupportedMode === "only"
|
|
378
|
+
? excludedUnsupportedRows
|
|
379
|
+
: unsupportedMode === "include"
|
|
380
|
+
? allRows
|
|
381
|
+
: allRows.filter((row) => row.supported);
|
|
382
|
+
return { rows, excludedUnsupportedRows, pricingBaseline, pricingBaselineMissing };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function table(result: ModelCatalogResult, includeDetails: boolean, unsupportedMode: UnsupportedMode, includePricing: boolean): string {
|
|
386
|
+
const rows = result.rows;
|
|
387
|
+
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.";
|
|
388
|
+
const headers = includePricing
|
|
389
|
+
? ["model", "auth", "support", "enabled", "ctx", "out", "caps", "think-levels", "price-tier", "quota", "in$/M", "out$/M", "rel-in", "rel-out", "rel-blend"]
|
|
390
|
+
: ["model", "auth", "support", "enabled", "ctx", "out", "caps", "think-levels", "price-tier", "rel-cost", "quota"];
|
|
391
|
+
const body = rows.map((row) => {
|
|
392
|
+
const cells = [
|
|
393
|
+
`${row.current ? "*" : ""}${row.fullId}`,
|
|
394
|
+
row.available ? "yes" : "no",
|
|
395
|
+
row.supported ? "yes" : "no",
|
|
396
|
+
row.cycleEnabled ? "yes" : "no",
|
|
397
|
+
row.context,
|
|
398
|
+
row.maxOut,
|
|
399
|
+
formatCapabilities(row),
|
|
400
|
+
formatThinkingLevels(row.thinkingLevels),
|
|
401
|
+
row.cost,
|
|
402
|
+
];
|
|
403
|
+
if (!includePricing) {
|
|
404
|
+
cells.push(formatRatio(row.pricing.relativeBlended));
|
|
405
|
+
}
|
|
406
|
+
cells.push(row.quota);
|
|
407
|
+
if (includePricing) {
|
|
408
|
+
cells.push(
|
|
409
|
+
formatPricePerMillion(row.pricing.inputPerMillion, row.pricing.known),
|
|
410
|
+
formatPricePerMillion(row.pricing.outputPerMillion, row.pricing.known),
|
|
411
|
+
formatRatio(row.pricing.relativeInput),
|
|
412
|
+
formatRatio(row.pricing.relativeOutput),
|
|
413
|
+
formatRatio(row.pricing.relativeBlended),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return cells;
|
|
417
|
+
});
|
|
418
|
+
const widths = headers.map((header, index) => Math.max(header.length, ...body.map((cells) => cells[index].length)));
|
|
419
|
+
const lines = [headers.map((header, index) => header.padEnd(widths[index])).join(" ")];
|
|
420
|
+
for (const cells of body) {
|
|
421
|
+
lines.push(cells.map((cell, index) => cell.padEnd(widths[index])).join(" "));
|
|
422
|
+
}
|
|
423
|
+
if (result.pricingBaseline) {
|
|
424
|
+
lines.push("", includePricing
|
|
425
|
+
? `Pricing: $/M; rel-* ratios compare against ${result.pricingBaseline}.`
|
|
426
|
+
: `rel-cost: rough local-registry cost vs ${result.pricingBaseline}; pass relativeTo to choose a different baseline.`);
|
|
427
|
+
} else if (result.pricingBaselineMissing) {
|
|
428
|
+
lines.push("", `Pricing: baseline '${result.pricingBaselineMissing}' was not found or lacks numeric pricing.`);
|
|
429
|
+
} else if (includePricing) {
|
|
430
|
+
lines.push("", "Pricing: $/M. Pass relativeTo: 'provider/model-id' to include ratios.");
|
|
431
|
+
}
|
|
432
|
+
if (includeDetails) {
|
|
433
|
+
lines.push("", "Usage guidance:");
|
|
434
|
+
for (const row of rows) {
|
|
435
|
+
const support = row.supported ? "" : ` Unsupported locally: ${row.unsupportedReason}.`;
|
|
436
|
+
const pricing = includePricing
|
|
437
|
+
? ` 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)}` : ""}.`
|
|
438
|
+
: "";
|
|
439
|
+
lines.push(`- ${row.fullId}: thinking levels ${row.thinkingLevels.join(", ") || "none"}; use for ${row.useFor}; avoid for ${row.avoidFor}.${pricing}${support}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (unsupportedMode === "exclude" && result.excludedUnsupportedRows.length > 0) {
|
|
443
|
+
lines.push("", `Excluded ${result.excludedUnsupportedRows.length} unsupported model(s); pass unsupported: 'include' to show them.`);
|
|
444
|
+
}
|
|
445
|
+
lines.push("", "Legend: think-levels use Pi abbreviations; price/quota/support are local guidance, not live guarantees.");
|
|
446
|
+
return lines.join("\n");
|
|
447
|
+
}
|
package/index.ts
CHANGED
|
@@ -1,434 +1,7 @@
|
|
|
1
|
-
import
|
|
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";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
2
|
// @ts-ignore The Pi extension runtime provides typebox, but this package does not ship declarations in current Pi installs.
|
|
7
3
|
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
|
-
}
|
|
4
|
+
import { parseModelsGuideArgs, table, toRows, type ListPiModelsParams, type UnsupportedMode } from "./catalog.ts";
|
|
432
5
|
|
|
433
6
|
const listPiModelsParameters = Type.Object({
|
|
434
7
|
query: Type.Optional(Type.String({ description: "Optional substring filter, e.g. 'mini', 'codex', 'sonnet'." })),
|
|
@@ -447,21 +20,23 @@ export default function modelCatalog(pi: ExtensionAPI): void {
|
|
|
447
20
|
pi.registerTool({
|
|
448
21
|
name: "list_pi_models",
|
|
449
22
|
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.",
|
|
23
|
+
description: "List available Pi models with concise decision fields by default, plus supported thinking levels, optional verbose guidance, and numeric pricing.",
|
|
24
|
+
promptSnippet: "List/query Pi models, supported thinking levels, model-selection guidance, and optional pricing.",
|
|
452
25
|
promptGuidelines: [
|
|
453
|
-
"Use list_pi_models before choosing or recommending a model when current model availability, local support status, cost, quota, or capability matters.",
|
|
26
|
+
"Use list_pi_models before choosing or recommending a model when current model availability, local support status, cost, quota, thinking levels, or capability matters.",
|
|
454
27
|
"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
28
|
"list_pi_models excludes locally unsupported models by default; use unsupported: 'include' or 'only' only for diagnostics.",
|
|
456
|
-
"list_pi_models
|
|
29
|
+
"Interpret list_pi_models fields as local guidance: support is local compatibility, quota is not live remaining quota, numeric pricing can be nominal/unknown, and rel-cost/rel-blend are rough local-registry ratios.",
|
|
30
|
+
"list_pi_models thinking levels are Pi names: off means provider no/none thinking when supported, and compact table abbreviations min/med/xhi mean minimal/medium/xhigh.",
|
|
31
|
+
"Treat local/free models as potentially slow or serial unless the local backend is known to support concurrent use; spark models are premium-speed, not cheap/mini substitutes.",
|
|
457
32
|
"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'.
|
|
33
|
+
"When precise cost comparisons matter, pass includePricing: true and relativeTo: 'provider/model-id'.",
|
|
459
34
|
],
|
|
460
35
|
parameters: listPiModelsParameters,
|
|
461
36
|
async execute(_toolCallId: string, params: ListPiModelsParams, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
462
37
|
const includeUnavailable = params.includeUnavailable === true;
|
|
463
38
|
const includeDetails = params.includeDetails === true;
|
|
464
|
-
const unsupportedMode = params.unsupported ?? "exclude";
|
|
39
|
+
const unsupportedMode: UnsupportedMode = params.unsupported ?? "exclude";
|
|
465
40
|
const includePricing = params.includePricing === true;
|
|
466
41
|
const result = toRows(ctx, includeUnavailable, unsupportedMode, params.query, params.relativeTo);
|
|
467
42
|
return {
|
|
@@ -475,7 +50,7 @@ export default function modelCatalog(pi: ExtensionAPI): void {
|
|
|
475
50
|
});
|
|
476
51
|
|
|
477
52
|
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.",
|
|
53
|
+
description: "Show available Pi models and supported thinking levels with concise defaults. Use --verbose for details, or --pricing and --relative-to provider/model-id for numeric ratios.",
|
|
479
54
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
480
55
|
const parsed = parseModelsGuideArgs(args);
|
|
481
56
|
const result = toRows(ctx, false, "exclude", parsed.query, parsed.relativeTo);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@badliveware/pi-model-catalog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Expose Pi model listings and selection guidance as an agent tool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"README.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
27
28
|
"LICENSE",
|
|
28
29
|
"index.ts",
|
|
30
|
+
"catalog.ts",
|
|
29
31
|
"package.json"
|
|
30
32
|
],
|
|
31
33
|
"pi": {
|
|
@@ -34,8 +36,8 @@
|
|
|
34
36
|
]
|
|
35
37
|
},
|
|
36
38
|
"peerDependencies": {
|
|
37
|
-
"@
|
|
38
|
-
"@
|
|
39
|
+
"@earendil-works/pi-ai": "*",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
39
41
|
"typebox": "*"
|
|
40
42
|
},
|
|
41
43
|
"engines": {
|