pi-qtools 0.2.0 → 0.2.2

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
@@ -16,7 +16,7 @@ Requires a `qwen-token-plan*` provider authenticated in pi (`/login`).
16
16
  | `qwen_web_search` | `/responses` `web_search` | answer + queries issued + source URLs |
17
17
  | `qwen_web_extractor` | `/responses` `web_search` + `web_extractor` | page text filtered to a stated goal; the API requires the search tool alongside |
18
18
  | `qwen_code_interpreter` | `/responses` `code_interpreter` | returns the Python it ran, container id, real stdout |
19
- | `qwen_image_search` | `/responses` `web_search_image` | text → image URLs. **qwen3.8-max only** |
19
+ | `qwen_image_search` | `/responses` `web_search_image` | text → image URLs. Runs on flash tiers too (see the matrix) |
20
20
  | `qwen_reverse_image_search` | `/responses` `image_search` | image URL or local file → similar images; accepts a local path and inlines it as a data URL (4 MB cap) |
21
21
  | `qwen_deep_search` | chat `search_strategy: "agent"` | multi-step research; very expensive (~340k prompt tokens observed) |
22
22
  | `qwen_generate_image` | native `multimodal-generation` | `wan2.7-image`, `wan2.7-image-pro`, `qwen-image-3.0-pro`; sync; `image` edits a reference picture; `save: true` writes into `<cwd>/out/qtools/` |
@@ -122,11 +122,12 @@ Persisted to `<agentDir>/qtools.json` (migrated automatically from the older
122
122
  ```json
123
123
  {
124
124
  "searchMode": "turbo",
125
- "fallbackModel": "qwen3.7-max",
125
+ "fallbackModel": "qwen3.6-flash",
126
126
  "showReasoning": false,
127
127
  "deepSearchMaxTokens": 16384,
128
128
  "imageModel": "wan2.7-image",
129
- "videoModel": "happyhorse-1.1-t2v"
129
+ "videoModel": "happyhorse-1.1-t2v",
130
+ "searchBlocklist": []
130
131
  }
131
132
  ```
132
133
 
@@ -137,16 +138,128 @@ surfaces differ per model:
137
138
 
138
139
  | model | chat search | agent | chat code_int | `/responses` tools | `web_search_image` | `image_search` |
139
140
  |---|---|---|---|---|---|---|
140
- | qwen3.8-max | yes | no | no | all | **yes** | yes |
141
- | qwen3.8-flash | yes | no | no | s/c/e | no | yes |
142
- | qwen3.7-max | yes | yes | yes | s/c/e | no | no |
143
- | qwen3.7-plus | yes | yes | yes | s/c/e | no | yes |
144
- | qwen3.6-flash | yes | yes | yes | s/c/e | no | yes |
141
+ | qwen3.8-max | yes | no | no | all | yes | yes |
142
+ | qwen3.8-flash | yes | no | no | all | **yes** | yes |
143
+ | qwen3.7-max | yes | yes | yes | s/c/e | **no** | **no** |
144
+ | qwen3.7-plus | yes | yes | yes | all | **yes** | yes |
145
+ | qwen3.6-flash | yes | yes | yes | all | **yes** | **yes** |
145
146
  | deepseek-v4-* | yes | yes | yes | s/c/e | no | no |
147
+ | deepseek-v4.1-flash | yes | (inherited) | yes (streaming) | s/c/e | no | no |
146
148
  | glm-5.2 | **no** | no | no | s/c/e | no | no |
149
+ | glm-5.3 | **no** | no | no | s/c/e | no | no |
150
+ | auto (plan router) | yes | unknown | unknown | s/c/e | no | no |
151
+ | anything unmeasured | **no** | no | no | s/c/e | no | no |
152
+
153
+ Two corrections from the 2026-10-10 remeasurement, both of which were load-bearing bugs:
154
+
155
+ - **`web_search_image` is not qwen3.8-max-only.** The table used to claim that, and it
156
+ is wrong: qwen3.6-flash, qwen3.7-plus and qwen3.8-flash all fire `web_search_image_call`.
157
+ - **qwen3.7-max cannot run either image tool** — it 400s on both. Since it was the
158
+ hardcoded last resort in the pickers, a GLM model asking for image search used to be
159
+ routed to a model that then failed the call, on the most expensive tier on the plan.
160
+
161
+ `agent` = chat `search_strategy: "agent"` (qwen_deep_search). `s/c/e` = `/responses`
162
+ web_search / code_interpreter / web_extractor. Cells marked (inherited) come from the
163
+ family rule, not from a direct measurement of that id.
164
+
165
+ Capability state lives in `src/capabilities.ts` (the measured table, the family rules, the
166
+ fail-safe default, the fallback chain) and imports nothing, so it is testable with no pi and
167
+ no network. `extensions/qtools.ts` owns everything else: config file, HTTP, tools, TUI.
168
+
169
+ `glm-5.2` and `glm-5.3` reject `enable_search` with a hard error, so injection is
170
+ skipped for them; without that guard every turn would fail after a model switch. They
171
+ can still *use* the search tools: `/responses` web_search, code_interpreter and
172
+ web_extractor all run on a GLM model — only the passive chat injection is lost.
173
+
174
+ ### Which model runs a tool
175
+
176
+ `requested -> active -> config.fallbackModel -> FALLBACK_CHAIN`, first capable id wins:
177
+
178
+ - The **active** model is used whenever its row allows the tool, so selecting a model
179
+ is not a silent invitation to bill a different one — and the answering model is also
180
+ the model that writes the search queries and reads the pages.
181
+ - Only when the active row says no does the chain step off it, and it steps onto the
182
+ **cheapest capable** id, not a fixed one. The chain is `qwen3.6-flash -> qwen3.8-flash
183
+ -> qwen3.7-plus -> deepseek-v4-flash -> deepseek-v4.1-flash -> qwen3.7-max ->
184
+ qwen3.8-max`, led by `config.fallbackModel` (default `qwen3.6-flash`, the cheapest id
185
+ measured running every surface). Max tiers sit at the end because the plan bills them
186
+ at several times a flash tier.
187
+ - If nothing can serve the capability, the tool **errors with the list of models that
188
+ were tried** instead of quietly calling a model that will 400.
189
+
190
+ Passive injection has no such choice: it rides inside the active model's own
191
+ `/chat/completions` body, which is why the capability table exists at all.
192
+
193
+ ### How an unknown model id is handled
194
+
195
+ Three layers, in this order. `node --experimental-strip-types test/caps.test.mjs`
196
+ pins all of them.
197
+
198
+ 1. **Exact row** in `CAPS` (`extensions/qtools.ts`) — measured, authoritative.
199
+ 2. **Family rule** (`FAMILY_CAPS`) — a regex on the id, so a dated release like
200
+ `glm-5.4` or `qwen3.9-max-1105` inherits its family's shape instead of falling
201
+ through. The generation-only rule (`qwen-audio-*`, `qwen-image-*`, `wan*`,
202
+ `happyhorse*`) must stay first, or a future `qwen-audio` id would be read as a
203
+ chat model. `/qtools-config` -> per-model capabilities lists the rules, and the
204
+ active model's line says which layer answered for it.
205
+ 3. **Fail-safe default** — `UNKNOWN_CAPS.chat.search = false`. This is deliberate
206
+ and is the whole point of the layering: a wrong `true` broke *every* turn when
207
+ glm-5.3 shipped and matched nothing, while a wrong `false` only costs the passive
208
+ search bonus and `qwen_web_search` still works.
209
+
210
+ If a *family guess* turns out wrong, the extension heals itself: the finalized
211
+ assistant message carries the request model id and the upstream error text, so when
212
+ that text mentions `enable_search` the id is appended to `config.searchBlocklist`
213
+ and later turns skip the injection. One bad turn, not forever. Clear them in
214
+ `/qtools-config` -> learned skips. Measured ids never learn — the table outranks the
215
+ runtime, and a verified model that suddenly rejects search should surface as an
216
+ error rather than be silently reconfigured around.
217
+
218
+ ### Adding a model the plan just shipped
219
+
220
+ 1. Check whether pi even lists it. The plan's own `/models` is not the same set as
221
+ the pi.dev catalogue; on 2026-10-09 the endpoint served `glm-5.3`,
222
+ `deepseek-v4.1-flash` and `auto` while pi's catalogue listed none of them. Add the
223
+ missing id to `~/.pi/agent/models.json` under the provider, mirroring a sibling
224
+ row's `compat`/`thinkingLevelMap`, and verify `reasoning_effort` levels by hand —
225
+ glm-5.3 accepts only `low|high|max` and 400s on `medium`/`xhigh`, and it silently
226
+ drops image parts, so declare `input: ["text"]`.
227
+ 2. Measure the chat surface: `enable_search`, and `enable_code_interpreter` **in
228
+ streaming mode** (non-streaming rejects it with a misleading 400).
229
+ 3. Measure the `/responses` surface with the real `tools[].type` strings, not the
230
+ console labels (see Known gaps).
231
+ 4. Add the row to `CAPS` in `src/capabilities.ts`, run `npm test`, then release. If the id
232
+ matches a `FAMILY_CAPS` rule, measure it anyway: family rules are inferences, and only a
233
+ `CAPS` row is reported as `measured`.
234
+
235
+ ## Testing
236
+
237
+ ```sh
238
+ npm test # parse guard + capability gating, no dependencies
239
+ bash test/injection.e2e.sh # live injection behaviour against a mock (needs pi)
240
+ ```
147
241
 
148
- `glm-5.2` rejects `enable_search` with a hard error, so injection is skipped for it;
149
- without that guard every turn would fail after a model switch.
242
+ `npm test` is two dependency-free scripts, and that is deliberate: CI runs it with **no
243
+ install step**, so a missing or mismatched pi package cannot break a release.
244
+
245
+ - `test/parse.check.mjs` imports `extensions/qtools.ts` and counts `ERR_MODULE_NOT_FOUND` as
246
+ success, because module resolution happens only *after* the TypeScript is parsed. It exists
247
+ because a table extraction once left two copies of `export function learnedSearchSkips` in
248
+ the file, and pi only reported that at load time as
249
+ `Failed to load extension ... has already been declared`. A file that cannot parse must
250
+ never reach `npm publish`.
251
+ - `test/caps.test.mjs` asserts the gating rules by importing `src/capabilities.ts` directly.
252
+
253
+ The workflow also fails the build if `node_modules` is ever tracked again: a node_modules
254
+ **symlink** is not caught by the usual `node_modules/` gitignore pattern (git stores symlinks
255
+ as files, mode `120000`), and one once shipped an absolute path from a maintainer's laptop
256
+ into the public repo. The ignore rule is `node_modules`, no trailing slash.
257
+
258
+ `test/injection.e2e.sh` boots `test/mock_endpoint.py` on 127.0.0.1, runs real `pi -p`
259
+ sessions in a throwaway `PI_CODING_AGENT_DIR`, and asserts whether `enable_search`
260
+ reached the wire for a family-yes id, a glm id, an unmeasured id, and a deliberately
261
+ wrong family guess (which must be learned after one rejected turn). Nothing leaves
262
+ the machine and your own `~/.pi/agent` is never touched.
150
263
 
151
264
  ## Known gaps
152
265
 
@@ -31,6 +31,14 @@ import { getAgentDir, getSelectListTheme, getSettingsListTheme } from "@earendil
31
31
  import { Container, type SelectItem, SelectList, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
32
32
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
33
33
  import { Type } from "typebox";
34
+ import {
35
+ CAPS,
36
+ createCaps,
37
+ FAMILY_CAPS,
38
+ modelsSupporting,
39
+ UNKNOWN_CAPS,
40
+ type Caps,
41
+ } from "../src/capabilities.ts";
34
42
 
35
43
  // ------------------------------------------------------------------ capability
36
44
 
@@ -43,79 +51,36 @@ const BASE_URLS: Record<string, string> = {
43
51
  };
44
52
 
45
53
  /**
46
- * The two surfaces have DIFFERENT support. Measured per model:
47
- * chat = /chat/completions params (enable_search, agent strategy, ci)
48
- * responses = /responses built-in tools (web_search, code_interpreter, web_extractor)
49
- * Notably glm-5.2 rejects chat enable_search outright yet works on /responses,
50
- * and qwen3.8-* lack chat agent/code_interpreter but work on /responses.
54
+ * Capability decisions live in src/capabilities.ts, which imports nothing, so they are
55
+ * unit-testable without pi, without the network and without a config file. Read the
56
+ * runtime-mutable parts (learned skips, configured fallback head) through getters.
51
57
  */
52
- interface Caps {
53
- chat: { search: boolean; agent: boolean; ci: boolean };
54
- responses: {
55
- web_search: boolean;
56
- code_interpreter: boolean;
57
- web_extractor: boolean;
58
- web_search_image: boolean;
59
- image_search: boolean;
60
- };
61
- }
62
-
63
- const chat = (search: boolean, agent: boolean, ci: boolean) => ({ search, agent, ci });
64
- const c = (v: boolean) => v;
65
- const res = (ws: boolean, ci: boolean, we: boolean, wsi: boolean, is: boolean) => ({
66
- web_search: ws,
67
- code_interpreter: ci,
68
- web_extractor: we,
69
- web_search_image: wsi,
70
- image_search: is,
58
+ const { capsFor, capsSource, searchCapable, fallbackChain, candidateOrder, chooseModel } = createCaps({
59
+ blocklist: () => config.searchBlocklist,
60
+ fallbackModel: () => config.fallbackModel,
71
61
  });
72
- const ALL_RES = res(c(true), c(true), c(true), c(true), c(true));
73
- /** Every text model does search/code/extract; only some do the image tools. */
74
- const IMG = (wsi: boolean, is: boolean) => res(true, true, true, wsi, is);
75
- const NO_RES = res(false, false, false, false, false);
76
62
 
77
- /**
78
- * Measured per model. The two image tools are gated separately from each other:
79
- * `web_search_image` is qwen3.8-max only, while `image_search` also works on
80
- * qwen3.8-flash / 3.7-plus / 3.6-flash. Untested cells are left false.
81
- */
82
- const CAPS: Record<string, Caps> = {
83
- "qwen3.8-max": { chat: chat(true, false, false), responses: ALL_RES },
84
- "qwen3.8-flash": { chat: chat(true, false, false), responses: IMG(false, true) },
85
- "qwen3.7-max": { chat: chat(true, true, true), responses: IMG(false, false) },
86
- "qwen3.7-plus": { chat: chat(true, true, true), responses: IMG(false, true) },
87
- "qwen3.6-flash": { chat: chat(true, true, true), responses: IMG(false, true) },
88
- "deepseek-v4-pro": { chat: chat(true, true, true), responses: IMG(false, false) },
89
- "deepseek-v4-pro-0813": { chat: chat(true, true, true), responses: IMG(false, false) },
90
- "deepseek-v4-flash": { chat: chat(true, true, true), responses: IMG(false, false) },
91
- "deepseek-v4-flash-0731": { chat: chat(true, true, true), responses: IMG(false, false) },
92
- "glm-5.2": { chat: chat(false, false, false), responses: IMG(false, false) },
93
- "wan2.7-image": { chat: chat(false, false, false), responses: NO_RES },
94
- "wan2.7-image-pro": { chat: chat(false, false, false), responses: NO_RES },
95
- "qwen-audio-3.0-tts-plus": { chat: chat(false, false, false), responses: NO_RES },
96
- "qwen-audio-3.0-realtime-plus": { chat: chat(false, false, false), responses: NO_RES },
97
- };
98
-
99
- /** Which models actually run each /responses tool, for error messages. */
100
- export function modelsSupporting(tool: keyof Caps["responses"]): string[] {
101
- return Object.entries(CAPS)
102
- .filter(([, k]) => k.responses[tool])
103
- .map(([id]) => id);
104
- }
105
63
 
106
- /** Unknown models: assume the common three, assume not the image tools. */
107
- const UNKNOWN_CAPS: Caps = { chat: chat(true, false, false), responses: IMG(false, false) };
108
64
 
109
- function capsFor(modelId: string): Caps {
110
- return CAPS[modelId] ?? UNKNOWN_CAPS;
65
+ /** Test/maintenance hook: the ids the runtime has learned to leave alone. */
66
+ export function learnedSearchSkips(): string[] {
67
+ return [...config.searchBlocklist];
111
68
  }
112
69
 
113
70
  /**
114
- * glm-5.2 returns "This model does not support enable_search", which would break
115
- * EVERY turn rather than just searches, so the main-loop injection is gated.
71
+ * pi never fires after_provider_response for a non-2xx (the OpenAI SDK throws first), so the
72
+ * capability verdict comes from the finalized assistant message instead: it carries the request
73
+ * model id and the upstream error text, which names enable_search. Matching that text is what
74
+ * makes this safe — an input-length 400 cannot be mistaken for a capability verdict, and no
75
+ * request/response pairing or cross-request state is needed.
116
76
  */
117
- function searchCapable(modelId: string): boolean {
118
- return capsFor(modelId).chat.search;
77
+ const SEARCH_REJECTED = /enable_search/i;
78
+
79
+ function learnSearchReject(modelId: string): boolean {
80
+ if (config.searchBlocklist.includes(modelId)) return false;
81
+ config.searchBlocklist = [...config.searchBlocklist, modelId];
82
+ saveConfig();
83
+ return true;
119
84
  }
120
85
 
121
86
  // --------------------------------------------------------------------- config
@@ -132,6 +97,12 @@ interface QwenToolsConfig {
132
97
  /** Defaults for the generation models (native endpoints, not chat models). */
133
98
  imageModel: string;
134
99
  videoModel: string;
100
+ /**
101
+ * Model ids that answered an injected enable_search with a 400. Learned at runtime
102
+ * so one unmeasured model can never break every turn the way glm-5.3 did; cleared
103
+ * by deleting entries from the file or with /qtools-config -> learned skips.
104
+ */
105
+ searchBlocklist: string[];
135
106
  }
136
107
 
137
108
  /** Verified working on this plan via the native multimodal-generation endpoint. */
@@ -142,11 +113,12 @@ const VIDEO_SIZES = ["1280*720", "1920*1080", "832*480"];
142
113
 
143
114
  const DEFAULTS: QwenToolsConfig = {
144
115
  searchMode: "turbo",
145
- fallbackModel: "qwen3.7-max",
116
+ fallbackModel: "qwen3.6-flash",
146
117
  showReasoning: false,
147
118
  deepSearchMaxTokens: 16384,
148
119
  imageModel: "wan2.7-image",
149
120
  videoModel: "happyhorse-1.1-t2v",
121
+ searchBlocklist: [],
150
122
  };
151
123
 
152
124
  const MODE_NOTES: Record<SearchMode, string> = {
@@ -192,6 +164,9 @@ function loadConfig(): void {
192
164
  fallbackModel: typeof raw.fallbackModel === "string" && raw.fallbackModel ? raw.fallbackModel : DEFAULTS.fallbackModel,
193
165
  imageModel: IMAGE_MODELS.includes(raw.imageModel ?? "") ? raw.imageModel! : DEFAULTS.imageModel,
194
166
  videoModel: VIDEO_MODELS.includes(raw.videoModel ?? "") ? raw.videoModel! : DEFAULTS.videoModel,
167
+ searchBlocklist: Array.isArray(raw.searchBlocklist)
168
+ ? raw.searchBlocklist.filter((id): id is string => typeof id === "string" && id.length > 0)
169
+ : DEFAULTS.searchBlocklist,
195
170
  };
196
171
  // Migrate the old filename once so the new one is authoritative.
197
172
  if (source !== p) saveConfig();
@@ -235,26 +210,35 @@ async function resolveEndpoint(ctx: ExtensionContext): Promise<{ baseUrl: string
235
210
  return { baseUrl, apiKey };
236
211
  }
237
212
 
213
+ function pickModel(ctx: ExtensionContext, eligible: (id: string) => boolean, requested?: string): string | undefined {
214
+ return chooseModel(eligible, ctx.model?.id, requested);
215
+ }
216
+
238
217
  /** Chat-completions agent-mode side call (deep search): needs chat.agent support. */
239
218
  function pickChatCapable(ctx: ExtensionContext, requested?: string): string {
240
- const active = ctx.model?.id ?? "";
241
- for (const c of [requested, active, config.fallbackModel]) {
242
- if (c && capsFor(c).chat.agent) return c;
243
- }
244
- return "qwen3.7-max";
219
+ const id = pickModel(ctx, (c) => capsFor(c).chat.agent, requested);
220
+ if (id) return id;
221
+ throw new Error(
222
+ `No model on this plan runs chat agent mode (search_strategy: "agent"). Candidates tried: ${candidateOrder(
223
+ ctx.model?.id,
224
+ requested,
225
+ ).join(", ")}.`,
226
+ );
245
227
  }
246
228
 
247
229
  /**
248
- * /responses tools: keep the ACTIVE model. Every chat model on this plan runs all
249
- * three built-in tools there, so downgrading to the fallback would silently bill a
250
- * different model than the one the user selected.
230
+ * /responses tools: keep the ACTIVE model whenever it can serve the tool, so the model the
231
+ * user selected is the one billed and the one writing the answer. Only step off it when its
232
+ * row says no, and then step onto the cheapest capable id rather than a fixed expensive one.
251
233
  */
252
234
  function pickResponsesModel(ctx: ExtensionContext, tool: keyof Caps["responses"], requested?: string): string {
253
- const active = ctx.model?.id ?? "";
254
- for (const c of [requested, active, config.fallbackModel]) {
255
- if (c && capsFor(c).responses[tool]) return c;
256
- }
257
- return "qwen3.7-max";
235
+ const id = pickModel(ctx, (c) => capsFor(c).responses[tool], requested);
236
+ if (id) return id;
237
+ const works = modelsSupporting(tool);
238
+ throw new Error(
239
+ `No model on this plan runs the /responses "${tool}" tool.` +
240
+ (works.length ? ` Measured working on: ${works.join(", ")}.` : " Nothing has been measured working on it yet."),
241
+ );
258
242
  }
259
243
 
260
244
  // --------------------------------------------------------------- /responses
@@ -967,7 +951,12 @@ const TYPE_MAP: Array<{ label: string; type: string; tool: string }> = [
967
951
  { label: "i2i_search", type: "image_search", tool: "qwen_reverse_image_search" },
968
952
  ];
969
953
 
970
- function capabilityItems(): SelectItem[] {
954
+ /**
955
+ * Real model ids with their measured rows. This is the list the fallback-model submenu
956
+ * offers, so it must stay id-only: a family rule or the "anything else" row is not a
957
+ * model id, and selecting one would write "family:0" into config.fallbackModel.
958
+ */
959
+ function modelIdItems(): SelectItem[] {
971
960
  return Object.entries(CAPS).map(([id, caps]) => ({
972
961
  value: id,
973
962
  label: id,
@@ -975,6 +964,25 @@ function capabilityItems(): SelectItem[] {
975
964
  }));
976
965
  }
977
966
 
967
+ /**
968
+ * Read-only capability browser: measured rows first, then the family rules that catch ids
969
+ * nobody measured, then the fail-safe default, so provenance is visible without guessing.
970
+ */
971
+ function capabilityItems(): SelectItem[] {
972
+ const rows: SelectItem[] = modelIdItems();
973
+ rows.push(...FAMILY_CAPS.map((rule, i) => ({
974
+ value: `family:${i}`,
975
+ label: `family ${rule.match.source}`,
976
+ description: `${capabilitySummary(rule.caps)} | inferred: ${rule.why}`,
977
+ })));
978
+ rows.push({
979
+ value: "unknown",
980
+ label: "anything else",
981
+ description: `${capabilitySummary(UNKNOWN_CAPS)} | fail-safe: nothing assumed about enable_search`,
982
+ });
983
+ return rows;
984
+ }
985
+
978
986
  function typeMapItems(): SelectItem[] {
979
987
  return TYPE_MAP.map((t) => ({
980
988
  value: t.type,
@@ -991,16 +999,27 @@ function descriptionFor(id: string, ctx: ExtensionContext): string {
991
999
  case "searchMode":
992
1000
  return (
993
1001
  MODE_NOTES[config.searchMode] +
994
- (caps.chat.search ? "" : ` — WARNING: ${model} rejects enable_search, so injection is skipped`)
1002
+ (caps.chat.search
1003
+ ? ""
1004
+ : ` — WARNING: ${model} rejects enable_search (${capsSource(model)}), so injection is skipped`) +
1005
+ (config.searchBlocklist.length ? ` | learned skips: ${config.searchBlocklist.join(", ")}` : "")
995
1006
  );
996
1007
  case "fallbackModel":
997
- return `Model the qwen_* tools run on only when the active model cannot. Currently ${config.fallbackModel}. (/responses tools use the active model — every chat model here supports them.)`;
1008
+ return (
1009
+ `Head of the cheap-first chain the qwen_* tools step onto when the ACTIVE model cannot serve a ` +
1010
+ `tool. Currently ${config.fallbackModel}; chain tried in order: ${fallbackChain().join(" -> ")}. ` +
1011
+ `The active model is always preferred, so this only matters for models with gaps (GLM rejects image tools).`
1012
+ );
998
1013
  case "showReasoning":
999
1014
  return "Adds the model's own plan/rationale to tool results. More context, more tokens.";
1000
1015
  case "deepSearchMaxTokens":
1001
1016
  return `max_tokens cap for qwen_deep_search. It burned ~340k prompt tokens uncapped; limit is ${config.deepSearchMaxTokens} now.`;
1002
1017
  case "capabilities":
1003
1018
  return "Measured against the live endpoint. Note the two image tools are gated differently.";
1019
+ case "learnedSkips":
1020
+ return config.searchBlocklist.length
1021
+ ? `Unmeasured models that took a 400 on the injected enable_search: ${config.searchBlocklist.join(", ")}. Select clear to forget them.`
1022
+ : "No runtime-learned skips. A 400 on an injected request for an unmeasured model lands here.";
1004
1023
  case "imageModel":
1005
1024
  return `Default for qwen_generate_image and /qimage. All three verified working via the native multimodal-generation endpoint.`;
1006
1025
  case "videoModel":
@@ -1020,7 +1039,8 @@ function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
1020
1039
  const capLine =
1021
1040
  `${model}: chat ${y(caps.chat.search)}${y(caps.chat.agent)}${y(caps.chat.ci)}` +
1022
1041
  ` | res ${y(caps.responses.web_search)}${y(caps.responses.code_interpreter)}${y(caps.responses.web_extractor)}` +
1023
- `${y(caps.responses.web_search_image)}${y(caps.responses.image_search)}`;
1042
+ `${y(caps.responses.web_search_image)}${y(caps.responses.image_search)}` +
1043
+ ` | ${capsSource(model)}`;
1024
1044
  return [
1025
1045
  {
1026
1046
  id: "searchMode",
@@ -1038,7 +1058,7 @@ function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
1038
1058
  // closeSubmenu() leaves submenuComponent set, so the submenu never closes
1039
1059
  // (onChange still fires, so it looks like a render hang). The cursor already
1040
1060
  // returns to this row, so navigateTo buys nothing here.
1041
- submenu: (current, done) => makeSubmenu(th, "Built-in tool model", capabilityItems(), current, (v) => done(v)),
1061
+ submenu: (current, done) => makeSubmenu(th, "Built-in tool model", modelIdItems(), current, (v) => done(v)),
1042
1062
  },
1043
1063
  {
1044
1064
  id: "showReasoning",
@@ -1103,6 +1123,13 @@ function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
1103
1123
  currentValue: capLine,
1104
1124
  submenu: (_current, done) => makeSubmenu(th, "Per-model capabilities", capabilityItems(), "", () => done(undefined)),
1105
1125
  },
1126
+ {
1127
+ id: "learnedSkips",
1128
+ label: "learned skips",
1129
+ description: descriptionFor("learnedSkips", ctx),
1130
+ currentValue: config.searchBlocklist.length ? config.searchBlocklist.join(", ") : "none",
1131
+ values: ["clear"],
1132
+ },
1106
1133
  {
1107
1134
  id: "provider",
1108
1135
  label: "Provider",
@@ -1118,7 +1145,9 @@ function applyChange(id: string, value: string): void {
1118
1145
  if (SEARCH_MODES.includes(value as SearchMode)) config.searchMode = value as SearchMode;
1119
1146
  break;
1120
1147
  case "fallbackModel":
1121
- if (value) config.fallbackModel = value;
1148
+ // Guard the submenu contract: anything that is not a measured id would silently
1149
+ // demote the whole fallback chain to "no capable model found".
1150
+ if (value && CAPS[value]) config.fallbackModel = value;
1122
1151
  break;
1123
1152
  case "showReasoning":
1124
1153
  config.showReasoning = value === "enabled";
@@ -1129,6 +1158,9 @@ function applyChange(id: string, value: string): void {
1129
1158
  case "videoModel":
1130
1159
  if (VIDEO_MODELS.includes(value)) config.videoModel = value;
1131
1160
  break;
1161
+ case "learnedSkips":
1162
+ if (value === "clear") config.searchBlocklist = [];
1163
+ break;
1132
1164
  case "deepSearchMaxTokens": {
1133
1165
  const n = Number.parseInt(value, 10);
1134
1166
  if (Number.isFinite(n) && n > 0) config.deepSearchMaxTokens = n;
@@ -1165,6 +1197,34 @@ export default function (pi: ExtensionAPI): void {
1165
1197
  };
1166
1198
  });
1167
1199
 
1200
+ // ---- self-heal: an enable_search error retires that model's passive search ----
1201
+ pi.on("message_end", (event, ctx) => {
1202
+ const message = event.message as {
1203
+ role?: string;
1204
+ provider?: string;
1205
+ model?: string;
1206
+ stopReason?: string;
1207
+ errorMessage?: string;
1208
+ };
1209
+ if (message.role !== "assistant" || message.stopReason !== "error") return;
1210
+ if (!(message.provider ?? "").startsWith(PROVIDER_PREFIX)) return;
1211
+ const detail = message.errorMessage ?? "";
1212
+ if (!SEARCH_REJECTED.test(detail)) return;
1213
+ const id = message.model;
1214
+ if (!id) return;
1215
+ // Only unmeasured ids get to learn: the table is authoritative, and a measured model
1216
+ // that suddenly rejects search is an upstream change the maintainer should see as an
1217
+ // error rather than have silently reconfigured around.
1218
+ if (CAPS[id]) return;
1219
+ if (!learnSearchReject(id)) return;
1220
+ ctx.ui.notify(
1221
+ `qtools: ${id} is unmeasured and rejected the injected enable_search, so passive search is now ` +
1222
+ `skipped for it (qwen_web_search still works). New ids belong in CAPS in src/capabilities.ts; ` +
1223
+ `this skip is in config.searchBlocklist, clearable via /qtools-config -> learned skips.`,
1224
+ "warning",
1225
+ );
1226
+ });
1227
+
1168
1228
  // ------------------------------------------------------------------- tools
1169
1229
  pi.registerTool({
1170
1230
  name: "qwen_web_search",
@@ -1258,7 +1318,9 @@ export default function (pi: ExtensionAPI): void {
1258
1318
  description:
1259
1319
  "Search the web for images matching a text description, using Qwen's server-side web_search_image " +
1260
1320
  "tool. Returns the image URLs it found plus the queries it issued. Qwen runs the retrieval; results " +
1261
- "are real URLs, not model guesses. Supported by qwen3.8-max only on this plan.",
1321
+ "are real URLs, not model guesses. Runs on the plan's flash tiers as well (qwen3.6-flash, " +
1322
+ "qwen3.7-plus, qwen3.8-flash, qwen3.8-max); glm-* and deepseek-* cannot, and those calls are " +
1323
+ "routed to a capable model for you.",
1262
1324
  promptSnippet: "Find images on the web from a text description",
1263
1325
  promptGuidelines: [
1264
1326
  "Use qwen_image_search when the user wants pictures of something found on the web, as opposed to qwen_web_search which retrieves text sources.",
@@ -1266,7 +1328,7 @@ export default function (pi: ExtensionAPI): void {
1266
1328
  ],
1267
1329
  parameters: Type.Object({
1268
1330
  query: Type.String({ description: "What to find images of, as a short descriptive phrase." }),
1269
- model: Type.Optional(Type.String({ description: "Override model id (only qwen3.8-max supports this tool)." })),
1331
+ model: Type.Optional(Type.String({ description: "Override model id (flash tiers work here)." })),
1270
1332
  }),
1271
1333
  async execute(_id, params, signal, onUpdate, ctx) {
1272
1334
  onUpdate?.({ content: [{ type: "text", text: `image search: ${params.query}` }] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-qtools",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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": [
@@ -31,6 +31,7 @@
31
31
  },
32
32
  "files": [
33
33
  "extensions",
34
+ "src",
34
35
  "README.md",
35
36
  "LICENSE"
36
37
  ],
@@ -54,5 +55,8 @@
54
55
  "extensions": [
55
56
  "./extensions/qtools.ts"
56
57
  ]
58
+ },
59
+ "scripts": {
60
+ "test": "node --experimental-strip-types test/parse.check.mjs && node --experimental-strip-types test/caps.test.mjs"
57
61
  }
58
62
  }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The capability layer, with NO imports at all — deliberately.
3
+ *
4
+ * Everything pi-specific (context, config file, TUI, HTTP) lives in ../extensions/qtools.ts.
5
+ * This file is the part that decides whether a model can take `enable_search` and which
6
+ * model runs a tool, and it is unit-tested without pi installed, without the network, and
7
+ * without a config file. That matters: when this table lived inside the extension, the test
8
+ * had to import the extension, which had to import `@earendil-works/pi-coding-agent`, so CI
9
+ * needed a full pi install just to assert a boolean.
10
+ *
11
+ * Measured against the live endpoint:
12
+ * https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
13
+ *
14
+ * TWO API surfaces, different capabilities:
15
+ * chat = /chat/completions params (enable_search, agent strategy, code interpreter)
16
+ * responses = /responses built-in tools (web_search, code_interpreter, web_extractor,
17
+ * web_search_image, image_search)
18
+ */
19
+
20
+ export interface Caps {
21
+ chat: { search: boolean; agent: boolean; ci: boolean };
22
+ responses: {
23
+ web_search: boolean;
24
+ code_interpreter: boolean;
25
+ web_extractor: boolean;
26
+ web_search_image: boolean;
27
+ image_search: boolean;
28
+ };
29
+ }
30
+
31
+ const chat = (search: boolean, agent: boolean, ci: boolean) => ({ search, agent, ci });
32
+ const c = (v: boolean) => v;
33
+ const res = (ws: boolean, ci: boolean, we: boolean, wsi: boolean, is: boolean) => ({
34
+ web_search: ws,
35
+ code_interpreter: ci,
36
+ web_extractor: we,
37
+ web_search_image: wsi,
38
+ image_search: is,
39
+ });
40
+ export const ALL_RES = res(c(true), c(true), c(true), c(true), c(true));
41
+ /** Every text model does search/code/extract; only some do the image tools. */
42
+ export const IMG = (wsi: boolean, is: boolean) => res(true, true, true, wsi, is);
43
+ export const NO_RES = res(false, false, false, false, false);
44
+
45
+ /**
46
+ * Measured per model. The two image tools are gated separately from each other.
47
+ * Re-measured 2026-10-10: `web_search_image` is NOT qwen3.8-max-only as this table first
48
+ * claimed — it also runs on qwen3.8-flash / 3.7-plus / 3.6-flash, and qwen3.7-max cannot
49
+ * run either image tool. Both mistakes pushed tool calls onto the plan's priciest tier.
50
+ * Untested cells are left false.
51
+ */
52
+ export const CAPS: Record<string, Caps> = {
53
+ "qwen3.8-max": { chat: chat(true, false, false), responses: ALL_RES },
54
+ "qwen3.8-flash": { chat: chat(true, false, false), responses: IMG(true, true) },
55
+ "qwen3.7-max": { chat: chat(true, true, true), responses: IMG(false, false) },
56
+ "qwen3.7-plus": { chat: chat(true, true, true), responses: IMG(true, true) },
57
+ "qwen3.6-flash": { chat: chat(true, true, true), responses: IMG(true, true) },
58
+ "deepseek-v4-pro": { chat: chat(true, true, true), responses: IMG(false, false) },
59
+ "deepseek-v4-pro-0813": { chat: chat(true, true, true), responses: IMG(false, false) },
60
+ "deepseek-v4-flash": { chat: chat(true, true, true), responses: IMG(false, false) },
61
+ "deepseek-v4-flash-0731": { chat: chat(true, true, true), responses: IMG(false, false) },
62
+ "glm-5.2": { chat: chat(false, false, false), responses: IMG(false, false) },
63
+ // Measured 2026-10-10: chat enable_search -> 400 "This model does not support enable_search"
64
+ // in both streaming and non-streaming mode; /responses runs web_search + code_interpreter;
65
+ // both image tools rejected; reasoning_effort only accepts low|high|max (xhigh/medium 400).
66
+ "glm-5.3": { chat: chat(false, false, false), responses: IMG(false, false) },
67
+ // Measured 2026-10-10: chat enable_search accepted, all five /responses built-ins accepted
68
+ // (both image tools fired), and chat enable_code_interpreter needs STREAMING mode: a
69
+ // non-streaming probe 400s with "Non-streaming mode does not support Code interpreter",
70
+ // which reads like a capability rejection and is not one. pi always streams.
71
+ "deepseek-v4.1-flash": { chat: chat(true, true, true), responses: IMG(false, false) },
72
+ // "auto" is the plan's router alias. Measured 2026-10-10: chat enable_search accepted,
73
+ // /responses web_search accepted, all reasoning_effort levels accepted. It routes to some
74
+ // model I cannot see, so only the cells I actually exercised are set; assume nothing else.
75
+ "auto": { chat: chat(true, false, false), responses: IMG(false, false) },
76
+ "wan2.7-image": { chat: chat(false, false, false), responses: NO_RES },
77
+ "wan2.7-image-pro": { chat: chat(false, false, false), responses: NO_RES },
78
+ "qwen-audio-3.0-tts-plus": { chat: chat(false, false, false), responses: NO_RES },
79
+ "qwen-audio-3.0-realtime-plus": { chat: chat(false, false, false), responses: NO_RES },
80
+ };
81
+
82
+ /**
83
+ * Family defaults for ids missing from CAPS, so a dated release (glm-5.4,
84
+ * deepseek-v4.2-flash-1101, qwen3.9-max-...) inherits the family's measured shape instead
85
+ * of falling through to UNKNOWN_CAPS. First match wins, so the generation-only rule must
86
+ * stay ahead of the qwen chat rule.
87
+ */
88
+ export const FAMILY_CAPS: Array<{ match: RegExp; caps: Caps; why: string }> = [
89
+ {
90
+ match: /^(qwen-(audio|image)|wan|happyhorse)/i,
91
+ caps: { chat: chat(false, false, false), responses: NO_RES },
92
+ why: "native generation endpoint, not a chat model",
93
+ },
94
+ {
95
+ // Every GLM on this plan so far rejects chat enable_search (glm-5.2, glm-5.3).
96
+ match: /^glm/i,
97
+ caps: { chat: chat(false, false, false), responses: IMG(false, false) },
98
+ why: "GLM runs /responses built-ins but rejects enable_search on chat",
99
+ },
100
+ {
101
+ match: /^deepseek/i,
102
+ caps: { chat: chat(true, true, true), responses: IMG(false, false) },
103
+ why: "DeepSeek on this plan takes chat search, agent mode and code interpreter",
104
+ },
105
+ {
106
+ match: /^qwen[-.\d]/i,
107
+ caps: { chat: chat(true, false, false), responses: IMG(false, false) },
108
+ why: "Qwen chat models take turbo/max search injection",
109
+ },
110
+ ];
111
+
112
+ /**
113
+ * Unknown models: assume the /responses tools work, assume NOTHING about chat
114
+ * enable_search. The asymmetry is deliberate. A wrong `true` here was a poison pill:
115
+ * glm-5.3 shipped on the plan, matched no CAPS row, took the injected enable_search, and
116
+ * every single turn 400'd until the table was edited. A wrong `false` only costs the
117
+ * passive search bonus, and `qwen_web_search` still works.
118
+ */
119
+ export const UNKNOWN_CAPS: Caps = { chat: chat(false, false, false), responses: IMG(false, false) };
120
+
121
+ /**
122
+ * Cheap-first candidate order for the fallback chain. The plan bills a max tier at a
123
+ * several-times-higher ratio than a flash tier, so the chain is ordered by price, not by
124
+ * recency, and `qwen3.6-flash` leads because it is the cheapest id measured running every
125
+ * tool surface: chat search/agent/code plus all five /responses tools. Never hardcode an
126
+ * endpoint here; add an id and let capsFor() decide who is eligible.
127
+ */
128
+ export const FALLBACK_CHAIN = [
129
+ "qwen3.6-flash",
130
+ "qwen3.8-flash",
131
+ "qwen3.7-plus",
132
+ "deepseek-v4-flash",
133
+ "deepseek-v4.1-flash",
134
+ "qwen3.7-max",
135
+ "qwen3.8-max",
136
+ ];
137
+
138
+ /** Which models actually run each /responses tool, for error messages. */
139
+ export function modelsSupporting(tool: keyof Caps["responses"]): string[] {
140
+ return Object.entries(CAPS)
141
+ .filter(([, k]) => k.responses[tool])
142
+ .map(([id]) => id);
143
+ }
144
+
145
+ function familyCaps(modelId: string): { caps: Caps; why: string } | undefined {
146
+ return FAMILY_CAPS.find((rule) => rule.match.test(modelId));
147
+ }
148
+
149
+ function baseCaps(modelId: string): Caps {
150
+ return CAPS[modelId] ?? familyCaps(modelId)?.caps ?? UNKNOWN_CAPS;
151
+ }
152
+
153
+ export interface CapsReader {
154
+ capsFor(modelId: string): Caps;
155
+ capsSource(modelId: string): string;
156
+ searchCapable(modelId: string): boolean;
157
+ fallbackChain(): string[];
158
+ candidateOrder(active?: string, requested?: string): string[];
159
+ chooseModel(eligible: (id: string) => boolean, active?: string, requested?: string): string | undefined;
160
+ }
161
+
162
+ /**
163
+ * Read capability state through getters instead of importing state, because the caller owns
164
+ * persistence: `config.searchBlocklist` and `config.fallbackModel` come from <agentDir>/qtools.json
165
+ * and change at runtime. Tests pass a fixed list, or none at all.
166
+ */
167
+ export function createCaps(
168
+ options: { blocklist?: () => readonly string[]; fallbackModel?: () => string } = {},
169
+ ): CapsReader {
170
+ const blocked = () => options.blocklist?.() ?? [];
171
+ const head = () => options.fallbackModel?.() ?? FALLBACK_CHAIN[0]!;
172
+
173
+ function capsFor(modelId: string): Caps {
174
+ const caps = baseCaps(modelId);
175
+ // The runtime blocklist only ever removes a capability, never grants one.
176
+ if (!blocked().includes(modelId)) return caps;
177
+ return { ...caps, chat: { ...caps.chat, search: false } };
178
+ }
179
+
180
+ function candidateOrder(active?: string, requested?: string): string[] {
181
+ const chain = [head(), ...FALLBACK_CHAIN.filter((id) => id !== head())];
182
+ return [requested, active, ...chain].filter((id): id is string => typeof id === "string" && id.length > 0);
183
+ }
184
+
185
+ return {
186
+ capsFor,
187
+ /** Where a model's caps came from, for the /qtools-config capability readout. */
188
+ capsSource(modelId: string): string {
189
+ if (blocked().includes(modelId)) return "learned: rejects enable_search";
190
+ if (CAPS[modelId]) return "measured";
191
+ return familyCaps(modelId)?.why ?? "unknown model, no capabilities assumed";
192
+ },
193
+ /**
194
+ * glm-5.2 and glm-5.3 return "This model does not support enable_search", which breaks
195
+ * EVERY turn rather than just searches, so the main-loop injection is gated on this.
196
+ */
197
+ searchCapable(modelId: string): boolean {
198
+ return capsFor(modelId).chat.search;
199
+ },
200
+ /** Exported so the TUI and the tests show the same chain the pickers walk. */
201
+ fallbackChain(): string[] {
202
+ return [head(), ...FALLBACK_CHAIN.filter((id) => id !== head())];
203
+ },
204
+ candidateOrder,
205
+ /**
206
+ * The pure half of the pickers: walk requested -> active -> fallback chain, first
207
+ * capable id wins. undefined means nothing on the plan can run the capability, and the
208
+ * caller must say so rather than call a model that will 400.
209
+ */
210
+ chooseModel(eligible: (id: string) => boolean, active?: string, requested?: string): string | undefined {
211
+ return candidateOrder(active, requested).find((id) => eligible(id));
212
+ },
213
+ };
214
+ }