@pi-unipi/image 2.2.1 → 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.1",
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
@@ -244,12 +244,18 @@ export function resolveImageGenModel(
244
244
  input: string,
245
245
  models: ImageGenModel[],
246
246
  ): ImageGenModel | string {
247
- const query = input.trim().toLowerCase();
247
+ const raw = input.trim();
248
+ const query = raw.toLowerCase();
248
249
  if (!query) return "No image model specified.";
249
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;
250
255
  return (
251
256
  "No image generation models are available.\n" +
252
- "→ 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)."
253
259
  );
254
260
  }
255
261
 
@@ -285,6 +291,11 @@ export function resolveImageGenModel(
285
291
 
286
292
  if (best && bestScore > 0) return best;
287
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
+
288
299
  const sample = models.slice(0, 10).map((m) => ` ${formatModelRef(m)}`).join("\n");
289
300
  return (
290
301
  `Unknown image model "${input}".\n` +
@@ -293,6 +304,22 @@ export function resolveImageGenModel(
293
304
  );
294
305
  }
295
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
+
296
323
  /**
297
324
  * List vision-capable chat models — those accepting image input.
298
325
  *
@@ -331,8 +358,13 @@ export function resolveVisionModel(
331
358
  registry: ChatModelRegistry,
332
359
  ): VisionModel | string {
333
360
  const vision = listVisionModels(registry);
361
+ const raw = input.trim();
334
362
 
335
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;
336
368
  return (
337
369
  "No vision-capable models are configured.\n" +
338
370
  "→ image_recognize needs a model that accepts image input " +
@@ -341,7 +373,7 @@ export function resolveVisionModel(
341
373
  );
342
374
  }
343
375
 
344
- const query = input.trim().toLowerCase();
376
+ const query = raw.toLowerCase();
345
377
  if (!query) return "No model specified.";
346
378
 
347
379
  const exact = vision.find((m) => formatModelRef(m).toLowerCase() === query);
@@ -398,8 +430,22 @@ export function resolveVisionModel(
398
430
  .join(", ")}`;
399
431
  }
400
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
+
401
439
  return (
402
440
  `Unknown model "${input}".\n` +
403
441
  `Vision-capable models: ${vision.map(formatModelRef).join(", ")}`
404
442
  );
405
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
+ }
@@ -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;
@@ -63,6 +66,11 @@ export class ImageModelSelectorOverlay implements Component {
63
66
  return;
64
67
  }
65
68
 
69
+ if (this.customMode) {
70
+ this.handleCustomInput(data);
71
+ return;
72
+ }
73
+
66
74
  if (this.filterMode) {
67
75
  this.handleFilterInput(data);
68
76
  return;
@@ -81,6 +89,14 @@ export class ImageModelSelectorOverlay implements Component {
81
89
  this.filterMode = true;
82
90
  this.filter = "";
83
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;
84
100
  case "\r":
85
101
  this.commit();
86
102
  break;
@@ -115,6 +131,50 @@ export class ImageModelSelectorOverlay implements Component {
115
131
  }
116
132
  }
117
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
+
118
178
  private clampSelection(): void {
119
179
  this.selectedIndex = Math.min(
120
180
  this.selectedIndex,
@@ -203,7 +263,20 @@ export class ImageModelSelectorOverlay implements Component {
203
263
  lines.push(this.ruleLine(innerWidth));
204
264
 
205
265
  // Filter bar
206
- 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) {
207
280
  lines.push(
208
281
  this.frameLine(
209
282
  ` ${this.fg("accent", "Filter:")} ${this.filter}${this.fg("accent", "█")}`,
@@ -220,7 +293,7 @@ export class ImageModelSelectorOverlay implements Component {
220
293
  } else {
221
294
  lines.push(
222
295
  this.frameLine(
223
- ` ${this.fg("dim", `${this.models.length} models · press / to filter`)}`,
296
+ ` ${this.fg("dim", `${this.models.length} models · / filter · c custom`)}`,
224
297
  innerWidth,
225
298
  ),
226
299
  );
@@ -233,7 +306,9 @@ export class ImageModelSelectorOverlay implements Component {
233
306
  const start = Math.max(0, this.selectedIndex - Math.floor(maxVisible / 2));
234
307
  const end = Math.min(this.filtered.length, start + maxVisible);
235
308
 
236
- 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) {
237
312
  const empty =
238
313
  this.models.length === 0
239
314
  ? this.kind === "generate"
@@ -255,7 +330,7 @@ export class ImageModelSelectorOverlay implements Component {
255
330
  }
256
331
  }
257
332
 
258
- if (this.filtered.length > maxVisible) {
333
+ if (!this.customMode && this.filtered.length > maxVisible) {
259
334
  const pct = Math.round(((this.selectedIndex + 1) / this.filtered.length) * 100);
260
335
  lines.push(
261
336
  this.frameLine(
@@ -277,7 +352,12 @@ export class ImageModelSelectorOverlay implements Component {
277
352
  lines.push(this.ruleLine(innerWidth));
278
353
  lines.push(
279
354
  this.frameLine(
280
- 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
+ ),
281
361
  innerWidth,
282
362
  ),
283
363
  );
@@ -165,19 +165,20 @@ 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. Add an OpenRouter key with /login, or register a provider that exposes image models."
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 =