pi-qtools 0.1.1 → 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 +147 -13
- package/extensions/qtools.ts +195 -100
- package/package.json +35 -7
- package/src/capabilities.ts +214 -0
package/README.md
CHANGED
|
@@ -16,10 +16,10 @@ 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.
|
|
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
|
-
| `qwen_generate_image` | native `multimodal-generation` | `wan2.7-image`, `wan2.7-image-pro`, `qwen-image-3.0-pro`; sync; `save: true` writes into `<cwd>/out/qtools/` |
|
|
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/` |
|
|
23
23
|
| `qwen_generate_video` | native `video-generation` + task polling | `happyhorse-1.1-t2v` / `-i2v` / `-r2v`; async, ~90-120s; `save: true` writes the mp4 |
|
|
24
24
|
|
|
25
25
|
## Passive main-loop search
|
|
@@ -40,10 +40,31 @@ sends one. That is why those two capabilities are standalone tools instead.
|
|
|
40
40
|
## Commands
|
|
41
41
|
|
|
42
42
|
- `/qtools-config` — settings TUI (search mode, model defaults, capability matrix)
|
|
43
|
-
- `/qimage <prompt> [-m model] [-s WxH]` — saves into `out/qtools/`
|
|
43
|
+
- `/qimage <prompt> [-i img] [-m model] [-s WxH]` — saves into `out/qtools/`
|
|
44
44
|
- `/qvideo <prompt> [-i <path|url>] [-m model] [-s WxH]` — `-i` selects image-to-video; saves into `out/qtools/`
|
|
45
45
|
|
|
46
|
-
###
|
|
46
|
+
### Image editing (measured)
|
|
47
|
+
|
|
48
|
+
Passing a reference image turns the same endpoint from text-to-image into
|
|
49
|
+
image+prompt editing. The shape is one extra content part:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{ "input": { "messages": [ { "role": "user", "content": [
|
|
53
|
+
{ "image": "https://... or data:image/png;base64,..." },
|
|
54
|
+
{ "text": "turn this into a red panda, keep the flat vector style" }
|
|
55
|
+
] } ] } }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Verified: a flat-vector pig + that prompt came back a red panda **in the same
|
|
59
|
+
style**, and a local file inlined as a data URL worked too. Two reference images
|
|
60
|
+
in one message are accepted.
|
|
61
|
+
|
|
62
|
+
The trap here is that a wrong request shape still returns a valid image, just
|
|
63
|
+
generated from the text alone. Every claim above was checked by asking a vision
|
|
64
|
+
model (`qwen3.8-max`, which has real image input on this plan) to describe the
|
|
65
|
+
output, not by trusting the HTTP 200.
|
|
66
|
+
|
|
67
|
+
## Getting parameter hints
|
|
47
68
|
|
|
48
69
|
pi exposes no `argumentHint` for extension commands, so `/qimage` and `/qvideo` make
|
|
49
70
|
themselves discoverable three ways:
|
|
@@ -101,11 +122,12 @@ Persisted to `<agentDir>/qtools.json` (migrated automatically from the older
|
|
|
101
122
|
```json
|
|
102
123
|
{
|
|
103
124
|
"searchMode": "turbo",
|
|
104
|
-
"fallbackModel": "qwen3.
|
|
125
|
+
"fallbackModel": "qwen3.6-flash",
|
|
105
126
|
"showReasoning": false,
|
|
106
127
|
"deepSearchMaxTokens": 16384,
|
|
107
128
|
"imageModel": "wan2.7-image",
|
|
108
|
-
"videoModel": "happyhorse-1.1-t2v"
|
|
129
|
+
"videoModel": "happyhorse-1.1-t2v",
|
|
130
|
+
"searchBlocklist": []
|
|
109
131
|
}
|
|
110
132
|
```
|
|
111
133
|
|
|
@@ -116,16 +138,128 @@ surfaces differ per model:
|
|
|
116
138
|
|
|
117
139
|
| model | chat search | agent | chat code_int | `/responses` tools | `web_search_image` | `image_search` |
|
|
118
140
|
|---|---|---|---|---|---|---|
|
|
119
|
-
| qwen3.8-max | yes | no | no | all |
|
|
120
|
-
| qwen3.8-flash | yes | no | no |
|
|
121
|
-
| qwen3.7-max | yes | yes | yes | s/c/e | no | no |
|
|
122
|
-
| qwen3.7-plus | yes | yes | yes |
|
|
123
|
-
| qwen3.6-flash | yes | yes | 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** |
|
|
124
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 |
|
|
125
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
|
+
```
|
|
126
241
|
|
|
127
|
-
`
|
|
128
|
-
|
|
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.
|
|
129
263
|
|
|
130
264
|
## Known gaps
|
|
131
265
|
|
package/extensions/qtools.ts
CHANGED
|
@@ -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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
63
|
|
|
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
64
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
*
|
|
115
|
-
*
|
|
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
|
-
|
|
118
|
-
|
|
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.
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
249
|
-
*
|
|
250
|
-
*
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
@@ -643,18 +627,40 @@ function extractImageUrls(body: any): string[] {
|
|
|
643
627
|
/** Shared core so the tool and the /qimage command behave identically. */
|
|
644
628
|
async function generateImage(
|
|
645
629
|
ctx: ExtensionContext,
|
|
646
|
-
opts: {
|
|
647
|
-
|
|
630
|
+
opts: {
|
|
631
|
+
prompt: string;
|
|
632
|
+
model?: string;
|
|
633
|
+
negativePrompt?: string;
|
|
634
|
+
size?: string;
|
|
635
|
+
/** Reference image to edit: local path, http(s) URL, or data: URL. */
|
|
636
|
+
image?: string;
|
|
637
|
+
signal?: AbortSignal;
|
|
638
|
+
},
|
|
639
|
+
): Promise<{ model: string; urls: string[]; raw: unknown; edited?: string }> {
|
|
648
640
|
const model = opts.model && IMAGE_MODELS.includes(opts.model) ? opts.model : config.imageModel;
|
|
649
641
|
const parameters: Record<string, unknown> = { result_format: "message" };
|
|
650
642
|
if (opts.size) parameters.size = opts.size;
|
|
651
643
|
if (opts.negativePrompt) parameters.negative_prompt = opts.negativePrompt;
|
|
644
|
+
|
|
645
|
+
// Verified shape: an extra {image} content part next to the text switches this
|
|
646
|
+
// endpoint from text-to-image to image+prompt editing. Confirmed by vision:
|
|
647
|
+
// a flat-vector pig + "turn this into a red panda" came back a red panda in the
|
|
648
|
+
// same style, and a local file inlined as a data URL worked too.
|
|
649
|
+
const content: Array<Record<string, unknown>> = [];
|
|
650
|
+
let edited: string | undefined;
|
|
651
|
+
if (opts.image?.trim()) {
|
|
652
|
+
const ref = toImageUrl(opts.image.trim());
|
|
653
|
+
content.push({ image: ref });
|
|
654
|
+
edited = ref.startsWith("data:") ? `inline image (${Math.round(ref.length / 1024)} KB base64)` : ref;
|
|
655
|
+
}
|
|
656
|
+
content.push({ text: opts.prompt });
|
|
657
|
+
|
|
652
658
|
const r = await nativeCall(ctx, MG_PATH, {
|
|
653
659
|
model,
|
|
654
|
-
input: { messages: [{ role: "user", content
|
|
660
|
+
input: { messages: [{ role: "user", content }] },
|
|
655
661
|
parameters,
|
|
656
662
|
}, { signal: opts.signal });
|
|
657
|
-
return { model, urls: extractImageUrls(r.body), raw: r.body };
|
|
663
|
+
return { model, urls: extractImageUrls(r.body), raw: r.body, edited };
|
|
658
664
|
}
|
|
659
665
|
|
|
660
666
|
/**
|
|
@@ -761,9 +767,11 @@ function genUsage(kind: "image" | "video", cwd: string): string {
|
|
|
761
767
|
const outDir = join(cwd, "out", "qtools");
|
|
762
768
|
if (kind === "image") {
|
|
763
769
|
return [
|
|
764
|
-
"/qimage <prompt> [-m model] [-s WxH]",
|
|
770
|
+
"/qimage <prompt> [-i img] [-m model] [-s WxH]",
|
|
765
771
|
"",
|
|
766
772
|
" prompt what to draw. No quotes needed: flags are stripped, the rest is the prompt.",
|
|
773
|
+
" -i reference image to edit (local path, http(s) or data URL).",
|
|
774
|
+
" With -i this is image+prompt editing, not text-to-image.",
|
|
767
775
|
` -m model: ${IMAGE_MODELS.join(", ")} (default: ${config.imageModel})`,
|
|
768
776
|
" -s e.g. 1024x1024. Must be 589824-16777216 total pixels (768x768 .. 4096x4096).",
|
|
769
777
|
" --help this text",
|
|
@@ -943,7 +951,12 @@ const TYPE_MAP: Array<{ label: string; type: string; tool: string }> = [
|
|
|
943
951
|
{ label: "i2i_search", type: "image_search", tool: "qwen_reverse_image_search" },
|
|
944
952
|
];
|
|
945
953
|
|
|
946
|
-
|
|
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[] {
|
|
947
960
|
return Object.entries(CAPS).map(([id, caps]) => ({
|
|
948
961
|
value: id,
|
|
949
962
|
label: id,
|
|
@@ -951,6 +964,25 @@ function capabilityItems(): SelectItem[] {
|
|
|
951
964
|
}));
|
|
952
965
|
}
|
|
953
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
|
+
|
|
954
986
|
function typeMapItems(): SelectItem[] {
|
|
955
987
|
return TYPE_MAP.map((t) => ({
|
|
956
988
|
value: t.type,
|
|
@@ -967,16 +999,27 @@ function descriptionFor(id: string, ctx: ExtensionContext): string {
|
|
|
967
999
|
case "searchMode":
|
|
968
1000
|
return (
|
|
969
1001
|
MODE_NOTES[config.searchMode] +
|
|
970
|
-
(caps.chat.search
|
|
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(", ")}` : "")
|
|
971
1006
|
);
|
|
972
1007
|
case "fallbackModel":
|
|
973
|
-
return
|
|
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
|
+
);
|
|
974
1013
|
case "showReasoning":
|
|
975
1014
|
return "Adds the model's own plan/rationale to tool results. More context, more tokens.";
|
|
976
1015
|
case "deepSearchMaxTokens":
|
|
977
1016
|
return `max_tokens cap for qwen_deep_search. It burned ~340k prompt tokens uncapped; limit is ${config.deepSearchMaxTokens} now.`;
|
|
978
1017
|
case "capabilities":
|
|
979
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.";
|
|
980
1023
|
case "imageModel":
|
|
981
1024
|
return `Default for qwen_generate_image and /qimage. All three verified working via the native multimodal-generation endpoint.`;
|
|
982
1025
|
case "videoModel":
|
|
@@ -996,7 +1039,8 @@ function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
|
|
|
996
1039
|
const capLine =
|
|
997
1040
|
`${model}: chat ${y(caps.chat.search)}${y(caps.chat.agent)}${y(caps.chat.ci)}` +
|
|
998
1041
|
` | res ${y(caps.responses.web_search)}${y(caps.responses.code_interpreter)}${y(caps.responses.web_extractor)}` +
|
|
999
|
-
`${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)}`;
|
|
1000
1044
|
return [
|
|
1001
1045
|
{
|
|
1002
1046
|
id: "searchMode",
|
|
@@ -1014,7 +1058,7 @@ function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
|
|
|
1014
1058
|
// closeSubmenu() leaves submenuComponent set, so the submenu never closes
|
|
1015
1059
|
// (onChange still fires, so it looks like a render hang). The cursor already
|
|
1016
1060
|
// returns to this row, so navigateTo buys nothing here.
|
|
1017
|
-
submenu: (current, done) => makeSubmenu(th, "Built-in tool model",
|
|
1061
|
+
submenu: (current, done) => makeSubmenu(th, "Built-in tool model", modelIdItems(), current, (v) => done(v)),
|
|
1018
1062
|
},
|
|
1019
1063
|
{
|
|
1020
1064
|
id: "showReasoning",
|
|
@@ -1079,6 +1123,13 @@ function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
|
|
|
1079
1123
|
currentValue: capLine,
|
|
1080
1124
|
submenu: (_current, done) => makeSubmenu(th, "Per-model capabilities", capabilityItems(), "", () => done(undefined)),
|
|
1081
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
|
+
},
|
|
1082
1133
|
{
|
|
1083
1134
|
id: "provider",
|
|
1084
1135
|
label: "Provider",
|
|
@@ -1094,7 +1145,9 @@ function applyChange(id: string, value: string): void {
|
|
|
1094
1145
|
if (SEARCH_MODES.includes(value as SearchMode)) config.searchMode = value as SearchMode;
|
|
1095
1146
|
break;
|
|
1096
1147
|
case "fallbackModel":
|
|
1097
|
-
|
|
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;
|
|
1098
1151
|
break;
|
|
1099
1152
|
case "showReasoning":
|
|
1100
1153
|
config.showReasoning = value === "enabled";
|
|
@@ -1105,6 +1158,9 @@ function applyChange(id: string, value: string): void {
|
|
|
1105
1158
|
case "videoModel":
|
|
1106
1159
|
if (VIDEO_MODELS.includes(value)) config.videoModel = value;
|
|
1107
1160
|
break;
|
|
1161
|
+
case "learnedSkips":
|
|
1162
|
+
if (value === "clear") config.searchBlocklist = [];
|
|
1163
|
+
break;
|
|
1108
1164
|
case "deepSearchMaxTokens": {
|
|
1109
1165
|
const n = Number.parseInt(value, 10);
|
|
1110
1166
|
if (Number.isFinite(n) && n > 0) config.deepSearchMaxTokens = n;
|
|
@@ -1141,6 +1197,34 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1141
1197
|
};
|
|
1142
1198
|
});
|
|
1143
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
|
+
|
|
1144
1228
|
// ------------------------------------------------------------------- tools
|
|
1145
1229
|
pi.registerTool({
|
|
1146
1230
|
name: "qwen_web_search",
|
|
@@ -1234,7 +1318,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1234
1318
|
description:
|
|
1235
1319
|
"Search the web for images matching a text description, using Qwen's server-side web_search_image " +
|
|
1236
1320
|
"tool. Returns the image URLs it found plus the queries it issued. Qwen runs the retrieval; results " +
|
|
1237
|
-
"are real URLs, not model guesses.
|
|
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.",
|
|
1238
1324
|
promptSnippet: "Find images on the web from a text description",
|
|
1239
1325
|
promptGuidelines: [
|
|
1240
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.",
|
|
@@ -1242,7 +1328,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1242
1328
|
],
|
|
1243
1329
|
parameters: Type.Object({
|
|
1244
1330
|
query: Type.String({ description: "What to find images of, as a short descriptive phrase." }),
|
|
1245
|
-
model: Type.Optional(Type.String({ description: "Override model id (
|
|
1331
|
+
model: Type.Optional(Type.String({ description: "Override model id (flash tiers work here)." })),
|
|
1246
1332
|
}),
|
|
1247
1333
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1248
1334
|
onUpdate?.({ content: [{ type: "text", text: `image search: ${params.query}` }] });
|
|
@@ -1342,14 +1428,23 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1342
1428
|
description:
|
|
1343
1429
|
"Generate images with the Qwen plan's image models (wan2.7-image, wan2.7-image-pro, qwen-image-3.0-pro) " +
|
|
1344
1430
|
"through the native multimodal-generation endpoint. These are not chat models, so they are unreachable " +
|
|
1345
|
-
"from pi's normal model list.
|
|
1346
|
-
|
|
1431
|
+
"from pi's normal model list. Pass `image` to edit an existing picture instead of drawing from scratch: " +
|
|
1432
|
+
"the reference plus the prompt produce a new image that keeps the source's style and subject layout. " +
|
|
1433
|
+
"Returns temporary hosted image URLs.",
|
|
1434
|
+
promptSnippet: "Generate or edit images with Qwen/Wan image models",
|
|
1347
1435
|
promptGuidelines: [
|
|
1348
1436
|
"Use qwen_generate_image when the user asks to create or draw an image; report the returned URLs as links and note they are temporary hosted files.",
|
|
1437
|
+
"Use qwen_generate_image with the image parameter when the user wants an existing picture restyled, edited, or used as a reference.",
|
|
1349
1438
|
"qwen_generate_image cannot render images in the terminal, so hand the user the URL rather than describing pixels you cannot see.",
|
|
1350
1439
|
],
|
|
1351
1440
|
parameters: Type.Object({
|
|
1352
|
-
prompt: Type.String({ description: "What to draw." }),
|
|
1441
|
+
prompt: Type.String({ description: "What to draw, or how to change the reference image." }),
|
|
1442
|
+
image: Type.Optional(
|
|
1443
|
+
Type.String({
|
|
1444
|
+
description:
|
|
1445
|
+
"Reference image to edit: http(s) URL, data: URL, or local file path. Omit for text-to-image.",
|
|
1446
|
+
}),
|
|
1447
|
+
),
|
|
1353
1448
|
model: Type.Optional(Type.String({ description: `Override model. One of: ${IMAGE_MODELS.join(", ")}.` })),
|
|
1354
1449
|
negativePrompt: Type.Optional(Type.String({ description: "What to avoid." })),
|
|
1355
1450
|
size: Type.Optional(Type.String({ description: "Pixel size, e.g. 1024*1024. Must be 589824-16777216 total pixels (768² to 4096²)." })),
|
|
@@ -1366,7 +1461,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1366
1461
|
files = await Promise.all(r.urls.map((u) => saveMedia(ctx, u, r.model, signal)));
|
|
1367
1462
|
text += `\n\nSaved:\n${files.map((f) => ` ${f}`).join("\n")}`;
|
|
1368
1463
|
}
|
|
1369
|
-
return { content: [{ type: "text", text }], details: { model: r.model, urls: r.urls, files } };
|
|
1464
|
+
return { content: [{ type: "text", text }], details: { model: r.model, urls: r.urls, files, edited: r.edited } };
|
|
1370
1465
|
},
|
|
1371
1466
|
});
|
|
1372
1467
|
|
|
@@ -1422,20 +1517,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1422
1517
|
|
|
1423
1518
|
// ---------------------------------------------------------------- commands
|
|
1424
1519
|
pi.registerCommand("qimage", {
|
|
1425
|
-
description: `Generate an image — /qimage <prompt> [-m model] [-s WxH]
|
|
1426
|
-
getArgumentCompletions: (prefix) => genCompletions(prefix, { models: IMAGE_MODELS, sizes: [], image:
|
|
1520
|
+
description: `Generate or edit an image — /qimage <prompt> [-i img] [-m model] [-s WxH]`,
|
|
1521
|
+
getArgumentCompletions: (prefix) => genCompletions(prefix, { models: IMAGE_MODELS, sizes: [], image: true }),
|
|
1427
1522
|
handler: async (args, ctx) => {
|
|
1428
1523
|
if (wantsHelp(args)) {
|
|
1429
1524
|
ctx.ui.notify(genUsage("image", ctx.cwd), "info");
|
|
1430
1525
|
return;
|
|
1431
1526
|
}
|
|
1432
|
-
const { prompt, model, size } = parseGenArgs(args, IMAGE_MODELS);
|
|
1527
|
+
const { prompt, model, size, image } = parseGenArgs(args, IMAGE_MODELS);
|
|
1433
1528
|
if (!prompt) {
|
|
1434
1529
|
ctx.ui.notify(genUsage("image", ctx.cwd), "error");
|
|
1435
1530
|
return;
|
|
1436
1531
|
}
|
|
1437
|
-
ctx.ui.notify(
|
|
1438
|
-
const r = await generateImage(ctx, { prompt, model, size });
|
|
1532
|
+
ctx.ui.notify(`${image ? "Editing" : "Generating"} image with ${model || config.imageModel}...`, "info");
|
|
1533
|
+
const r = await generateImage(ctx, { prompt, model, size, image });
|
|
1439
1534
|
if (!r.urls.length) {
|
|
1440
1535
|
ctx.ui.notify(`No image URL from ${r.model}`, "error");
|
|
1441
1536
|
return;
|
package/package.json
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-qtools",
|
|
3
|
-
"version": "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
|
-
"keywords": [
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"qwen",
|
|
11
|
+
"dashscope",
|
|
12
|
+
"web-search",
|
|
13
|
+
"web-scraping",
|
|
14
|
+
"code-interpreter",
|
|
15
|
+
"image-search",
|
|
16
|
+
"image-generation",
|
|
17
|
+
"video-generation"
|
|
18
|
+
],
|
|
7
19
|
"license": "MIT",
|
|
8
20
|
"publishConfig": {
|
|
9
21
|
"access": "public"
|
|
@@ -17,18 +29,34 @@
|
|
|
17
29
|
"bugs": {
|
|
18
30
|
"url": "https://github.com/YasribProyas/pi-qtools/issues"
|
|
19
31
|
},
|
|
20
|
-
"files": [
|
|
32
|
+
"files": [
|
|
33
|
+
"extensions",
|
|
34
|
+
"src",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
21
38
|
"peerDependencies": {
|
|
22
39
|
"@earendil-works/pi-coding-agent": "*",
|
|
23
40
|
"@earendil-works/pi-tui": "*",
|
|
24
41
|
"typebox": "*"
|
|
25
42
|
},
|
|
26
43
|
"peerDependenciesMeta": {
|
|
27
|
-
"@earendil-works/pi-coding-agent": {
|
|
28
|
-
|
|
29
|
-
|
|
44
|
+
"@earendil-works/pi-coding-agent": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@earendil-works/pi-tui": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"typebox": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
30
53
|
},
|
|
31
54
|
"pi": {
|
|
32
|
-
"extensions": [
|
|
55
|
+
"extensions": [
|
|
56
|
+
"./extensions/qtools.ts"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"test": "node --experimental-strip-types test/parse.check.mjs && node --experimental-strip-types test/caps.test.mjs"
|
|
33
61
|
}
|
|
34
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
|
+
}
|