pi-qtools 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -10
- package/extensions/qtools.ts +215 -86
- package/package.json +5 -1
- package/src/capabilities.ts +214 -0
- package/src/catalog.ts +166 -0
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.
|
|
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,14 +122,70 @@ Persisted to `<agentDir>/qtools.json` (migrated automatically from the older
|
|
|
122
122
|
```json
|
|
123
123
|
{
|
|
124
124
|
"searchMode": "turbo",
|
|
125
|
-
"fallbackModel": "qwen3.
|
|
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
|
|
|
134
|
+
## Model catalog overlay
|
|
135
|
+
|
|
136
|
+
pi ships its model list from a remote catalog that lags the plan. Measured against
|
|
137
|
+
the live `/models` route: the plan serves **21** ids, pi's catalog knew **10**. Four
|
|
138
|
+
of the missing ones are usable chat models that simply could not be selected.
|
|
139
|
+
|
|
140
|
+
This package overlays its own measured list on top of pi's, adding only what pi
|
|
141
|
+
lacks. It runs on every model refresh, so a pi update that drops or renames a model
|
|
142
|
+
gets corrected on the next refresh without a manual edit.
|
|
143
|
+
|
|
144
|
+
| added id | verified |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `qwen4-max-preview` | chat, reasoning, image input, all 6 thinking levels |
|
|
147
|
+
| `deepseek-v4.1-flash` | chat, reasoning, image input, all 6 thinking levels |
|
|
148
|
+
| `deepseek-v4.1-flash-1017` | chat, reasoning, image input |
|
|
149
|
+
| `auto` | plan router; chat, reasoning, image input |
|
|
150
|
+
|
|
151
|
+
Every row was probed, not copied from a sibling model — the same rule the
|
|
152
|
+
capability table follows after `glm-5.3` shipped unmeasured and 400'd every turn.
|
|
153
|
+
That includes `enable_search`: all four accept it, and prompt-token inflation
|
|
154
|
+
(235-387 vs a ~10-28 baseline) confirms search actually ran rather than the flag
|
|
155
|
+
merely being tolerated. They inherit `search=true` from the Qwen family rule, so
|
|
156
|
+
this had to be measured rather than assumed.
|
|
157
|
+
`contextWindow`/`maxTokens` are plan-wide family values, not per-model measurements:
|
|
158
|
+
the endpoint accepts `max_tokens=999999` without complaint, so it cannot reveal the
|
|
159
|
+
real ceiling.
|
|
160
|
+
|
|
161
|
+
**Scope: `qwen-token-plan` and `qwen-token-plan-individual` only.** Both share the
|
|
162
|
+
ap-southeast-1 endpoint these rows were measured on. `qwen-token-plan-cn` serves
|
|
163
|
+
cn-beijing, a different regional deployment whose model list has never been probed
|
|
164
|
+
(the SG key returns 401 there), so it is deliberately excluded rather than handed
|
|
165
|
+
SG-measured ids. Add it only after probing its `/models` route with a CN credential.
|
|
166
|
+
|
|
167
|
+
**Generation and audio ids are deliberately excluded.** `wan2.7-image`,
|
|
168
|
+
`qwen-image-3.0-pro`, `wan3.0-video` and `qwen-audio-3.0-*` are not chat models;
|
|
169
|
+
adding them to pi's picker would create entries that 400 on every turn. This package
|
|
170
|
+
already drives them through their native endpoints.
|
|
171
|
+
|
|
172
|
+
### Two rules that keep it non-destructive
|
|
173
|
+
|
|
174
|
+
pi's `refreshModels` return value **replaces the provider's entire model list**, so:
|
|
175
|
+
|
|
176
|
+
1. pi's baseline is carried through verbatim, and **pi's own metadata wins** for any
|
|
177
|
+
id it already knows — its catalog is vendor-refreshed and may carry corrections.
|
|
178
|
+
2. With no baseline to merge into, the overlay returns *nothing* and adds nothing.
|
|
179
|
+
|
|
180
|
+
Rule 2 was learned the hard way. The first implementation returned `[]` on that
|
|
181
|
+
path, which **wiped all 11 built-in plan models**. Measured with pi 1.5.0:
|
|
182
|
+
`pi -ne --list-models` showed 11, the extension showed 0. Returning nothing keeps
|
|
183
|
+
pi's list intact; the additions land on the next refresh once pi has a stored
|
|
184
|
+
catalog. Nothing is persisted to `models-store.json` — that file belongs to pi's
|
|
185
|
+
catalog refresh, and writing an overlay into it would corrupt its etag bookkeeping.
|
|
186
|
+
|
|
187
|
+
`/qtools-config` reports what was added under **Added plan models**.
|
|
188
|
+
|
|
133
189
|
## Verified capability matrix
|
|
134
190
|
|
|
135
191
|
Measured against the live endpoint rather than taken from docs, because the two
|
|
@@ -137,16 +193,128 @@ surfaces differ per model:
|
|
|
137
193
|
|
|
138
194
|
| model | chat search | agent | chat code_int | `/responses` tools | `web_search_image` | `image_search` |
|
|
139
195
|
|---|---|---|---|---|---|---|
|
|
140
|
-
| qwen3.8-max | yes | no | no | all |
|
|
141
|
-
| qwen3.8-flash | yes | no | no |
|
|
142
|
-
| qwen3.7-max | yes | yes | yes | s/c/e | no | no |
|
|
143
|
-
| qwen3.7-plus | yes | yes | yes |
|
|
144
|
-
| qwen3.6-flash | yes | yes | yes |
|
|
196
|
+
| qwen3.8-max | yes | no | no | all | yes | yes |
|
|
197
|
+
| qwen3.8-flash | yes | no | no | all | **yes** | yes |
|
|
198
|
+
| qwen3.7-max | yes | yes | yes | s/c/e | **no** | **no** |
|
|
199
|
+
| qwen3.7-plus | yes | yes | yes | all | **yes** | yes |
|
|
200
|
+
| qwen3.6-flash | yes | yes | yes | all | **yes** | **yes** |
|
|
145
201
|
| deepseek-v4-* | yes | yes | yes | s/c/e | no | no |
|
|
202
|
+
| deepseek-v4.1-flash | yes | (inherited) | yes (streaming) | s/c/e | no | no |
|
|
146
203
|
| glm-5.2 | **no** | no | no | s/c/e | no | no |
|
|
204
|
+
| glm-5.3 | **no** | no | no | s/c/e | no | no |
|
|
205
|
+
| auto (plan router) | yes | unknown | unknown | s/c/e | no | no |
|
|
206
|
+
| anything unmeasured | **no** | no | no | s/c/e | no | no |
|
|
207
|
+
|
|
208
|
+
Two corrections from the 2026-10-10 remeasurement, both of which were load-bearing bugs:
|
|
209
|
+
|
|
210
|
+
- **`web_search_image` is not qwen3.8-max-only.** The table used to claim that, and it
|
|
211
|
+
is wrong: qwen3.6-flash, qwen3.7-plus and qwen3.8-flash all fire `web_search_image_call`.
|
|
212
|
+
- **qwen3.7-max cannot run either image tool** — it 400s on both. Since it was the
|
|
213
|
+
hardcoded last resort in the pickers, a GLM model asking for image search used to be
|
|
214
|
+
routed to a model that then failed the call, on the most expensive tier on the plan.
|
|
215
|
+
|
|
216
|
+
`agent` = chat `search_strategy: "agent"` (qwen_deep_search). `s/c/e` = `/responses`
|
|
217
|
+
web_search / code_interpreter / web_extractor. Cells marked (inherited) come from the
|
|
218
|
+
family rule, not from a direct measurement of that id.
|
|
219
|
+
|
|
220
|
+
Capability state lives in `src/capabilities.ts` (the measured table, the family rules, the
|
|
221
|
+
fail-safe default, the fallback chain) and imports nothing, so it is testable with no pi and
|
|
222
|
+
no network. `extensions/qtools.ts` owns everything else: config file, HTTP, tools, TUI.
|
|
223
|
+
|
|
224
|
+
`glm-5.2` and `glm-5.3` reject `enable_search` with a hard error, so injection is
|
|
225
|
+
skipped for them; without that guard every turn would fail after a model switch. They
|
|
226
|
+
can still *use* the search tools: `/responses` web_search, code_interpreter and
|
|
227
|
+
web_extractor all run on a GLM model — only the passive chat injection is lost.
|
|
228
|
+
|
|
229
|
+
### Which model runs a tool
|
|
230
|
+
|
|
231
|
+
`requested -> active -> config.fallbackModel -> FALLBACK_CHAIN`, first capable id wins:
|
|
232
|
+
|
|
233
|
+
- The **active** model is used whenever its row allows the tool, so selecting a model
|
|
234
|
+
is not a silent invitation to bill a different one — and the answering model is also
|
|
235
|
+
the model that writes the search queries and reads the pages.
|
|
236
|
+
- Only when the active row says no does the chain step off it, and it steps onto the
|
|
237
|
+
**cheapest capable** id, not a fixed one. The chain is `qwen3.6-flash -> qwen3.8-flash
|
|
238
|
+
-> qwen3.7-plus -> deepseek-v4-flash -> deepseek-v4.1-flash -> qwen3.7-max ->
|
|
239
|
+
qwen3.8-max`, led by `config.fallbackModel` (default `qwen3.6-flash`, the cheapest id
|
|
240
|
+
measured running every surface). Max tiers sit at the end because the plan bills them
|
|
241
|
+
at several times a flash tier.
|
|
242
|
+
- If nothing can serve the capability, the tool **errors with the list of models that
|
|
243
|
+
were tried** instead of quietly calling a model that will 400.
|
|
244
|
+
|
|
245
|
+
Passive injection has no such choice: it rides inside the active model's own
|
|
246
|
+
`/chat/completions` body, which is why the capability table exists at all.
|
|
247
|
+
|
|
248
|
+
### How an unknown model id is handled
|
|
249
|
+
|
|
250
|
+
Three layers, in this order. `node --experimental-strip-types test/caps.test.mjs`
|
|
251
|
+
pins all of them.
|
|
252
|
+
|
|
253
|
+
1. **Exact row** in `CAPS` (`extensions/qtools.ts`) — measured, authoritative.
|
|
254
|
+
2. **Family rule** (`FAMILY_CAPS`) — a regex on the id, so a dated release like
|
|
255
|
+
`glm-5.4` or `qwen3.9-max-1105` inherits its family's shape instead of falling
|
|
256
|
+
through. The generation-only rule (`qwen-audio-*`, `qwen-image-*`, `wan*`,
|
|
257
|
+
`happyhorse*`) must stay first, or a future `qwen-audio` id would be read as a
|
|
258
|
+
chat model. `/qtools-config` -> per-model capabilities lists the rules, and the
|
|
259
|
+
active model's line says which layer answered for it.
|
|
260
|
+
3. **Fail-safe default** — `UNKNOWN_CAPS.chat.search = false`. This is deliberate
|
|
261
|
+
and is the whole point of the layering: a wrong `true` broke *every* turn when
|
|
262
|
+
glm-5.3 shipped and matched nothing, while a wrong `false` only costs the passive
|
|
263
|
+
search bonus and `qwen_web_search` still works.
|
|
264
|
+
|
|
265
|
+
If a *family guess* turns out wrong, the extension heals itself: the finalized
|
|
266
|
+
assistant message carries the request model id and the upstream error text, so when
|
|
267
|
+
that text mentions `enable_search` the id is appended to `config.searchBlocklist`
|
|
268
|
+
and later turns skip the injection. One bad turn, not forever. Clear them in
|
|
269
|
+
`/qtools-config` -> learned skips. Measured ids never learn — the table outranks the
|
|
270
|
+
runtime, and a verified model that suddenly rejects search should surface as an
|
|
271
|
+
error rather than be silently reconfigured around.
|
|
272
|
+
|
|
273
|
+
### Adding a model the plan just shipped
|
|
274
|
+
|
|
275
|
+
1. Check whether pi even lists it. The plan's own `/models` is not the same set as
|
|
276
|
+
the pi.dev catalogue; on 2026-10-09 the endpoint served `glm-5.3`,
|
|
277
|
+
`deepseek-v4.1-flash` and `auto` while pi's catalogue listed none of them. Add the
|
|
278
|
+
missing id to `~/.pi/agent/models.json` under the provider, mirroring a sibling
|
|
279
|
+
row's `compat`/`thinkingLevelMap`, and verify `reasoning_effort` levels by hand —
|
|
280
|
+
glm-5.3 accepts only `low|high|max` and 400s on `medium`/`xhigh`, and it silently
|
|
281
|
+
drops image parts, so declare `input: ["text"]`.
|
|
282
|
+
2. Measure the chat surface: `enable_search`, and `enable_code_interpreter` **in
|
|
283
|
+
streaming mode** (non-streaming rejects it with a misleading 400).
|
|
284
|
+
3. Measure the `/responses` surface with the real `tools[].type` strings, not the
|
|
285
|
+
console labels (see Known gaps).
|
|
286
|
+
4. Add the row to `CAPS` in `src/capabilities.ts`, run `npm test`, then release. If the id
|
|
287
|
+
matches a `FAMILY_CAPS` rule, measure it anyway: family rules are inferences, and only a
|
|
288
|
+
`CAPS` row is reported as `measured`.
|
|
289
|
+
|
|
290
|
+
## Testing
|
|
291
|
+
|
|
292
|
+
```sh
|
|
293
|
+
npm test # parse guard + capability gating, no dependencies
|
|
294
|
+
bash test/injection.e2e.sh # live injection behaviour against a mock (needs pi)
|
|
295
|
+
```
|
|
147
296
|
|
|
148
|
-
`
|
|
149
|
-
|
|
297
|
+
`npm test` is two dependency-free scripts, and that is deliberate: CI runs it with **no
|
|
298
|
+
install step**, so a missing or mismatched pi package cannot break a release.
|
|
299
|
+
|
|
300
|
+
- `test/parse.check.mjs` imports `extensions/qtools.ts` and counts `ERR_MODULE_NOT_FOUND` as
|
|
301
|
+
success, because module resolution happens only *after* the TypeScript is parsed. It exists
|
|
302
|
+
because a table extraction once left two copies of `export function learnedSearchSkips` in
|
|
303
|
+
the file, and pi only reported that at load time as
|
|
304
|
+
`Failed to load extension ... has already been declared`. A file that cannot parse must
|
|
305
|
+
never reach `npm publish`.
|
|
306
|
+
- `test/caps.test.mjs` asserts the gating rules by importing `src/capabilities.ts` directly.
|
|
307
|
+
|
|
308
|
+
The workflow also fails the build if `node_modules` is ever tracked again: a node_modules
|
|
309
|
+
**symlink** is not caught by the usual `node_modules/` gitignore pattern (git stores symlinks
|
|
310
|
+
as files, mode `120000`), and one once shipped an absolute path from a maintainer's laptop
|
|
311
|
+
into the public repo. The ignore rule is `node_modules`, no trailing slash.
|
|
312
|
+
|
|
313
|
+
`test/injection.e2e.sh` boots `test/mock_endpoint.py` on 127.0.0.1, runs real `pi -p`
|
|
314
|
+
sessions in a throwaway `PI_CODING_AGENT_DIR`, and asserts whether `enable_search`
|
|
315
|
+
reached the wire for a family-yes id, a glm id, an unmeasured id, and a deliberately
|
|
316
|
+
wrong family guess (which must be learned after one rejected turn). Nothing leaves
|
|
317
|
+
the machine and your own `~/.pi/agent` is never touched.
|
|
150
318
|
|
|
151
319
|
## Known gaps
|
|
152
320
|
|
package/extensions/qtools.ts
CHANGED
|
@@ -31,6 +31,66 @@ 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";
|
|
42
|
+
import { mergeCatalog, PLAN_PROVIDERS, QTOOLS_MODELS } from "../src/catalog.ts";
|
|
43
|
+
|
|
44
|
+
// ------------------------------------------------------- token-plan catalog
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* pi's remote model catalog lags the plan's /models route, so usable chat models
|
|
48
|
+
* are simply unselectable. This overlays the ids we have measured on top of pi's
|
|
49
|
+
* own list, adding only what pi lacks.
|
|
50
|
+
*
|
|
51
|
+
* Two rules make this safe:
|
|
52
|
+
* - refreshModels' return value REPLACES the provider's whole list, so pi's
|
|
53
|
+
* baseline is carried through verbatim and its metadata wins for known ids.
|
|
54
|
+
* - with no baseline to merge into we register nothing, rather than replacing
|
|
55
|
+
* pi's catalog with only our four rows.
|
|
56
|
+
*
|
|
57
|
+
* Nothing is persisted: models-store.json belongs to pi's catalog refresh, and
|
|
58
|
+
* writing our overlay into it would corrupt its etag/lastModified bookkeeping.
|
|
59
|
+
*/
|
|
60
|
+
const catalogAdded = new Map<string, string[]>();
|
|
61
|
+
|
|
62
|
+
function registerCatalogOverlay(pi: ExtensionAPI): void {
|
|
63
|
+
for (const provider of PLAN_PROVIDERS) {
|
|
64
|
+
try {
|
|
65
|
+
pi.registerProvider(provider, {
|
|
66
|
+
async refreshModels(context) {
|
|
67
|
+
const merged = mergeCatalog(context.stored?.models);
|
|
68
|
+
if (merged.unsafe) {
|
|
69
|
+
// No baseline yet. Returning [] here is DESTRUCTIVE: measured with
|
|
70
|
+
// pi 1.5.0, an empty return wiped all 11 built-in plan models
|
|
71
|
+
// (pi -ne showed 11, this extension showed 0). Return nothing so pi
|
|
72
|
+
// keeps its own list; we add ours on the next refresh, once pi has
|
|
73
|
+
// a stored catalog to merge into. pi's own providers do the same.
|
|
74
|
+
catalogAdded.delete(provider);
|
|
75
|
+
return undefined as never;
|
|
76
|
+
}
|
|
77
|
+
if (merged.added.length > 0) catalogAdded.set(provider, merged.added);
|
|
78
|
+
else catalogAdded.delete(provider);
|
|
79
|
+
return merged.models as never[];
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
// A provider pi does not know, or a registration it rejects, must never
|
|
84
|
+
// take the rest of the extension down with it.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What the overlay added, for /qtools-config. */
|
|
90
|
+
function catalogReport(): string {
|
|
91
|
+
if (catalogAdded.size === 0) return "pi's catalog already covers every measured model";
|
|
92
|
+
return [...catalogAdded.entries()].map(([p, ids]) => `${p}: +${ids.join(", ")}`).join(" | ");
|
|
93
|
+
}
|
|
34
94
|
|
|
35
95
|
// ------------------------------------------------------------------ capability
|
|
36
96
|
|
|
@@ -43,79 +103,36 @@ const BASE_URLS: Record<string, string> = {
|
|
|
43
103
|
};
|
|
44
104
|
|
|
45
105
|
/**
|
|
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.
|
|
106
|
+
* Capability decisions live in src/capabilities.ts, which imports nothing, so they are
|
|
107
|
+
* unit-testable without pi, without the network and without a config file. Read the
|
|
108
|
+
* runtime-mutable parts (learned skips, configured fallback head) through getters.
|
|
51
109
|
*/
|
|
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,
|
|
110
|
+
const { capsFor, capsSource, searchCapable, fallbackChain, candidateOrder, chooseModel } = createCaps({
|
|
111
|
+
blocklist: () => config.searchBlocklist,
|
|
112
|
+
fallbackModel: () => config.fallbackModel,
|
|
71
113
|
});
|
|
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
114
|
|
|
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
115
|
|
|
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
|
-
|
|
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
116
|
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
/** Test/maintenance hook: the ids the runtime has learned to leave alone. */
|
|
118
|
+
export function learnedSearchSkips(): string[] {
|
|
119
|
+
return [...config.searchBlocklist];
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
123
|
+
* pi never fires after_provider_response for a non-2xx (the OpenAI SDK throws first), so the
|
|
124
|
+
* capability verdict comes from the finalized assistant message instead: it carries the request
|
|
125
|
+
* model id and the upstream error text, which names enable_search. Matching that text is what
|
|
126
|
+
* makes this safe — an input-length 400 cannot be mistaken for a capability verdict, and no
|
|
127
|
+
* request/response pairing or cross-request state is needed.
|
|
116
128
|
*/
|
|
117
|
-
|
|
118
|
-
|
|
129
|
+
const SEARCH_REJECTED = /enable_search/i;
|
|
130
|
+
|
|
131
|
+
function learnSearchReject(modelId: string): boolean {
|
|
132
|
+
if (config.searchBlocklist.includes(modelId)) return false;
|
|
133
|
+
config.searchBlocklist = [...config.searchBlocklist, modelId];
|
|
134
|
+
saveConfig();
|
|
135
|
+
return true;
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
// --------------------------------------------------------------------- config
|
|
@@ -132,6 +149,12 @@ interface QwenToolsConfig {
|
|
|
132
149
|
/** Defaults for the generation models (native endpoints, not chat models). */
|
|
133
150
|
imageModel: string;
|
|
134
151
|
videoModel: string;
|
|
152
|
+
/**
|
|
153
|
+
* Model ids that answered an injected enable_search with a 400. Learned at runtime
|
|
154
|
+
* so one unmeasured model can never break every turn the way glm-5.3 did; cleared
|
|
155
|
+
* by deleting entries from the file or with /qtools-config -> learned skips.
|
|
156
|
+
*/
|
|
157
|
+
searchBlocklist: string[];
|
|
135
158
|
}
|
|
136
159
|
|
|
137
160
|
/** Verified working on this plan via the native multimodal-generation endpoint. */
|
|
@@ -142,11 +165,12 @@ const VIDEO_SIZES = ["1280*720", "1920*1080", "832*480"];
|
|
|
142
165
|
|
|
143
166
|
const DEFAULTS: QwenToolsConfig = {
|
|
144
167
|
searchMode: "turbo",
|
|
145
|
-
fallbackModel: "qwen3.
|
|
168
|
+
fallbackModel: "qwen3.6-flash",
|
|
146
169
|
showReasoning: false,
|
|
147
170
|
deepSearchMaxTokens: 16384,
|
|
148
171
|
imageModel: "wan2.7-image",
|
|
149
172
|
videoModel: "happyhorse-1.1-t2v",
|
|
173
|
+
searchBlocklist: [],
|
|
150
174
|
};
|
|
151
175
|
|
|
152
176
|
const MODE_NOTES: Record<SearchMode, string> = {
|
|
@@ -192,6 +216,9 @@ function loadConfig(): void {
|
|
|
192
216
|
fallbackModel: typeof raw.fallbackModel === "string" && raw.fallbackModel ? raw.fallbackModel : DEFAULTS.fallbackModel,
|
|
193
217
|
imageModel: IMAGE_MODELS.includes(raw.imageModel ?? "") ? raw.imageModel! : DEFAULTS.imageModel,
|
|
194
218
|
videoModel: VIDEO_MODELS.includes(raw.videoModel ?? "") ? raw.videoModel! : DEFAULTS.videoModel,
|
|
219
|
+
searchBlocklist: Array.isArray(raw.searchBlocklist)
|
|
220
|
+
? raw.searchBlocklist.filter((id): id is string => typeof id === "string" && id.length > 0)
|
|
221
|
+
: DEFAULTS.searchBlocklist,
|
|
195
222
|
};
|
|
196
223
|
// Migrate the old filename once so the new one is authoritative.
|
|
197
224
|
if (source !== p) saveConfig();
|
|
@@ -235,26 +262,35 @@ async function resolveEndpoint(ctx: ExtensionContext): Promise<{ baseUrl: string
|
|
|
235
262
|
return { baseUrl, apiKey };
|
|
236
263
|
}
|
|
237
264
|
|
|
265
|
+
function pickModel(ctx: ExtensionContext, eligible: (id: string) => boolean, requested?: string): string | undefined {
|
|
266
|
+
return chooseModel(eligible, ctx.model?.id, requested);
|
|
267
|
+
}
|
|
268
|
+
|
|
238
269
|
/** Chat-completions agent-mode side call (deep search): needs chat.agent support. */
|
|
239
270
|
function pickChatCapable(ctx: ExtensionContext, requested?: string): string {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
271
|
+
const id = pickModel(ctx, (c) => capsFor(c).chat.agent, requested);
|
|
272
|
+
if (id) return id;
|
|
273
|
+
throw new Error(
|
|
274
|
+
`No model on this plan runs chat agent mode (search_strategy: "agent"). Candidates tried: ${candidateOrder(
|
|
275
|
+
ctx.model?.id,
|
|
276
|
+
requested,
|
|
277
|
+
).join(", ")}.`,
|
|
278
|
+
);
|
|
245
279
|
}
|
|
246
280
|
|
|
247
281
|
/**
|
|
248
|
-
* /responses tools: keep the ACTIVE model
|
|
249
|
-
*
|
|
250
|
-
*
|
|
282
|
+
* /responses tools: keep the ACTIVE model whenever it can serve the tool, so the model the
|
|
283
|
+
* user selected is the one billed and the one writing the answer. Only step off it when its
|
|
284
|
+
* row says no, and then step onto the cheapest capable id rather than a fixed expensive one.
|
|
251
285
|
*/
|
|
252
286
|
function pickResponsesModel(ctx: ExtensionContext, tool: keyof Caps["responses"], requested?: string): string {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
287
|
+
const id = pickModel(ctx, (c) => capsFor(c).responses[tool], requested);
|
|
288
|
+
if (id) return id;
|
|
289
|
+
const works = modelsSupporting(tool);
|
|
290
|
+
throw new Error(
|
|
291
|
+
`No model on this plan runs the /responses "${tool}" tool.` +
|
|
292
|
+
(works.length ? ` Measured working on: ${works.join(", ")}.` : " Nothing has been measured working on it yet."),
|
|
293
|
+
);
|
|
258
294
|
}
|
|
259
295
|
|
|
260
296
|
// --------------------------------------------------------------- /responses
|
|
@@ -967,7 +1003,12 @@ const TYPE_MAP: Array<{ label: string; type: string; tool: string }> = [
|
|
|
967
1003
|
{ label: "i2i_search", type: "image_search", tool: "qwen_reverse_image_search" },
|
|
968
1004
|
];
|
|
969
1005
|
|
|
970
|
-
|
|
1006
|
+
/**
|
|
1007
|
+
* Real model ids with their measured rows. This is the list the fallback-model submenu
|
|
1008
|
+
* offers, so it must stay id-only: a family rule or the "anything else" row is not a
|
|
1009
|
+
* model id, and selecting one would write "family:0" into config.fallbackModel.
|
|
1010
|
+
*/
|
|
1011
|
+
function modelIdItems(): SelectItem[] {
|
|
971
1012
|
return Object.entries(CAPS).map(([id, caps]) => ({
|
|
972
1013
|
value: id,
|
|
973
1014
|
label: id,
|
|
@@ -975,6 +1016,25 @@ function capabilityItems(): SelectItem[] {
|
|
|
975
1016
|
}));
|
|
976
1017
|
}
|
|
977
1018
|
|
|
1019
|
+
/**
|
|
1020
|
+
* Read-only capability browser: measured rows first, then the family rules that catch ids
|
|
1021
|
+
* nobody measured, then the fail-safe default, so provenance is visible without guessing.
|
|
1022
|
+
*/
|
|
1023
|
+
function capabilityItems(): SelectItem[] {
|
|
1024
|
+
const rows: SelectItem[] = modelIdItems();
|
|
1025
|
+
rows.push(...FAMILY_CAPS.map((rule, i) => ({
|
|
1026
|
+
value: `family:${i}`,
|
|
1027
|
+
label: `family ${rule.match.source}`,
|
|
1028
|
+
description: `${capabilitySummary(rule.caps)} | inferred: ${rule.why}`,
|
|
1029
|
+
})));
|
|
1030
|
+
rows.push({
|
|
1031
|
+
value: "unknown",
|
|
1032
|
+
label: "anything else",
|
|
1033
|
+
description: `${capabilitySummary(UNKNOWN_CAPS)} | fail-safe: nothing assumed about enable_search`,
|
|
1034
|
+
});
|
|
1035
|
+
return rows;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
978
1038
|
function typeMapItems(): SelectItem[] {
|
|
979
1039
|
return TYPE_MAP.map((t) => ({
|
|
980
1040
|
value: t.type,
|
|
@@ -991,16 +1051,27 @@ function descriptionFor(id: string, ctx: ExtensionContext): string {
|
|
|
991
1051
|
case "searchMode":
|
|
992
1052
|
return (
|
|
993
1053
|
MODE_NOTES[config.searchMode] +
|
|
994
|
-
(caps.chat.search
|
|
1054
|
+
(caps.chat.search
|
|
1055
|
+
? ""
|
|
1056
|
+
: ` — WARNING: ${model} rejects enable_search (${capsSource(model)}), so injection is skipped`) +
|
|
1057
|
+
(config.searchBlocklist.length ? ` | learned skips: ${config.searchBlocklist.join(", ")}` : "")
|
|
995
1058
|
);
|
|
996
1059
|
case "fallbackModel":
|
|
997
|
-
return
|
|
1060
|
+
return (
|
|
1061
|
+
`Head of the cheap-first chain the qwen_* tools step onto when the ACTIVE model cannot serve a ` +
|
|
1062
|
+
`tool. Currently ${config.fallbackModel}; chain tried in order: ${fallbackChain().join(" -> ")}. ` +
|
|
1063
|
+
`The active model is always preferred, so this only matters for models with gaps (GLM rejects image tools).`
|
|
1064
|
+
);
|
|
998
1065
|
case "showReasoning":
|
|
999
1066
|
return "Adds the model's own plan/rationale to tool results. More context, more tokens.";
|
|
1000
1067
|
case "deepSearchMaxTokens":
|
|
1001
1068
|
return `max_tokens cap for qwen_deep_search. It burned ~340k prompt tokens uncapped; limit is ${config.deepSearchMaxTokens} now.`;
|
|
1002
1069
|
case "capabilities":
|
|
1003
1070
|
return "Measured against the live endpoint. Note the two image tools are gated differently.";
|
|
1071
|
+
case "learnedSkips":
|
|
1072
|
+
return config.searchBlocklist.length
|
|
1073
|
+
? `Unmeasured models that took a 400 on the injected enable_search: ${config.searchBlocklist.join(", ")}. Select clear to forget them.`
|
|
1074
|
+
: "No runtime-learned skips. A 400 on an injected request for an unmeasured model lands here.";
|
|
1004
1075
|
case "imageModel":
|
|
1005
1076
|
return `Default for qwen_generate_image and /qimage. All three verified working via the native multimodal-generation endpoint.`;
|
|
1006
1077
|
case "videoModel":
|
|
@@ -1009,6 +1080,12 @@ function descriptionFor(id: string, ctx: ExtensionContext): string {
|
|
|
1009
1080
|
return "Console capability labels differ from the API tools[].type strings. Sending a label is silently ignored.";
|
|
1010
1081
|
case "provider":
|
|
1011
1082
|
return "Where the API key and base URL come from, plus whether the active model is a Qwen plan model.";
|
|
1083
|
+
case "catalog":
|
|
1084
|
+
return (
|
|
1085
|
+
`Chat models this package adds on top of pi's own catalog, which lags the plan's /models route. ` +
|
|
1086
|
+
`${QTOOLS_MODELS.length} measured rows; pi's metadata always wins for ids it already knows. ` +
|
|
1087
|
+
`Generation/audio ids are deliberately excluded: they are not chat models and would 400 in the picker.`
|
|
1088
|
+
);
|
|
1012
1089
|
}
|
|
1013
1090
|
return "";
|
|
1014
1091
|
}
|
|
@@ -1020,7 +1097,8 @@ function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
|
|
|
1020
1097
|
const capLine =
|
|
1021
1098
|
`${model}: chat ${y(caps.chat.search)}${y(caps.chat.agent)}${y(caps.chat.ci)}` +
|
|
1022
1099
|
` | 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)}
|
|
1100
|
+
`${y(caps.responses.web_search_image)}${y(caps.responses.image_search)}` +
|
|
1101
|
+
` | ${capsSource(model)}`;
|
|
1024
1102
|
return [
|
|
1025
1103
|
{
|
|
1026
1104
|
id: "searchMode",
|
|
@@ -1038,7 +1116,7 @@ function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
|
|
|
1038
1116
|
// closeSubmenu() leaves submenuComponent set, so the submenu never closes
|
|
1039
1117
|
// (onChange still fires, so it looks like a render hang). The cursor already
|
|
1040
1118
|
// returns to this row, so navigateTo buys nothing here.
|
|
1041
|
-
submenu: (current, done) => makeSubmenu(th, "Built-in tool model",
|
|
1119
|
+
submenu: (current, done) => makeSubmenu(th, "Built-in tool model", modelIdItems(), current, (v) => done(v)),
|
|
1042
1120
|
},
|
|
1043
1121
|
{
|
|
1044
1122
|
id: "showReasoning",
|
|
@@ -1103,6 +1181,19 @@ function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
|
|
|
1103
1181
|
currentValue: capLine,
|
|
1104
1182
|
submenu: (_current, done) => makeSubmenu(th, "Per-model capabilities", capabilityItems(), "", () => done(undefined)),
|
|
1105
1183
|
},
|
|
1184
|
+
{
|
|
1185
|
+
id: "learnedSkips",
|
|
1186
|
+
label: "learned skips",
|
|
1187
|
+
description: descriptionFor("learnedSkips", ctx),
|
|
1188
|
+
currentValue: config.searchBlocklist.length ? config.searchBlocklist.join(", ") : "none",
|
|
1189
|
+
values: ["clear"],
|
|
1190
|
+
},
|
|
1191
|
+
{
|
|
1192
|
+
id: "catalog",
|
|
1193
|
+
label: "Added plan models",
|
|
1194
|
+
description: descriptionFor("catalog", ctx),
|
|
1195
|
+
currentValue: catalogReport(),
|
|
1196
|
+
},
|
|
1106
1197
|
{
|
|
1107
1198
|
id: "provider",
|
|
1108
1199
|
label: "Provider",
|
|
@@ -1118,7 +1209,9 @@ function applyChange(id: string, value: string): void {
|
|
|
1118
1209
|
if (SEARCH_MODES.includes(value as SearchMode)) config.searchMode = value as SearchMode;
|
|
1119
1210
|
break;
|
|
1120
1211
|
case "fallbackModel":
|
|
1121
|
-
|
|
1212
|
+
// Guard the submenu contract: anything that is not a measured id would silently
|
|
1213
|
+
// demote the whole fallback chain to "no capable model found".
|
|
1214
|
+
if (value && CAPS[value]) config.fallbackModel = value;
|
|
1122
1215
|
break;
|
|
1123
1216
|
case "showReasoning":
|
|
1124
1217
|
config.showReasoning = value === "enabled";
|
|
@@ -1129,6 +1222,9 @@ function applyChange(id: string, value: string): void {
|
|
|
1129
1222
|
case "videoModel":
|
|
1130
1223
|
if (VIDEO_MODELS.includes(value)) config.videoModel = value;
|
|
1131
1224
|
break;
|
|
1225
|
+
case "learnedSkips":
|
|
1226
|
+
if (value === "clear") config.searchBlocklist = [];
|
|
1227
|
+
break;
|
|
1132
1228
|
case "deepSearchMaxTokens": {
|
|
1133
1229
|
const n = Number.parseInt(value, 10);
|
|
1134
1230
|
if (Number.isFinite(n) && n > 0) config.deepSearchMaxTokens = n;
|
|
@@ -1143,6 +1239,9 @@ function applyChange(id: string, value: string): void {
|
|
|
1143
1239
|
loadConfig();
|
|
1144
1240
|
|
|
1145
1241
|
export default function (pi: ExtensionAPI): void {
|
|
1242
|
+
// Fill in plan models pi's catalog has not caught up with yet.
|
|
1243
|
+
registerCatalogOverlay(pi);
|
|
1244
|
+
|
|
1146
1245
|
// ---- main-loop web_search injection (chat/completions) ----
|
|
1147
1246
|
pi.on("before_provider_request", (event, ctx) => {
|
|
1148
1247
|
if (config.searchMode === "off" || !isQwen(ctx)) return undefined;
|
|
@@ -1165,6 +1264,34 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1165
1264
|
};
|
|
1166
1265
|
});
|
|
1167
1266
|
|
|
1267
|
+
// ---- self-heal: an enable_search error retires that model's passive search ----
|
|
1268
|
+
pi.on("message_end", (event, ctx) => {
|
|
1269
|
+
const message = event.message as {
|
|
1270
|
+
role?: string;
|
|
1271
|
+
provider?: string;
|
|
1272
|
+
model?: string;
|
|
1273
|
+
stopReason?: string;
|
|
1274
|
+
errorMessage?: string;
|
|
1275
|
+
};
|
|
1276
|
+
if (message.role !== "assistant" || message.stopReason !== "error") return;
|
|
1277
|
+
if (!(message.provider ?? "").startsWith(PROVIDER_PREFIX)) return;
|
|
1278
|
+
const detail = message.errorMessage ?? "";
|
|
1279
|
+
if (!SEARCH_REJECTED.test(detail)) return;
|
|
1280
|
+
const id = message.model;
|
|
1281
|
+
if (!id) return;
|
|
1282
|
+
// Only unmeasured ids get to learn: the table is authoritative, and a measured model
|
|
1283
|
+
// that suddenly rejects search is an upstream change the maintainer should see as an
|
|
1284
|
+
// error rather than have silently reconfigured around.
|
|
1285
|
+
if (CAPS[id]) return;
|
|
1286
|
+
if (!learnSearchReject(id)) return;
|
|
1287
|
+
ctx.ui.notify(
|
|
1288
|
+
`qtools: ${id} is unmeasured and rejected the injected enable_search, so passive search is now ` +
|
|
1289
|
+
`skipped for it (qwen_web_search still works). New ids belong in CAPS in src/capabilities.ts; ` +
|
|
1290
|
+
`this skip is in config.searchBlocklist, clearable via /qtools-config -> learned skips.`,
|
|
1291
|
+
"warning",
|
|
1292
|
+
);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1168
1295
|
// ------------------------------------------------------------------- tools
|
|
1169
1296
|
pi.registerTool({
|
|
1170
1297
|
name: "qwen_web_search",
|
|
@@ -1258,7 +1385,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1258
1385
|
description:
|
|
1259
1386
|
"Search the web for images matching a text description, using Qwen's server-side web_search_image " +
|
|
1260
1387
|
"tool. Returns the image URLs it found plus the queries it issued. Qwen runs the retrieval; results " +
|
|
1261
|
-
"are real URLs, not model guesses.
|
|
1388
|
+
"are real URLs, not model guesses. Runs on the plan's flash tiers as well (qwen3.6-flash, " +
|
|
1389
|
+
"qwen3.7-plus, qwen3.8-flash, qwen3.8-max); glm-* and deepseek-* cannot, and those calls are " +
|
|
1390
|
+
"routed to a capable model for you.",
|
|
1262
1391
|
promptSnippet: "Find images on the web from a text description",
|
|
1263
1392
|
promptGuidelines: [
|
|
1264
1393
|
"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 +1395,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1266
1395
|
],
|
|
1267
1396
|
parameters: Type.Object({
|
|
1268
1397
|
query: Type.String({ description: "What to find images of, as a short descriptive phrase." }),
|
|
1269
|
-
model: Type.Optional(Type.String({ description: "Override model id (
|
|
1398
|
+
model: Type.Optional(Type.String({ description: "Override model id (flash tiers work here)." })),
|
|
1270
1399
|
}),
|
|
1271
1400
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1272
1401
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Qwen Token Plan bridge for pi: web search, scraping, code interpreter, image search, and image/video generation via DashScope built-in tools and native task endpoints.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -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 && node --experimental-strip-types test/catalog.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
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Token Plan model catalog, with NO imports at all — same discipline as
|
|
3
|
+
* capabilities.ts: pi-specific wiring lives in ../extensions/qtools.ts, and the
|
|
4
|
+
* decision logic here is unit-tested without pi, without the network, without a
|
|
5
|
+
* config file.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS EXISTS
|
|
8
|
+
* pi ships its model list from a remote catalog that lags the endpoint. Measured
|
|
9
|
+
* 2026-10-10 against the live /models route: the plan serves 21 ids, pi's catalog
|
|
10
|
+
* knew 10. The 11 missing included four usable chat models (qwen4-max-preview,
|
|
11
|
+
* deepseek-v4.1-flash, deepseek-v4.1-flash-1017, auto) that simply could not be
|
|
12
|
+
* selected in pi.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IS DELIBERATELY NOT HERE
|
|
15
|
+
* The generation ids the endpoint also lists — wan2.7-image, wan2.7-image-pro,
|
|
16
|
+
* qwen-image-3.0-pro, wan3.0-video, qwen-audio-3.0-* — are NOT chat models. Adding
|
|
17
|
+
* them to pi's chat picker would create entries that 400 on every turn. This
|
|
18
|
+
* package already drives them through their native endpoints (qwen_generate_image,
|
|
19
|
+
* qwen_generate_video), which is the only surface they answer on.
|
|
20
|
+
*
|
|
21
|
+
* Every row below was probed, not copied from a sibling model: chat completion,
|
|
22
|
+
* reasoning_content, image input, and all six reasoning_effort levels. That is the
|
|
23
|
+
* same rule capabilities.ts follows after glm-5.3 shipped unmeasured and 400'd every
|
|
24
|
+
* turn. contextWindow/maxTokens are the plan-wide family values, not per-model
|
|
25
|
+
* measurements — the endpoint accepts max_tokens=999999 without complaint, so it
|
|
26
|
+
* cannot be used to discover the real ceiling.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Shape pi's registerProvider/refreshModels expects for a chat model. */
|
|
30
|
+
export interface CatalogModel {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
type: "chat";
|
|
34
|
+
api: string;
|
|
35
|
+
reasoning: boolean;
|
|
36
|
+
input: Array<"text" | "image">;
|
|
37
|
+
contextWindow: number;
|
|
38
|
+
maxTokens: number;
|
|
39
|
+
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
40
|
+
compat: Record<string, unknown>;
|
|
41
|
+
thinkingLevelMap: Record<string, string | null>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Subscription plan: tokens are prepaid, so per-token cost is zero. */
|
|
45
|
+
const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
46
|
+
|
|
47
|
+
/** Verified on every plan model probed; matches pi's own qwen3.8-max entry. */
|
|
48
|
+
const QWEN_COMPAT = {
|
|
49
|
+
supportsStrictMode: true,
|
|
50
|
+
thinkingFormat: "qwen",
|
|
51
|
+
supportsDeveloperRole: false,
|
|
52
|
+
supportsStore: false,
|
|
53
|
+
supportsReasoningEffort: true,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** All six levels returned 200 on every model below, so none are mapped to null. */
|
|
57
|
+
const ALL_LEVELS = {
|
|
58
|
+
off: null,
|
|
59
|
+
minimal: "minimal",
|
|
60
|
+
low: "low",
|
|
61
|
+
medium: "medium",
|
|
62
|
+
high: "high",
|
|
63
|
+
xhigh: "xhigh",
|
|
64
|
+
max: "max",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const model = (id: string, name: string, maxTokens: number): CatalogModel => ({
|
|
68
|
+
id,
|
|
69
|
+
name,
|
|
70
|
+
type: "chat",
|
|
71
|
+
api: "openai-completions",
|
|
72
|
+
reasoning: true,
|
|
73
|
+
input: ["text", "image"],
|
|
74
|
+
contextWindow: 1000000,
|
|
75
|
+
maxTokens,
|
|
76
|
+
cost: ZERO_COST,
|
|
77
|
+
compat: QWEN_COMPAT,
|
|
78
|
+
thinkingLevelMap: ALL_LEVELS,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Chat models the plan serves that pi's catalog may lack.
|
|
83
|
+
* Probed 2026-10-10: all four answer chat, emit reasoning_content, accept image
|
|
84
|
+
* input (each correctly identified a test PNG as a pig), and accept every
|
|
85
|
+
* reasoning_effort level.
|
|
86
|
+
*/
|
|
87
|
+
export const QTOOLS_MODELS: readonly CatalogModel[] = [
|
|
88
|
+
model("qwen4-max-preview", "Qwen4 Max (preview)", 131072),
|
|
89
|
+
model("deepseek-v4.1-flash", "DeepSeek V4.1 Flash", 131072),
|
|
90
|
+
model("deepseek-v4.1-flash-1017", "DeepSeek V4.1 Flash 1017", 131072),
|
|
91
|
+
// Router id: the plan picks the backing model per request.
|
|
92
|
+
model("auto", "Qwen Auto (plan router)", 131072),
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Provider ids this overlay applies to.
|
|
97
|
+
*
|
|
98
|
+
* Deliberately NOT all three plan providers: `qwen-token-plan-cn` serves
|
|
99
|
+
* cn-beijing, a different regional deployment whose model list has never been
|
|
100
|
+
* measured (the SG key returns 401 there). Pushing the SG-measured ids onto it
|
|
101
|
+
* would be exactly the unmeasured assumption that made glm-5.3 400 every turn.
|
|
102
|
+
* Both ids below share the ap-southeast-1 endpoint these rows were probed on.
|
|
103
|
+
* Add CN only after probing its /models route with a CN credential.
|
|
104
|
+
*/
|
|
105
|
+
export const PLAN_PROVIDERS = [
|
|
106
|
+
"qwen-token-plan",
|
|
107
|
+
"qwen-token-plan-individual",
|
|
108
|
+
] as const;
|
|
109
|
+
|
|
110
|
+
export interface MergeResult {
|
|
111
|
+
/** The full list to hand back to pi: baseline first, additions appended. */
|
|
112
|
+
models: unknown[];
|
|
113
|
+
/** Ids actually added, for logging and for the /qtools-config report. */
|
|
114
|
+
added: string[];
|
|
115
|
+
/**
|
|
116
|
+
* True when there was no baseline to merge into. Registering then would REPLACE
|
|
117
|
+
* pi's catalog with only our four rows, deleting every model pi knows about —
|
|
118
|
+
* so the caller must skip registration instead.
|
|
119
|
+
*/
|
|
120
|
+
unsafe: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Merge our curated rows into pi's existing catalog, adding only ids pi lacks.
|
|
125
|
+
*
|
|
126
|
+
* pi's `models`/refreshModels return value REPLACES the provider's whole list, so
|
|
127
|
+
* the baseline must be carried through verbatim. pi's own metadata always wins for
|
|
128
|
+
* an id it already knows: its catalog is refreshed from the vendor and may carry
|
|
129
|
+
* corrections (context windows, compat flags) that our static rows do not.
|
|
130
|
+
*/
|
|
131
|
+
export function mergeCatalog(
|
|
132
|
+
baseline: readonly unknown[] | undefined,
|
|
133
|
+
curated: readonly CatalogModel[] = QTOOLS_MODELS,
|
|
134
|
+
): MergeResult {
|
|
135
|
+
if (!baseline || baseline.length === 0) {
|
|
136
|
+
return { models: [], added: [], unsafe: true };
|
|
137
|
+
}
|
|
138
|
+
// Dedupe the baseline too, first occurrence wins: pi's catalog should not contain
|
|
139
|
+
// repeats, but a duplicate id reaching registerProvider is worse than a dropped
|
|
140
|
+
// copy, and keeping the first preserves pi's own metadata and ordering.
|
|
141
|
+
const seen = new Set<string>();
|
|
142
|
+
const models: unknown[] = [];
|
|
143
|
+
for (const entry of baseline) {
|
|
144
|
+
const id = (entry as { id?: unknown })?.id;
|
|
145
|
+
if (typeof id === "string") {
|
|
146
|
+
if (seen.has(id)) continue;
|
|
147
|
+
seen.add(id);
|
|
148
|
+
}
|
|
149
|
+
models.push(entry);
|
|
150
|
+
}
|
|
151
|
+
const additions = curated.filter((m) => !seen.has(m.id));
|
|
152
|
+
for (const m of additions) seen.add(m.id);
|
|
153
|
+
return {
|
|
154
|
+
models: [...models, ...additions],
|
|
155
|
+
added: additions.map((m) => m.id),
|
|
156
|
+
unsafe: false,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Ids our catalog would add, without building the merged list. */
|
|
161
|
+
export function missingIds(
|
|
162
|
+
baseline: readonly unknown[] | undefined,
|
|
163
|
+
curated: readonly CatalogModel[] = QTOOLS_MODELS,
|
|
164
|
+
): string[] {
|
|
165
|
+
return mergeCatalog(baseline, curated).added;
|
|
166
|
+
}
|