pi-qtools 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -131,6 +131,61 @@ Persisted to `<agentDir>/qtools.json` (migrated automatically from the older
131
131
  }
132
132
  ```
133
133
 
134
+ ## Model catalog overlay
135
+
136
+ pi ships its model list from a remote catalog that lags the plan. Measured against
137
+ the live `/models` route: the plan serves **21** ids, pi's catalog knew **10**. Four
138
+ of the missing ones are usable chat models that simply could not be selected.
139
+
140
+ This package overlays its own measured list on top of pi's, adding only what pi
141
+ lacks. It runs on every model refresh, so a pi update that drops or renames a model
142
+ gets corrected on the next refresh without a manual edit.
143
+
144
+ | added id | verified |
145
+ |---|---|
146
+ | `qwen4-max-preview` | chat, reasoning, image input, all 6 thinking levels |
147
+ | `deepseek-v4.1-flash` | chat, reasoning, image input, all 6 thinking levels |
148
+ | `deepseek-v4.1-flash-1017` | chat, reasoning, image input |
149
+ | `auto` | plan router; chat, reasoning, image input |
150
+
151
+ Every row was probed, not copied from a sibling model — the same rule the
152
+ capability table follows after `glm-5.3` shipped unmeasured and 400'd every turn.
153
+ That includes `enable_search`: all four accept it, and prompt-token inflation
154
+ (235-387 vs a ~10-28 baseline) confirms search actually ran rather than the flag
155
+ merely being tolerated. They inherit `search=true` from the Qwen family rule, so
156
+ this had to be measured rather than assumed.
157
+ `contextWindow`/`maxTokens` are plan-wide family values, not per-model measurements:
158
+ the endpoint accepts `max_tokens=999999` without complaint, so it cannot reveal the
159
+ real ceiling.
160
+
161
+ **Scope: `qwen-token-plan` and `qwen-token-plan-individual` only.** Both share the
162
+ ap-southeast-1 endpoint these rows were measured on. `qwen-token-plan-cn` serves
163
+ cn-beijing, a different regional deployment whose model list has never been probed
164
+ (the SG key returns 401 there), so it is deliberately excluded rather than handed
165
+ SG-measured ids. Add it only after probing its `/models` route with a CN credential.
166
+
167
+ **Generation and audio ids are deliberately excluded.** `wan2.7-image`,
168
+ `qwen-image-3.0-pro`, `wan3.0-video` and `qwen-audio-3.0-*` are not chat models;
169
+ adding them to pi's picker would create entries that 400 on every turn. This package
170
+ already drives them through their native endpoints.
171
+
172
+ ### Two rules that keep it non-destructive
173
+
174
+ pi's `refreshModels` return value **replaces the provider's entire model list**, so:
175
+
176
+ 1. pi's baseline is carried through verbatim, and **pi's own metadata wins** for any
177
+ id it already knows — its catalog is vendor-refreshed and may carry corrections.
178
+ 2. With no baseline to merge into, the overlay returns *nothing* and adds nothing.
179
+
180
+ Rule 2 was learned the hard way. The first implementation returned `[]` on that
181
+ path, which **wiped all 11 built-in plan models**. Measured with pi 1.5.0:
182
+ `pi -ne --list-models` showed 11, the extension showed 0. Returning nothing keeps
183
+ pi's list intact; the additions land on the next refresh once pi has a stored
184
+ catalog. Nothing is persisted to `models-store.json` — that file belongs to pi's
185
+ catalog refresh, and writing an overlay into it would corrupt its etag bookkeeping.
186
+
187
+ `/qtools-config` reports what was added under **Added plan models**.
188
+
134
189
  ## Verified capability matrix
135
190
 
136
191
  Measured against the live endpoint rather than taken from docs, because the two
@@ -39,6 +39,58 @@ import {
39
39
  UNKNOWN_CAPS,
40
40
  type Caps,
41
41
  } from "../src/capabilities.ts";
42
+ import { mergeCatalog, PLAN_PROVIDERS, QTOOLS_MODELS } from "../src/catalog.ts";
43
+
44
+ // ------------------------------------------------------- token-plan catalog
45
+
46
+ /**
47
+ * pi's remote model catalog lags the plan's /models route, so usable chat models
48
+ * are simply unselectable. This overlays the ids we have measured on top of pi's
49
+ * own list, adding only what pi lacks.
50
+ *
51
+ * Two rules make this safe:
52
+ * - refreshModels' return value REPLACES the provider's whole list, so pi's
53
+ * baseline is carried through verbatim and its metadata wins for known ids.
54
+ * - with no baseline to merge into we register nothing, rather than replacing
55
+ * pi's catalog with only our four rows.
56
+ *
57
+ * Nothing is persisted: models-store.json belongs to pi's catalog refresh, and
58
+ * writing our overlay into it would corrupt its etag/lastModified bookkeeping.
59
+ */
60
+ const catalogAdded = new Map<string, string[]>();
61
+
62
+ function registerCatalogOverlay(pi: ExtensionAPI): void {
63
+ for (const provider of PLAN_PROVIDERS) {
64
+ try {
65
+ pi.registerProvider(provider, {
66
+ async refreshModels(context) {
67
+ const merged = mergeCatalog(context.stored?.models);
68
+ if (merged.unsafe) {
69
+ // No baseline yet. Returning [] here is DESTRUCTIVE: measured with
70
+ // pi 1.5.0, an empty return wiped all 11 built-in plan models
71
+ // (pi -ne showed 11, this extension showed 0). Return nothing so pi
72
+ // keeps its own list; we add ours on the next refresh, once pi has
73
+ // a stored catalog to merge into. pi's own providers do the same.
74
+ catalogAdded.delete(provider);
75
+ return undefined as never;
76
+ }
77
+ if (merged.added.length > 0) catalogAdded.set(provider, merged.added);
78
+ else catalogAdded.delete(provider);
79
+ return merged.models as never[];
80
+ },
81
+ });
82
+ } catch {
83
+ // A provider pi does not know, or a registration it rejects, must never
84
+ // take the rest of the extension down with it.
85
+ }
86
+ }
87
+ }
88
+
89
+ /** What the overlay added, for /qtools-config. */
90
+ function catalogReport(): string {
91
+ if (catalogAdded.size === 0) return "pi's catalog already covers every measured model";
92
+ return [...catalogAdded.entries()].map(([p, ids]) => `${p}: +${ids.join(", ")}`).join(" | ");
93
+ }
42
94
 
43
95
  // ------------------------------------------------------------------ capability
44
96
 
@@ -1028,6 +1080,12 @@ function descriptionFor(id: string, ctx: ExtensionContext): string {
1028
1080
  return "Console capability labels differ from the API tools[].type strings. Sending a label is silently ignored.";
1029
1081
  case "provider":
1030
1082
  return "Where the API key and base URL come from, plus whether the active model is a Qwen plan model.";
1083
+ case "catalog":
1084
+ return (
1085
+ `Chat models this package adds on top of pi's own catalog, which lags the plan's /models route. ` +
1086
+ `${QTOOLS_MODELS.length} measured rows; pi's metadata always wins for ids it already knows. ` +
1087
+ `Generation/audio ids are deliberately excluded: they are not chat models and would 400 in the picker.`
1088
+ );
1031
1089
  }
1032
1090
  return "";
1033
1091
  }
@@ -1130,6 +1188,12 @@ function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
1130
1188
  currentValue: config.searchBlocklist.length ? config.searchBlocklist.join(", ") : "none",
1131
1189
  values: ["clear"],
1132
1190
  },
1191
+ {
1192
+ id: "catalog",
1193
+ label: "Added plan models",
1194
+ description: descriptionFor("catalog", ctx),
1195
+ currentValue: catalogReport(),
1196
+ },
1133
1197
  {
1134
1198
  id: "provider",
1135
1199
  label: "Provider",
@@ -1175,6 +1239,9 @@ function applyChange(id: string, value: string): void {
1175
1239
  loadConfig();
1176
1240
 
1177
1241
  export default function (pi: ExtensionAPI): void {
1242
+ // Fill in plan models pi's catalog has not caught up with yet.
1243
+ registerCatalogOverlay(pi);
1244
+
1178
1245
  // ---- main-loop web_search injection (chat/completions) ----
1179
1246
  pi.on("before_provider_request", (event, ctx) => {
1180
1247
  if (config.searchMode === "off" || !isQwen(ctx)) return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-qtools",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Qwen Token Plan bridge for pi: web search, scraping, code interpreter, image search, and image/video generation via DashScope built-in tools and native task endpoints.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -57,6 +57,6 @@
57
57
  ]
58
58
  },
59
59
  "scripts": {
60
- "test": "node --experimental-strip-types test/parse.check.mjs && node --experimental-strip-types test/caps.test.mjs"
60
+ "test": "node --experimental-strip-types test/parse.check.mjs && node --experimental-strip-types test/caps.test.mjs && node --experimental-strip-types test/catalog.test.mjs"
61
61
  }
62
62
  }
package/src/catalog.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The Token Plan model catalog, with NO imports at all — same discipline as
3
+ * capabilities.ts: pi-specific wiring lives in ../extensions/qtools.ts, and the
4
+ * decision logic here is unit-tested without pi, without the network, without a
5
+ * config file.
6
+ *
7
+ * WHY THIS EXISTS
8
+ * pi ships its model list from a remote catalog that lags the endpoint. Measured
9
+ * 2026-10-10 against the live /models route: the plan serves 21 ids, pi's catalog
10
+ * knew 10. The 11 missing included four usable chat models (qwen4-max-preview,
11
+ * deepseek-v4.1-flash, deepseek-v4.1-flash-1017, auto) that simply could not be
12
+ * selected in pi.
13
+ *
14
+ * WHAT IS DELIBERATELY NOT HERE
15
+ * The generation ids the endpoint also lists — wan2.7-image, wan2.7-image-pro,
16
+ * qwen-image-3.0-pro, wan3.0-video, qwen-audio-3.0-* — are NOT chat models. Adding
17
+ * them to pi's chat picker would create entries that 400 on every turn. This
18
+ * package already drives them through their native endpoints (qwen_generate_image,
19
+ * qwen_generate_video), which is the only surface they answer on.
20
+ *
21
+ * Every row below was probed, not copied from a sibling model: chat completion,
22
+ * reasoning_content, image input, and all six reasoning_effort levels. That is the
23
+ * same rule capabilities.ts follows after glm-5.3 shipped unmeasured and 400'd every
24
+ * turn. contextWindow/maxTokens are the plan-wide family values, not per-model
25
+ * measurements — the endpoint accepts max_tokens=999999 without complaint, so it
26
+ * cannot be used to discover the real ceiling.
27
+ */
28
+
29
+ /** Shape pi's registerProvider/refreshModels expects for a chat model. */
30
+ export interface CatalogModel {
31
+ id: string;
32
+ name: string;
33
+ type: "chat";
34
+ api: string;
35
+ reasoning: boolean;
36
+ input: Array<"text" | "image">;
37
+ contextWindow: number;
38
+ maxTokens: number;
39
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
40
+ compat: Record<string, unknown>;
41
+ thinkingLevelMap: Record<string, string | null>;
42
+ }
43
+
44
+ /** Subscription plan: tokens are prepaid, so per-token cost is zero. */
45
+ const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
46
+
47
+ /** Verified on every plan model probed; matches pi's own qwen3.8-max entry. */
48
+ const QWEN_COMPAT = {
49
+ supportsStrictMode: true,
50
+ thinkingFormat: "qwen",
51
+ supportsDeveloperRole: false,
52
+ supportsStore: false,
53
+ supportsReasoningEffort: true,
54
+ };
55
+
56
+ /** All six levels returned 200 on every model below, so none are mapped to null. */
57
+ const ALL_LEVELS = {
58
+ off: null,
59
+ minimal: "minimal",
60
+ low: "low",
61
+ medium: "medium",
62
+ high: "high",
63
+ xhigh: "xhigh",
64
+ max: "max",
65
+ };
66
+
67
+ const model = (id: string, name: string, maxTokens: number): CatalogModel => ({
68
+ id,
69
+ name,
70
+ type: "chat",
71
+ api: "openai-completions",
72
+ reasoning: true,
73
+ input: ["text", "image"],
74
+ contextWindow: 1000000,
75
+ maxTokens,
76
+ cost: ZERO_COST,
77
+ compat: QWEN_COMPAT,
78
+ thinkingLevelMap: ALL_LEVELS,
79
+ });
80
+
81
+ /**
82
+ * Chat models the plan serves that pi's catalog may lack.
83
+ * Probed 2026-10-10: all four answer chat, emit reasoning_content, accept image
84
+ * input (each correctly identified a test PNG as a pig), and accept every
85
+ * reasoning_effort level.
86
+ */
87
+ export const QTOOLS_MODELS: readonly CatalogModel[] = [
88
+ model("qwen4-max-preview", "Qwen4 Max (preview)", 131072),
89
+ model("deepseek-v4.1-flash", "DeepSeek V4.1 Flash", 131072),
90
+ model("deepseek-v4.1-flash-1017", "DeepSeek V4.1 Flash 1017", 131072),
91
+ // Router id: the plan picks the backing model per request.
92
+ model("auto", "Qwen Auto (plan router)", 131072),
93
+ ];
94
+
95
+ /**
96
+ * Provider ids this overlay applies to.
97
+ *
98
+ * Deliberately NOT all three plan providers: `qwen-token-plan-cn` serves
99
+ * cn-beijing, a different regional deployment whose model list has never been
100
+ * measured (the SG key returns 401 there). Pushing the SG-measured ids onto it
101
+ * would be exactly the unmeasured assumption that made glm-5.3 400 every turn.
102
+ * Both ids below share the ap-southeast-1 endpoint these rows were probed on.
103
+ * Add CN only after probing its /models route with a CN credential.
104
+ */
105
+ export const PLAN_PROVIDERS = [
106
+ "qwen-token-plan",
107
+ "qwen-token-plan-individual",
108
+ ] as const;
109
+
110
+ export interface MergeResult {
111
+ /** The full list to hand back to pi: baseline first, additions appended. */
112
+ models: unknown[];
113
+ /** Ids actually added, for logging and for the /qtools-config report. */
114
+ added: string[];
115
+ /**
116
+ * True when there was no baseline to merge into. Registering then would REPLACE
117
+ * pi's catalog with only our four rows, deleting every model pi knows about —
118
+ * so the caller must skip registration instead.
119
+ */
120
+ unsafe: boolean;
121
+ }
122
+
123
+ /**
124
+ * Merge our curated rows into pi's existing catalog, adding only ids pi lacks.
125
+ *
126
+ * pi's `models`/refreshModels return value REPLACES the provider's whole list, so
127
+ * the baseline must be carried through verbatim. pi's own metadata always wins for
128
+ * an id it already knows: its catalog is refreshed from the vendor and may carry
129
+ * corrections (context windows, compat flags) that our static rows do not.
130
+ */
131
+ export function mergeCatalog(
132
+ baseline: readonly unknown[] | undefined,
133
+ curated: readonly CatalogModel[] = QTOOLS_MODELS,
134
+ ): MergeResult {
135
+ if (!baseline || baseline.length === 0) {
136
+ return { models: [], added: [], unsafe: true };
137
+ }
138
+ // Dedupe the baseline too, first occurrence wins: pi's catalog should not contain
139
+ // repeats, but a duplicate id reaching registerProvider is worse than a dropped
140
+ // copy, and keeping the first preserves pi's own metadata and ordering.
141
+ const seen = new Set<string>();
142
+ const models: unknown[] = [];
143
+ for (const entry of baseline) {
144
+ const id = (entry as { id?: unknown })?.id;
145
+ if (typeof id === "string") {
146
+ if (seen.has(id)) continue;
147
+ seen.add(id);
148
+ }
149
+ models.push(entry);
150
+ }
151
+ const additions = curated.filter((m) => !seen.has(m.id));
152
+ for (const m of additions) seen.add(m.id);
153
+ return {
154
+ models: [...models, ...additions],
155
+ added: additions.map((m) => m.id),
156
+ unsafe: false,
157
+ };
158
+ }
159
+
160
+ /** Ids our catalog would add, without building the merged list. */
161
+ export function missingIds(
162
+ baseline: readonly unknown[] | undefined,
163
+ curated: readonly CatalogModel[] = QTOOLS_MODELS,
164
+ ): string[] {
165
+ return mergeCatalog(baseline, curated).added;
166
+ }