@pi-unipi/image 2.2.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,10 +22,20 @@ image_generate(prompt: "A cutaway diagram of a submarine, technical illustration
22
22
  image_generate(prompt: "...", model: "flux.2-pro")
23
23
  ```
24
24
 
25
- Models come from pi-ai's image catalog 34 models including FLUX.2,
26
- Gemini 3 Pro Image, GPT-5 Image, Recraft and Riverflow — all served through
27
- **OpenRouter**, so an OpenRouter key is required
28
- ([get one](https://openrouter.ai/keys)).
25
+ Models come from two places, merged into one list:
26
+
27
+ - pi-ai's built-in image catalog 34 models including FLUX.2, Gemini 3 Pro
28
+ Image, GPT-5 Image, Recraft and Riverflow — served through **OpenRouter**
29
+ ([get a key](https://openrouter.ai/keys)).
30
+ - **Any provider registered by another extension.** Third-party providers
31
+ publish no image metadata, so these are detected by name; a real registry
32
+ with 393 models contributed 12 generators (FLUX, Nano Banana, Ideogram,
33
+ Recraft, Seedream, Stable Diffusion) alongside the built-ins.
34
+
35
+ Because that detection is heuristic, the picker has a **custom entry** — press
36
+ `c` in `/unipi:image-settings` and type any `provider/model-id`. A
37
+ well-formed reference is always accepted, even when the catalog has never
38
+ heard of it, so no model is ever unreachable.
29
39
 
30
40
  The `model` parameter is fuzzy-matched, so `flux`, `recraft` and
31
41
  `gemini-3-pro` all work. Omit it to use the model chosen in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/image",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "Image generation and image recognition tools for the Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -63,8 +63,11 @@ magic numbers, so a misnamed extension still works.
63
63
 
64
64
  `/unipi:image-settings` configures both tools:
65
65
 
66
- - Generation model (picker over the image catalog)
66
+ - Generation model (picker over the built-in catalog plus image models from
67
+ any provider registered by another extension)
67
68
  - Recognition model (picker over vision-capable models only)
69
+ - Either picker accepts a **custom** `provider/model-id` — press `c` — for a
70
+ model the catalog does not list
68
71
  - Enable/disable either tool
69
72
  - Output directory and whether to save to disk
70
73
  - The recognition system prompt
package/src/models.ts CHANGED
@@ -72,10 +72,20 @@ let imagesModelsAttempted = false;
72
72
  /**
73
73
  * Load pi-ai's built-in images collection.
74
74
  *
75
- * `getImageModels`/`generateImages` are not re-exported from the pi-ai package
76
- * root, but `providers/all` exports `builtinImagesModels()`, which is the
77
- * supported entry point and also resolves auth. A failure is not fatal: it
78
- * degrades to null and callers report that no models are available.
75
+ * ⚠️ Do NOT reach for `getImageModels`/`getImageProviders`/`generateImages`:
76
+ *
77
+ * - They are NOT re-exported from the pi-ai package root. Importing
78
+ * `@earendil-works/pi-ai` and calling `getImageModels("openrouter")`
79
+ * returns an EMPTY ARRAY rather than throwing, so the mistake looks like
80
+ * "no models are installed" and costs a long debugging session.
81
+ * - The file that defines them, `dist/image-models.js`, is not a permitted
82
+ * subpath in pi-ai's `exports` map, so importing it directly throws
83
+ * ERR_PACKAGE_PATH_NOT_EXPORTED.
84
+ *
85
+ * The supported entry point is `@earendil-works/pi-ai/providers/all` →
86
+ * `builtinImagesModels()`, which returns the catalog AND resolves auth.
87
+ * A failure is not fatal: it degrades to null and callers report that no
88
+ * models are available.
79
89
  */
80
90
  export async function getImagesModels(): Promise<ImagesModelsLike | null> {
81
91
  if (cachedImagesModels || imagesModelsAttempted) return cachedImagesModels;
@@ -106,6 +116,111 @@ export async function listImageGenModels(): Promise<ImageGenModel[]> {
106
116
  }
107
117
  }
108
118
 
119
+ /**
120
+ * Heuristic: does a chat-registry model look like an image generator?
121
+ *
122
+ * Third-party providers registered by other extensions (pi-omniroute-bridge,
123
+ * for example) surface text-to-image endpoints as ordinary chat models. They
124
+ * declare no `output` modality at all, so a strict `output.includes("image")`
125
+ * check finds nothing and the user sees only pi-ai's built-in OpenRouter
126
+ * catalog. We therefore accept an explicit image output when present, and
127
+ * otherwise fall back to well-known generator naming.
128
+ */
129
+ const GENERATOR_NAME_HINTS = [
130
+ "text-to-image",
131
+ "flux",
132
+ "dall-e",
133
+ "dalle",
134
+ "imagen",
135
+ "recraft",
136
+ "seedream",
137
+ "riverflow",
138
+ "grok-imagine",
139
+ "stable-diffusion",
140
+ "sdxl",
141
+ "midjourney",
142
+ "ideogram",
143
+ "nano-banana",
144
+ ];
145
+
146
+ export function looksLikeImageGenerator(model: {
147
+ id: string;
148
+ name?: string;
149
+ output?: string[];
150
+ }): boolean {
151
+ // An explicit declaration always wins.
152
+ if (Array.isArray(model.output)) {
153
+ if (model.output.includes("image")) return true;
154
+ // Declared, but text-only — trust it and do not guess from the name.
155
+ if (model.output.length > 0) return false;
156
+ }
157
+
158
+ const haystack = `${model.id} ${model.name ?? ""}`.toLowerCase();
159
+ // Match "image" as a delimited segment ("gpt-5-image", "gemini-3-pro-image"),
160
+ // not the bare word anywhere — which would wrongly catch vision models such
161
+ // as "claude-image-understanding". \b handles the whitespace between id and
162
+ // name; a character class alone missed ids ending the id portion.
163
+ if (/(^|[/\-_\s])image\b/.test(haystack) && !haystack.includes("understand")) {
164
+ return true;
165
+ }
166
+ return GENERATOR_NAME_HINTS.some((hint) => haystack.includes(hint));
167
+ }
168
+
169
+ /**
170
+ * Image-generation models discovered from the chat registry — i.e. providers
171
+ * registered by other extensions, which pi-ai's built-in catalog knows nothing
172
+ * about.
173
+ */
174
+ export function listRegistryImageGenModels(
175
+ registry: ChatModelRegistry,
176
+ ): ImageGenModel[] {
177
+ let models: unknown[];
178
+ try {
179
+ models = registry.getAvailable?.() ?? registry.getAll();
180
+ } catch {
181
+ return [];
182
+ }
183
+
184
+ const out: ImageGenModel[] = [];
185
+ for (const model of models) {
186
+ if (model === null || typeof model !== "object") continue;
187
+ const candidate = model as Partial<ImageGenModel>;
188
+ if (typeof candidate.id !== "string" || typeof candidate.provider !== "string") {
189
+ continue;
190
+ }
191
+ if (!looksLikeImageGenerator(candidate as ImageGenModel)) continue;
192
+ out.push({
193
+ id: candidate.id,
194
+ provider: candidate.provider,
195
+ name: candidate.name,
196
+ api: candidate.api ?? "",
197
+ ...(candidate.output ? { output: candidate.output } : {}),
198
+ });
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * Every selectable generation model: pi-ai's built-in catalog plus anything
205
+ * contributed by registered providers, de-duplicated by "provider/id".
206
+ */
207
+ export async function listAllImageGenModels(
208
+ registry?: ChatModelRegistry | null,
209
+ ): Promise<ImageGenModel[]> {
210
+ const builtin = await listImageGenModels();
211
+ const fromRegistry = registry ? listRegistryImageGenModels(registry) : [];
212
+
213
+ const seen = new Set(builtin.map((m) => formatModelRef(m).toLowerCase()));
214
+ const merged = [...builtin];
215
+ for (const model of fromRegistry) {
216
+ const key = formatModelRef(model).toLowerCase();
217
+ if (seen.has(key)) continue;
218
+ seen.add(key);
219
+ merged.push(model);
220
+ }
221
+ return merged;
222
+ }
223
+
109
224
  /** Inject a stub images collection. Test-only. */
110
225
  export function __setImagesModelsForTests(models: ImagesModelsLike | null): void {
111
226
  cachedImagesModels = models;
@@ -129,12 +244,18 @@ export function resolveImageGenModel(
129
244
  input: string,
130
245
  models: ImageGenModel[],
131
246
  ): ImageGenModel | string {
132
- const query = input.trim().toLowerCase();
247
+ const raw = input.trim();
248
+ const query = raw.toLowerCase();
133
249
  if (!query) return "No image model specified.";
134
250
  if (models.length === 0) {
251
+ // A fully-qualified reference still works: detection is heuristic, so the
252
+ // user must be able to name a model we failed to discover.
253
+ const explicit = asExplicitModelRef(raw);
254
+ if (explicit) return explicit;
135
255
  return (
136
256
  "No image generation models are available.\n" +
137
- "→ Image generation requires an OpenRouter account: https://openrouter.ai/keys"
257
+ "→ Image generation requires an OpenRouter account: https://openrouter.ai/keys\n" +
258
+ "→ Or set an exact model with /unipi:image-settings (press c to enter one manually)."
138
259
  );
139
260
  }
140
261
 
@@ -170,6 +291,11 @@ export function resolveImageGenModel(
170
291
 
171
292
  if (best && bestScore > 0) return best;
172
293
 
294
+ // Nothing matched, but an explicit "provider/model-id" is taken at face
295
+ // value — the catalog is not authoritative for third-party providers.
296
+ const explicit = asExplicitModelRef(raw);
297
+ if (explicit) return explicit;
298
+
173
299
  const sample = models.slice(0, 10).map((m) => ` ${formatModelRef(m)}`).join("\n");
174
300
  return (
175
301
  `Unknown image model "${input}".\n` +
@@ -178,6 +304,22 @@ export function resolveImageGenModel(
178
304
  );
179
305
  }
180
306
 
307
+ /**
308
+ * Treat a well-formed "provider/model-id" as a usable model even when it is
309
+ * absent from the catalog.
310
+ *
311
+ * Generator detection is heuristic and third-party providers publish no image
312
+ * metadata, so refusing an unknown-but-well-formed reference would make some
313
+ * models permanently unreachable. Requiring the provider segment keeps this
314
+ * from swallowing plain typos, which still get the "Unknown image model" list.
315
+ */
316
+ function asExplicitModelRef(raw: string): ImageGenModel | null {
317
+ const parts = splitModelRef(raw);
318
+ if (!parts) return null;
319
+ if (/\s/.test(raw)) return null;
320
+ return { id: parts.id, provider: parts.provider, api: "" };
321
+ }
322
+
181
323
  /**
182
324
  * List vision-capable chat models — those accepting image input.
183
325
  *
@@ -216,8 +358,13 @@ export function resolveVisionModel(
216
358
  registry: ChatModelRegistry,
217
359
  ): VisionModel | string {
218
360
  const vision = listVisionModels(registry);
361
+ const raw = input.trim();
219
362
 
220
363
  if (vision.length === 0) {
364
+ // Accept an explicit reference so a provider we cannot introspect is still
365
+ // usable (mirrors resolveImageGenModel).
366
+ const explicit = asExplicitVisionRef(raw);
367
+ if (explicit) return explicit;
221
368
  return (
222
369
  "No vision-capable models are configured.\n" +
223
370
  "→ image_recognize needs a model that accepts image input " +
@@ -226,7 +373,7 @@ export function resolveVisionModel(
226
373
  );
227
374
  }
228
375
 
229
- const query = input.trim().toLowerCase();
376
+ const query = raw.toLowerCase();
230
377
  if (!query) return "No model specified.";
231
378
 
232
379
  const exact = vision.find((m) => formatModelRef(m).toLowerCase() === query);
@@ -283,8 +430,22 @@ export function resolveVisionModel(
283
430
  .join(", ")}`;
284
431
  }
285
432
 
433
+ // Unknown to the registry, but well-formed — accept it. Checked after
434
+ // `knownButBlind` so a registered text-only model still gets the precise
435
+ // "does not accept image input" error rather than being waved through.
436
+ const explicit = asExplicitVisionRef(raw);
437
+ if (explicit) return explicit;
438
+
286
439
  return (
287
440
  `Unknown model "${input}".\n` +
288
441
  `Vision-capable models: ${vision.map(formatModelRef).join(", ")}`
289
442
  );
290
443
  }
444
+
445
+ /** Accept a well-formed "provider/model-id" the registry does not know. */
446
+ function asExplicitVisionRef(raw: string): VisionModel | null {
447
+ const parts = splitModelRef(raw);
448
+ if (!parts) return null;
449
+ if (/\s/.test(raw)) return null;
450
+ return { id: parts.id, provider: parts.provider, input: ["text", "image"] };
451
+ }
package/src/tools.ts CHANGED
@@ -14,7 +14,7 @@ import { generateImage } from "./generate.js";
14
14
  import { loadImage } from "./image-source.js";
15
15
  import {
16
16
  formatModelRef,
17
- listImageGenModels,
17
+ listAllImageGenModels,
18
18
  resolveImageGenModel,
19
19
  resolveVisionModel,
20
20
  splitModelRef,
@@ -108,7 +108,10 @@ function registerGenerateTool(pi: ExtensionAPI): void {
108
108
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
109
109
  try {
110
110
  const config = loadConfig();
111
- const models = await listImageGenModels();
111
+ const registry = getRegistry(ctx);
112
+ // Include image models contributed by registered providers, so the
113
+ // tool can resolve anything the settings picker offers.
114
+ const models = await listAllImageGenModels(registry);
112
115
 
113
116
  const requested = params.model?.trim() || config.generate.model;
114
117
  const resolved = resolveImageGenModel(requested, models);
@@ -116,7 +119,6 @@ function registerGenerateTool(pi: ExtensionAPI): void {
116
119
 
117
120
  // pi-ai resolves image auth from its own credential store; only fall
118
121
  // back to pi's chat-provider key when that comes up empty.
119
- const registry = getRegistry(ctx);
120
122
  const fallbackKey = await resolveApiKey(registry, resolved.provider);
121
123
 
122
124
  const result = await generateImage({
@@ -28,6 +28,9 @@ export class ImageModelSelectorOverlay implements Component {
28
28
  private saved = false;
29
29
  private error: string | null = null;
30
30
  private theme: Theme | null = null;
31
+ /** Free-text entry, for models the catalog does not know about. */
32
+ private customMode = false;
33
+ private custom = "";
31
34
 
32
35
  onClose?: () => void;
33
36
  onSelect?: (modelRef: string) => void;
@@ -56,6 +59,18 @@ export class ImageModelSelectorOverlay implements Component {
56
59
  invalidate(): void {}
57
60
 
58
61
  handleInput(data: string): void {
62
+ // Ctrl+C must always escape, even mid-filter. Without this the overlay traps
63
+ // the user with no way out.
64
+ if (data === "\x03") {
65
+ this.onClose?.();
66
+ return;
67
+ }
68
+
69
+ if (this.customMode) {
70
+ this.handleCustomInput(data);
71
+ return;
72
+ }
73
+
59
74
  if (this.filterMode) {
60
75
  this.handleFilterInput(data);
61
76
  return;
@@ -74,6 +89,14 @@ export class ImageModelSelectorOverlay implements Component {
74
89
  this.filterMode = true;
75
90
  this.filter = "";
76
91
  break;
92
+ case "c":
93
+ case "C":
94
+ // Escape hatch: generator detection is heuristic, so a provider may
95
+ // expose a model the catalog cannot recognise. Let the user name it.
96
+ this.customMode = true;
97
+ this.custom = "";
98
+ this.error = null;
99
+ break;
77
100
  case "\r":
78
101
  this.commit();
79
102
  break;
@@ -108,6 +131,50 @@ export class ImageModelSelectorOverlay implements Component {
108
131
  }
109
132
  }
110
133
 
134
+ /** Free-text "provider/model-id" entry. */
135
+ private handleCustomInput(data: string): void {
136
+ if (data === "\r") {
137
+ this.commitCustom();
138
+ return;
139
+ }
140
+ if (data === "\x1b") {
141
+ this.customMode = false;
142
+ this.custom = "";
143
+ this.error = null;
144
+ return;
145
+ }
146
+ if (data === "\x7f" || data === "\b") {
147
+ this.custom = this.custom.slice(0, -1);
148
+ this.error = null;
149
+ return;
150
+ }
151
+ if (data.length === 1 && data >= " ") {
152
+ this.custom += data;
153
+ this.error = null;
154
+ }
155
+ }
156
+
157
+ private commitCustom(): void {
158
+ const ref = this.custom.trim();
159
+ if (!ref) {
160
+ this.error = "Enter a model as provider/model-id";
161
+ return;
162
+ }
163
+ // Validate here rather than letting a malformed ref fail later with an
164
+ // opaque provider error.
165
+ const slash = ref.indexOf("/");
166
+ if (slash <= 0 || slash === ref.length - 1) {
167
+ this.error = `"${ref}" must be in the form provider/model-id`;
168
+ return;
169
+ }
170
+
171
+ this.customMode = false;
172
+ this.onSelect?.(ref);
173
+ this.saved = true;
174
+ this.error = null;
175
+ this.onClose?.();
176
+ }
177
+
111
178
  private clampSelection(): void {
112
179
  this.selectedIndex = Math.min(
113
180
  this.selectedIndex,
@@ -137,7 +204,9 @@ export class ImageModelSelectorOverlay implements Component {
137
204
  this.onSelect?.(`${model.provider}/${model.id}`);
138
205
  this.saved = true;
139
206
  this.error = null;
140
- setTimeout(() => this.onClose?.(), 500);
207
+ // Close immediately. A deferred close leaves the overlay focused while the
208
+ // caller resumes, which is what let input reach two components at once.
209
+ this.onClose?.();
141
210
  }
142
211
 
143
212
  // ─── Theme helpers ───────────────────────────────────────────────────
@@ -194,7 +263,20 @@ export class ImageModelSelectorOverlay implements Component {
194
263
  lines.push(this.ruleLine(innerWidth));
195
264
 
196
265
  // Filter bar
197
- if (this.filterMode) {
266
+ if (this.customMode) {
267
+ lines.push(
268
+ this.frameLine(
269
+ ` ${this.fg("accent", "Model:")} ${this.custom}${this.fg("accent", "█")}`,
270
+ innerWidth,
271
+ ),
272
+ );
273
+ lines.push(
274
+ this.frameLine(
275
+ ` ${this.fg("dim", "e.g. omniroute/fal/fal-ai/flux-2-pro")}`,
276
+ innerWidth,
277
+ ),
278
+ );
279
+ } else if (this.filterMode) {
198
280
  lines.push(
199
281
  this.frameLine(
200
282
  ` ${this.fg("accent", "Filter:")} ${this.filter}${this.fg("accent", "█")}`,
@@ -211,7 +293,7 @@ export class ImageModelSelectorOverlay implements Component {
211
293
  } else {
212
294
  lines.push(
213
295
  this.frameLine(
214
- ` ${this.fg("dim", `${this.models.length} models · press / to filter`)}`,
296
+ ` ${this.fg("dim", `${this.models.length} models · / filter · c custom`)}`,
215
297
  innerWidth,
216
298
  ),
217
299
  );
@@ -224,7 +306,9 @@ export class ImageModelSelectorOverlay implements Component {
224
306
  const start = Math.max(0, this.selectedIndex - Math.floor(maxVisible / 2));
225
307
  const end = Math.min(this.filtered.length, start + maxVisible);
226
308
 
227
- if (this.filtered.length === 0) {
309
+ if (this.customMode) {
310
+ // The list is noise while typing a model reference.
311
+ } else if (this.filtered.length === 0) {
228
312
  const empty =
229
313
  this.models.length === 0
230
314
  ? this.kind === "generate"
@@ -246,7 +330,7 @@ export class ImageModelSelectorOverlay implements Component {
246
330
  }
247
331
  }
248
332
 
249
- if (this.filtered.length > maxVisible) {
333
+ if (!this.customMode && this.filtered.length > maxVisible) {
250
334
  const pct = Math.round(((this.selectedIndex + 1) / this.filtered.length) * 100);
251
335
  lines.push(
252
336
  this.frameLine(
@@ -268,7 +352,12 @@ export class ImageModelSelectorOverlay implements Component {
268
352
  lines.push(this.ruleLine(innerWidth));
269
353
  lines.push(
270
354
  this.frameLine(
271
- this.fg("dim", "↑↓ navigate · / filter · Enter select · Esc cancel"),
355
+ this.fg(
356
+ "dim",
357
+ this.customMode
358
+ ? "Enter save · Esc back to list"
359
+ : "↑↓ navigate · / filter · c custom · Enter select · Esc cancel",
360
+ ),
272
361
  innerWidth,
273
362
  ),
274
363
  );
@@ -17,7 +17,7 @@ import {
17
17
  } from "../settings.js";
18
18
  import {
19
19
  formatModelRef,
20
- listImageGenModels,
20
+ listAllImageGenModels,
21
21
  listVisionModels,
22
22
  type ChatModelRegistry,
23
23
  } from "../models.js";
@@ -165,36 +165,41 @@ async function pickModel(
165
165
  ): Promise<void> {
166
166
  const models = await collectModels(ctx, kind);
167
167
 
168
+ if (!ctx.hasUI) {
169
+ ctx.ui.notify("Model selection requires an interactive UI.", "warning");
170
+ return;
171
+ }
172
+
173
+ // An empty catalog is NOT a dead end: the overlay's custom entry (`c`) is
174
+ // precisely the escape hatch for a provider whose models we cannot detect.
168
175
  if (models.length === 0) {
169
176
  ctx.ui.notify(
170
177
  kind === "generate"
171
- ? "No image models available. Image generation is served through OpenRouter — add a key with /login."
172
- : "No vision-capable models configured. Add a model that accepts image input.",
178
+ ? "No image models detected press c to enter one manually."
179
+ : "No vision-capable models detected press c to enter one manually.",
173
180
  "warning",
174
181
  );
175
- return;
176
- }
177
-
178
- if (!ctx.hasUI) {
179
- ctx.ui.notify("Model selection requires an interactive UI.", "warning");
180
- return;
181
182
  }
182
183
 
183
184
  const current =
184
185
  kind === "generate" ? config.generate.model : config.recognize.model;
185
186
 
186
- await new Promise<void>((resolve) => {
187
- ctx.ui.custom(
187
+ // `ctx.ui.custom` resolves only when the factory calls `done()`. It MUST be
188
+ // awaited: returning early leaves the overlay on screen while the settings
189
+ // loop mounts the next `ctx.ui.select`, so two focused components fight over
190
+ // the same keystrokes and neither can be closed.
191
+ let picked: string | undefined;
192
+
193
+ try {
194
+ picked = await ctx.ui.custom<string | undefined>(
188
195
  (tui, theme, _keybindings, done) => {
189
196
  const overlay = new ImageModelSelectorOverlay(kind, models, current);
190
197
  overlay.setTheme(theme);
198
+ overlay.requestRender = () => tui.requestRender();
191
199
  overlay.onSelect = (modelRef) => {
192
- const next = loadConfig();
193
- if (kind === "generate") next.generate.model = modelRef;
194
- else next.recognize.model = modelRef;
195
- saveConfig(next);
200
+ picked = modelRef;
196
201
  };
197
- overlay.onClose = () => done(undefined);
202
+ overlay.onClose = () => done(picked);
198
203
  return {
199
204
  render: (width: number) => overlay.render(width),
200
205
  invalidate: () => overlay.invalidate(),
@@ -209,21 +214,36 @@ async function pickModel(
209
214
  overlayOptions: { width: "80%", minWidth: 50, anchor: "center", margin: 2 },
210
215
  },
211
216
  );
212
- resolve();
213
- });
217
+ } catch (err) {
218
+ ctx.ui.notify(`Model selector error: ${err}`, "error");
219
+ return;
220
+ }
221
+
222
+ // Persist only after the overlay has fully closed, so a cancel leaves the
223
+ // existing config untouched.
224
+ if (!picked) return;
225
+
226
+ const next = loadConfig();
227
+ if (kind === "generate") next.generate.model = picked;
228
+ else next.recognize.model = picked;
229
+ saveConfig(next);
230
+ ctx.ui.notify(`${kind === "generate" ? "Generation" : "Recognition"} model set to ${picked}`, "info");
214
231
  }
215
232
 
216
233
  async function collectModels(
217
234
  ctx: ExtensionCommandContext,
218
235
  kind: "generate" | "recognize",
219
236
  ): Promise<SelectableModel[]> {
237
+ const registry = (ctx as unknown as { modelRegistry?: ChatModelRegistry })
238
+ .modelRegistry;
239
+
220
240
  if (kind === "generate") {
221
- const models = await listImageGenModels();
241
+ // Include models from providers registered by other extensions, not just
242
+ // pi-ai's built-in OpenRouter catalog.
243
+ const models = await listAllImageGenModels(registry);
222
244
  return models.map((m) => ({ provider: m.provider, id: m.id, name: m.name }));
223
245
  }
224
246
 
225
- const registry = (ctx as unknown as { modelRegistry?: ChatModelRegistry })
226
- .modelRegistry;
227
247
  if (!registry) return [];
228
248
 
229
249
  return listVisionModels(registry).map((m) => ({