pi-banana 2.3.0 → 2.5.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 CHANGED
@@ -58,7 +58,7 @@ The model calls `banana_image` or `banana_vision` automatically.
58
58
  | `prompt` | string | — | Required. What to draw, or what to change about the reference image. |
59
59
  | `aspectRatio` | enum | `1:1` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` |
60
60
  | `imageSize` | enum | `1K` | `1K`, `2K`, `4K`. `4K` requires `quality=high`. |
61
- | `quality` | enum | `fast` | `fast` = Nano Banana 2 (~3–10 s, cheapest). `high` = Nano Banana Pro (slower, top quality). |
61
+ | `quality` | enum | `fast` | `lite` = Nano Banana 2 Lite (ultra-low latency, cheapest). `fast` = Nano Banana 2 (~3–10 s). `high` = Nano Banana Pro (slower, top quality). All GA model ids since 2.4.0. |
62
62
  | `referenceImages` | array | — | Optional array of paths (PNG/JPEG/WebP/GIF) to edit or use for composition instead of generating from scratch. |
63
63
  | `outputPath` | string | `./generated/<slug>-<ts>.png` | Optional output path. Parent dirs are created. |
64
64
 
@@ -72,6 +72,23 @@ The model calls `banana_image` or `banana_vision` automatically.
72
72
 
73
73
  ## Configuration
74
74
 
75
+ ### OpenAI-compatible providers (mantice and friends)
76
+
77
+ Run `/banana-setup` to point the tools at any OpenAI-compatible image provider. The wizard asks for a base URL and API key (prefilled from pi credentials when available), probes the endpoint for image generation, image editing, vision, and video support, and lets you pick a model per quality tier. Config is saved in settings.json under `"banana"`:
78
+
79
+ ```json
80
+ "banana": {
81
+ "baseUrl": "https://llm.fornace.net/v1",
82
+ "apiKey": "sk-...",
83
+ "models": { "lite": "fornace-image-lite", "fast": "fornace-image", "high": "fornace-image-max" },
84
+ "visionModel": "fornace-vision"
85
+ }
86
+ ```
87
+
88
+ Delete the `"banana"` section to return to the built-in Google path.
89
+
90
+ ### Environment
91
+
75
92
  Two env vars adjust defaults without touching tool parameters:
76
93
 
77
94
  | Env var | Default | Effect |
@@ -18,7 +18,9 @@
18
18
  * Vertex AI Express keys (AQ.…) automatically switch to Vertex.
19
19
  */
20
20
 
21
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
+ import { resolveProvider, openaiGenerateImage, openaiEditImage, openaiVision, sizeFor } from "./providers.ts";
23
+ import { runBananaSetup } from "./wizard.ts";
22
24
  import { StringEnum } from "@earendil-works/pi-ai";
23
25
  import {
24
26
  Box,
@@ -51,26 +53,26 @@ const ASPECT_RATIOS = [
51
53
  "21:9",
52
54
  ] as const;
53
55
  const IMAGE_SIZES = ["1K", "2K", "4K"] as const;
54
- const QUALITY = ["fast", "high"] as const;
55
-
56
- // Resolved against a live Vertex Express (AQ.*) key on 2026-05-08:
57
- // gemini-3.1-flash-image → 404
58
- // gemini-3.1-flash-image-preview → ok (Nano Banana 2)
59
- // gemini-3-pro-image 404
60
- // gemini-3-pro-image-preview ok (Nano Banana Pro)
61
- // AI Studio keys (AIza*) sometimes publish the un-suffixed GA names.
62
- // `callWithModelFallback` below transparently retries with `-preview`
63
- // if the un-suffixed id 404s, so both backends just work.
64
- const MODEL_FOR_QUALITY: Record<(typeof QUALITY)[number], string> = {
65
- fast: "gemini-3.1-flash-image-preview", // Nano Banana 2
66
- high: "gemini-3-pro-image-preview", // Nano Banana Pro
56
+ const IMAGE_QUALITY = ["lite", "fast", "high"] as const;
57
+ const VISION_QUALITY = ["fast", "high"] as const;
58
+ type ImageQuality = (typeof IMAGE_QUALITY)[number];
59
+
60
+ // GA ids (Gemini API changelog 2026-05-28: gemini-3.1-flash-image and
61
+ // gemini-3-pro-image went GA; 2026-06-30: gemini-3.1-flash-lite-image GA).
62
+ // Vertex Express keys historically served the `-preview` variants; the
63
+ // GA and preview ids are published at different times per backend, so
64
+ // `callWithModelFallback` retries the other variant on 404. Both work.
65
+ // Verified against the Gemini API changelog on 2026-09-01.
66
+ const MODEL_FOR_QUALITY: Record<ImageQuality, string> = {
67
+ lite: "gemini-3.1-flash-lite-image", // Nano Banana 2 Lite (ultra-low latency, cheapest)
68
+ fast: "gemini-3.1-flash-image", // Nano Banana 2
69
+ high: "gemini-3-pro-image", // Nano Banana Pro
67
70
  };
68
71
 
69
72
  const DEFAULT_OUTPUT_DIR =
70
73
  process.env.PI_IMAGE_DIR?.trim() || "generated";
71
74
  const DEFAULT_QUALITY = (process.env.PI_IMAGE_QUALITY as
72
- | "fast"
73
- | "high"
75
+ | ImageQuality
74
76
  | undefined) ?? "fast";
75
77
  const SUPPORTED_INPUT_MIME = new Set([
76
78
  "image/png",
@@ -103,7 +105,7 @@ function hasVertexADC(): boolean {
103
105
  }
104
106
 
105
107
  async function buildClient(
106
- modelRegistry: ExtensionAPI["modelRegistry"] | undefined,
108
+ modelRegistry: ExtensionContext["modelRegistry"] | undefined,
107
109
  ): Promise<GoogleGenAI> {
108
110
  // 1. Pi's model registry — the pi-native auth chain
109
111
  // (auth.json → env vars → models.json fallback)
@@ -241,6 +243,13 @@ async function resolveOutputPath(
241
243
  // ─── Extension ─────────────────────────────────────────────────────────────
242
244
 
243
245
  export default function (pi: ExtensionAPI) {
246
+ pi.registerCommand("banana-setup", {
247
+ description: "Interactive setup: point banana at any OpenAI-compatible image provider (URL + key, capability probe, model selection)",
248
+ handler: async (_args, ctx) => {
249
+ await runBananaSetup(ctx);
250
+ },
251
+ });
252
+
244
253
  pi.registerTool({
245
254
  name: "banana_image",
246
255
  label: "Banana Image",
@@ -277,9 +286,9 @@ export default function (pi: ExtensionAPI) {
277
286
  }),
278
287
  ),
279
288
  quality: Type.Optional(
280
- StringEnum(QUALITY, {
289
+ StringEnum(IMAGE_QUALITY, {
281
290
  description:
282
- "'fast' = Nano Banana 2 (default, ~3s, cheap). 'high' = Nano Banana Pro (slower, top quality).",
291
+ "'lite' = Nano Banana 2 Lite (ultra-low latency, cheapest). 'fast' = Nano Banana 2 (default, ~3s, cheap). 'high' = Nano Banana Pro (slower, top quality).",
283
292
  default: DEFAULT_QUALITY,
284
293
  }),
285
294
  ),
@@ -326,6 +335,41 @@ export default function (pi: ExtensionAPI) {
326
335
  );
327
336
  }
328
337
 
338
+ // OpenAI-compatible provider (mantice etc.) when configured via /banana-setup.
339
+ const provider = await resolveProvider(ctx);
340
+ if (provider.kind === "openai-compat") {
341
+ const { config } = provider;
342
+ const oaiModel = config.models[quality] ?? config.models.fast;
343
+ if (!oaiModel) {
344
+ throw new Error(`banana: no model mapped for quality "${quality}". Run /banana-setup.`);
345
+ }
346
+ const size = sizeFor(aspectRatio, imageSize);
347
+ const refs: { mimeType: string; data: string }[] = [];
348
+ if (params.referenceImages) {
349
+ for (const refPath of params.referenceImages) {
350
+ refs.push(await loadReferenceImage(cwd, refPath));
351
+ }
352
+ }
353
+ const editing = refs.length > 0;
354
+ onUpdate?.({
355
+ content: [{ type: "text", text: `🎨 ${editing ? "Editing" : "Generating"} ${aspectRatio} ${imageSize} image with ${oaiModel}…` }],
356
+ details: { model: oaiModel, aspectRatio, imageSize, quality, editing },
357
+ });
358
+ const buf = editing
359
+ ? await openaiEditImage(config, { model: oaiModel, prompt: params.prompt, images: refs, size, signal })
360
+ : await openaiGenerateImage(config, { model: oaiModel, prompt: params.prompt, size, signal });
361
+ const outPath = await resolveOutputPath(cwd, params.prompt, "image/png", params.outputPath);
362
+ await writeFile(outPath, buf);
363
+ return {
364
+ content: [{ type: "text", text: editing ? `Edited → ${outPath}` : `Generated → ${outPath}` }],
365
+ details: {
366
+ prompt: params.prompt, model: oaiModel, quality, aspectRatio, imageSize,
367
+ mimeType: "image/png", outputPath: outPath, editing,
368
+ referenceImages: params.referenceImages, imageBase64: buf.toString("base64"),
369
+ },
370
+ };
371
+ }
372
+
329
373
  const client = await buildClient(ctx.modelRegistry);
330
374
 
331
375
  // Build content parts — text + (optional) reference image.
@@ -348,7 +392,7 @@ export default function (pi: ExtensionAPI) {
348
392
  content: [
349
393
  {
350
394
  type: "text",
351
- text: `🎨 ${verbLabel} ${aspectRatio} ${imageSize} image with ${quality === "fast" ? "Nano Banana 2" : "Nano Banana Pro"}…`,
395
+ text: `🎨 ${verbLabel} ${aspectRatio} ${imageSize} image with ${quality === "lite" ? "Nano Banana 2 Lite" : quality === "fast" ? "Nano Banana 2" : "Nano Banana Pro"}…`,
352
396
  },
353
397
  ],
354
398
  details: { model, aspectRatio, imageSize, quality, editing },
@@ -479,7 +523,6 @@ export default function (pi: ExtensionAPI) {
479
523
  container.addChild(new Spacer(1));
480
524
  container.addChild(
481
525
  new Image((details as any).imageBase64, (details as any).mimeType ?? "image/png", {
482
- ...theme,
483
526
  fallbackColor: (s: string) => theme.fg("muted", s),
484
527
  }, {
485
528
  maxWidthCells: 80,
@@ -560,10 +603,10 @@ export default function (pi: ExtensionAPI) {
560
603
  "to analyze (e.g., ['image1.png']). Relative paths resolve to current working directory.",
561
604
  }),
562
605
  quality: Type.Optional(
563
- StringEnum(QUALITY, {
606
+ StringEnum(VISION_QUALITY, {
564
607
  description:
565
608
  "'fast' = gemini-3.1-flash-lite (default, fast/cheap). 'high' = gemini-3.1-pro-preview (slower, deep reasoning).",
566
- default: DEFAULT_QUALITY,
609
+ default: "fast",
567
610
  }),
568
611
  ),
569
612
  }),
@@ -595,6 +638,25 @@ export default function (pi: ExtensionAPI) {
595
638
  throw new Error("At least one image path must be provided in imagePaths.");
596
639
  }
597
640
 
641
+ // OpenAI-compatible provider (mantice etc.) when configured via /banana-setup.
642
+ const provider = await resolveProvider(ctx);
643
+ if (provider.kind === "openai-compat" && provider.config.visionModel) {
644
+ const { config } = provider;
645
+ const images: { mimeType: string; data: string }[] = [];
646
+ for (const imgPath of params.imagePaths) {
647
+ images.push(await loadReferenceImage(cwd, imgPath));
648
+ }
649
+ onUpdate?.({
650
+ content: [{ type: "text", text: `👁️ Analyzing image(s) with ${config.visionModel}…` }],
651
+ details: { model: config.visionModel, quality, imagePaths: params.imagePaths },
652
+ });
653
+ const textOut = await openaiVision(config, { model: config.visionModel!, prompt: params.prompt, images, signal });
654
+ return {
655
+ content: [{ type: "text", text: textOut }],
656
+ details: { prompt: params.prompt, model: config.visionModel, quality, imagePaths: params.imagePaths },
657
+ };
658
+ }
659
+
598
660
  for (const imgPath of params.imagePaths) {
599
661
  const ref = await loadReferenceImage(cwd, imgPath);
600
662
  parts.push({ inlineData: ref });
@@ -0,0 +1,212 @@
1
+ // OpenAI-compatible provider layer for pi-banana.
2
+ // Lets banana_image / banana_vision run against any OpenAI-shaped gateway
3
+ // (mantice/llm.fornace.net included) instead of the Google API. Configured
4
+ // via the "banana" section in settings.json (written by /banana-setup):
5
+ //
6
+ // "banana": {
7
+ // "baseUrl": "https://llm.fornace.net/v1",
8
+ // "apiKey": "sk-...",
9
+ // "models": { "lite": "...", "fast": "...", "high": "..." },
10
+ // "visionModel": "..."
11
+ // }
12
+ //
13
+ // When no baseUrl is configured, the built-in Google path is used.
14
+ import { readFileSync } from "fs";
15
+ import { homedir } from "os";
16
+ import { resolve } from "path";
17
+
18
+ export interface OpenAiCompatConfig {
19
+ baseUrl: string;
20
+ apiKey: string;
21
+ models: { lite?: string; fast?: string; high?: string };
22
+ visionModel?: string;
23
+ /** Optional provider id whose key lives in pi's auth.json (e.g. "mantice"). */
24
+ providerName?: string;
25
+ }
26
+
27
+ export type Provider =
28
+ | { kind: "openai-compat"; config: OpenAiCompatConfig }
29
+ | { kind: "google" };
30
+
31
+ export function loadBananaSettings(): Partial<OpenAiCompatConfig> {
32
+ try {
33
+ const raw = JSON.parse(
34
+ readFileSync(resolve(homedir(), ".pi", "agent", "settings.json"), "utf8"),
35
+ );
36
+ return raw?.banana ?? {};
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ export async function resolveProvider(ctx: any): Promise<Provider> {
43
+ const s = loadBananaSettings();
44
+ if (!s.baseUrl) return { kind: "google" };
45
+ let apiKey = s.apiKey;
46
+ if (!apiKey && s.providerName && ctx?.modelRegistry) {
47
+ try {
48
+ apiKey = await ctx.modelRegistry.getApiKeyForProvider(s.providerName);
49
+ } catch { /* fall through */ }
50
+ }
51
+ if (!apiKey) apiKey = process.env.MANTICE_API_KEY;
52
+ if (!apiKey) {
53
+ throw new Error(
54
+ "banana: baseUrl is configured but no API key was found. " +
55
+ "Run /banana-setup again, or set banana.apiKey in settings.json.",
56
+ );
57
+ }
58
+ return {
59
+ kind: "openai-compat",
60
+ config: {
61
+ baseUrl: s.baseUrl.replace(/\/+$/, ""),
62
+ apiKey,
63
+ models: s.models ?? {},
64
+ visionModel: s.visionModel,
65
+ providerName: s.providerName,
66
+ },
67
+ };
68
+ }
69
+
70
+ // ─── OpenAI-compat calls ─────────────────────────────────────────────
71
+
72
+ /** Map aspect ratio + size tier to a generic WxH string. */
73
+ export function sizeFor(aspectRatio: string, imageSize: string): string {
74
+ const base = imageSize === "4K" ? 4096 : imageSize === "2K" ? 2048 : 1024;
75
+ const ratio = (() => {
76
+ const [w, h] = aspectRatio.split(":").map(Number);
77
+ return w && h ? w / h : 1;
78
+ })();
79
+ if (Math.abs(ratio - 1) < 0.01) return `${base}x${base}`;
80
+ if (ratio > 1) return `${base}x${Math.round(base / ratio)}`;
81
+ return `${Math.round(base * ratio)}x${base}`;
82
+ }
83
+
84
+ async function asBuffer(data: any): Promise<Buffer> {
85
+ const first = data?.data?.[0] ?? {};
86
+ if (first.b64_json) return Buffer.from(first.b64_json, "base64");
87
+ if (first.url) {
88
+ const res = await fetch(first.url);
89
+ if (!res.ok) throw new Error(`Image download failed: ${res.statusText}`);
90
+ return Buffer.from(await res.arrayBuffer());
91
+ }
92
+ throw new Error(`No image payload in response: ${JSON.stringify(data).slice(0, 300)}`);
93
+ }
94
+
95
+ async function apiPost(config: OpenAiCompatConfig, path: string, body: unknown, signal?: AbortSignal): Promise<any> {
96
+ const res = await fetch(`${config.baseUrl}${path}`, {
97
+ method: "POST",
98
+ headers: { Authorization: `Bearer ${config.apiKey}`, "Content-Type": "application/json" },
99
+ body: JSON.stringify(body),
100
+ signal,
101
+ });
102
+ const text = await res.text();
103
+ if (!res.ok) throw new Error(`API error (${res.status}) on ${path}: ${text.slice(0, 400)}`);
104
+ try { return JSON.parse(text); } catch { return text; }
105
+ }
106
+
107
+ export async function openaiGenerateImage(
108
+ config: OpenAiCompatConfig,
109
+ opts: { model: string; prompt: string; size: string; signal?: AbortSignal },
110
+ ): Promise<Buffer> {
111
+ return asBuffer(await apiPost(config, "/images/generations", {
112
+ model: opts.model, prompt: opts.prompt, n: 1, size: opts.size,
113
+ }, opts.signal));
114
+ }
115
+
116
+ export async function openaiEditImage(
117
+ config: OpenAiCompatConfig,
118
+ opts: { model: string; prompt: string; images: { mimeType: string; data: string }[]; size: string; signal?: AbortSignal },
119
+ ): Promise<Buffer> {
120
+ const form = new FormData();
121
+ form.append("model", opts.model);
122
+ form.append("prompt", opts.prompt);
123
+ form.append("size", opts.size);
124
+ for (const img of opts.images) {
125
+ const bytes = Buffer.from(img.data, "base64");
126
+ const ext = img.mimeType.split("/")[1] ?? "png";
127
+ form.append("image", new Blob([bytes], { type: img.mimeType }), `ref.${ext}`);
128
+ }
129
+ const res = await fetch(`${config.baseUrl}/images/edits`, {
130
+ method: "POST",
131
+ headers: { Authorization: `Bearer ${config.apiKey}` },
132
+ body: form,
133
+ signal: opts.signal,
134
+ });
135
+ const text = await res.text();
136
+ if (!res.ok) throw new Error(`API error (${res.status}) on /images/edits: ${text.slice(0, 400)}`);
137
+ return asBuffer(JSON.parse(text));
138
+ }
139
+
140
+ export async function openaiVision(
141
+ config: OpenAiCompatConfig,
142
+ opts: { model: string; prompt: string; images: { mimeType: string; data: string }[]; signal?: AbortSignal },
143
+ ): Promise<string> {
144
+ const content: any[] = [{ type: "text", text: opts.prompt }];
145
+ for (const img of opts.images) {
146
+ content.push({ type: "image_url", image_url: { url: `data:${img.mimeType};base64,${img.data}` } });
147
+ }
148
+ const data = await apiPost(config, "/chat/completions", {
149
+ model: opts.model,
150
+ messages: [{ role: "user", content }],
151
+ }, opts.signal);
152
+ const text = data?.choices?.[0]?.message?.content;
153
+ if (!text) throw new Error(`Empty vision response: ${JSON.stringify(data).slice(0, 300)}`);
154
+ return typeof text === "string" ? text : text.map((p: any) => p.text ?? "").join("");
155
+ }
156
+
157
+ // ─── Capability probing (used by /banana-setup) ──────────────────────
158
+
159
+ export interface ProbeResult {
160
+ models: string[];
161
+ imageModels: string[];
162
+ visionModels: string[];
163
+ videoModels: string[];
164
+ supportsImageGen: boolean;
165
+ supportsImageEdit: boolean;
166
+ supportsVideo: boolean;
167
+ }
168
+
169
+ const IMAGE_RE = /image|banana|seedream|imagen|gpt-image|dall-?e|flux|kolors/i;
170
+ const VISION_RE = /vision|omni|-vl\b|vl-|multimodal/i;
171
+ const VIDEO_RE = /video|sora|veo|wan|seedance|kling|hailuo|minimax-h/i;
172
+
173
+ export function classifyModels(ids: string[]): Pick<ProbeResult, "imageModels" | "visionModels" | "videoModels"> {
174
+ return {
175
+ imageModels: ids.filter((m) => IMAGE_RE.test(m)),
176
+ visionModels: ids.filter((m) => VISION_RE.test(m) && !IMAGE_RE.test(m)),
177
+ videoModels: ids.filter((m) => VIDEO_RE.test(m)),
178
+ };
179
+ }
180
+
181
+ async function endpointReachable(baseUrl: string, apiKey: string, path: string): Promise<boolean> {
182
+ // A missing capability answers 404; an existing endpoint answers anything else
183
+ // (400 for a junk payload, 401 without a key).
184
+ try {
185
+ const res = await fetch(`${baseUrl}${path}`, {
186
+ method: "POST",
187
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
188
+ body: JSON.stringify({ model: "__banana_probe__", prompt: "probe" }),
189
+ });
190
+ return res.status !== 404;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ export async function probeCapabilities(baseUrl: string, apiKey: string): Promise<ProbeResult> {
197
+ const base = baseUrl.replace(/\/+$/, "");
198
+ let models: string[] = [];
199
+ const res = await fetch(`${base}/models`, { headers: { Authorization: `Bearer ${apiKey}` } });
200
+ if (!res.ok) {
201
+ throw new Error(`GET ${base}/models failed (${res.status}): ${(await res.text()).slice(0, 300)}`);
202
+ }
203
+ const data: any = await res.json();
204
+ models = (data?.data ?? []).map((m: any) => m.id).filter(Boolean);
205
+ const { imageModels, visionModels, videoModels } = classifyModels(models);
206
+ const [supportsImageGen, supportsImageEdit, supportsVideo] = await Promise.all([
207
+ endpointReachable(base, apiKey, "/images/generations"),
208
+ endpointReachable(base, apiKey, "/images/edits"),
209
+ endpointReachable(base, apiKey, "/videos/generations"),
210
+ ]);
211
+ return { models, imageModels, visionModels, videoModels, supportsImageGen, supportsImageEdit, supportsVideo };
212
+ }
@@ -0,0 +1,98 @@
1
+ // /banana-setup: interactive provider wizard.
2
+ // Asks for a base URL + API key, probes the endpoint for image and vision
3
+ // capabilities, lets the user confirm model assignments per quality tier,
4
+ // and persists the result to settings.json under the "banana" key.
5
+ import { writeFileSync, readFileSync } from "fs";
6
+ import { homedir } from "os";
7
+ import { resolve } from "path";
8
+ import { probeCapabilities, classifyModels } from "./providers.ts";
9
+
10
+ const SETTINGS_PATH = resolve(homedir(), ".pi", "agent", "settings.json");
11
+
12
+ function persistBananaConfig(cfg: Record<string, unknown>): void {
13
+ let raw: Record<string, unknown> = {};
14
+ try { raw = JSON.parse(readFileSync(SETTINGS_PATH, "utf8")); } catch { /* new file */ }
15
+ raw.banana = cfg;
16
+ writeFileSync(SETTINGS_PATH, JSON.stringify(raw, null, 2));
17
+ }
18
+
19
+ function pickDefault(candidates: string[], hint: RegExp): string | undefined {
20
+ return candidates.find((m) => hint.test(m)) ?? candidates[0];
21
+ }
22
+
23
+ export async function runBananaSetup(ctx: any): Promise<void> {
24
+ const ui = ctx.ui;
25
+ ui.notify("Banana setup: configure an OpenAI-compatible image provider (mantice works).", "info");
26
+
27
+ // Prefill base URL and key from the pi credential store when available.
28
+ let preKey: string | undefined;
29
+ for (const p of ["mantice", "google"]) {
30
+ try {
31
+ preKey = await ctx.modelRegistry?.getApiKeyForProvider(p);
32
+ if (preKey) break;
33
+ } catch { /* next */ }
34
+ }
35
+ const baseUrl = (await ui.input("Provider base URL (OpenAI-compatible)", "https://llm.fornace.net/v1"))?.trim();
36
+ if (!baseUrl) return;
37
+ const apiKey = (await ui.input("API key", preKey ?? "sk-..."))?.trim();
38
+ if (!apiKey) return;
39
+
40
+ ui.notify("Probing capabilities…", "info");
41
+ let probe;
42
+ try {
43
+ probe = await probeCapabilities(baseUrl, apiKey);
44
+ } catch (err: any) {
45
+ ui.notify(`Probe failed: ${err.message}`, "error");
46
+ return;
47
+ }
48
+
49
+ if (probe.models.length === 0) {
50
+ ui.notify("No models listed on this endpoint.", "error");
51
+ return;
52
+ }
53
+
54
+ const report = [
55
+ `Models: ${probe.models.length} total`,
56
+ `Image models: ${probe.imageModels.join(", ") || "none detected"}`,
57
+ `Vision models: ${probe.visionModels.join(", ") || "none detected"}`,
58
+ `Video models: ${probe.videoModels.join(", ") || "none detected"}`,
59
+ `Endpoints: image gen ${probe.supportsImageGen ? "✓" : "✗"}, image edit ${probe.supportsImageEdit ? "✓" : "✗"}, video ${probe.supportsVideo ? "✓" : "✗"}`,
60
+ ].join("\n");
61
+ ui.notify(report, "info");
62
+
63
+ if (!probe.supportsImageGen || probe.imageModels.length === 0) {
64
+ ui.notify("This endpoint has no image generation models. Setup aborted, Google stays the default.", "error");
65
+ return;
66
+ }
67
+
68
+ // Auto-assign tiers, then let the user confirm each.
69
+ const tiers = [
70
+ { key: "lite", hint: /lite|fast|turbo|mini/i, label: "lite tier (cheapest)" },
71
+ { key: "fast", hint: /^(?!.*(lite|high|max|pro)).*$/i, label: "fast tier (default)" },
72
+ { key: "high", hint: /max|high|pro|ultra/i, label: "high tier (top quality)" },
73
+ ] as const;
74
+ const models: Record<string, string> = {};
75
+ for (const t of tiers) {
76
+ const suggestion = pickDefault(probe.imageModels, t.hint) ?? probe.imageModels[0];
77
+ const options = [suggestion, ...probe.imageModels.filter((m) => m !== suggestion)];
78
+ const choice = await ui.select(`Model for ${t.label}`, options);
79
+ if (!choice) return;
80
+ models[t.key] = choice;
81
+ }
82
+
83
+ let visionModel: string | undefined;
84
+ if (probe.visionModels.length > 0) {
85
+ const v = await ui.select("Model for banana_vision (image analysis)", [
86
+ ...probe.visionModels,
87
+ "(keep Google default)",
88
+ ]);
89
+ visionModel = v && !v.startsWith("(") ? v : undefined;
90
+ }
91
+
92
+ persistBananaConfig({ baseUrl, apiKey, models, ...(visionModel ? { visionModel } : {}) });
93
+ ui.notify(
94
+ `Saved. banana_image now calls ${baseUrl} (${models.fast}/${models.lite}/${models.high}). ` +
95
+ "Run /banana-setup again to change, or delete \"banana\" from settings.json to return to Google.",
96
+ "info",
97
+ );
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-banana",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Generate, edit, and analyze images in pi using Google Nano Banana (image gen) and Gemini Vision (analysis). Inline terminal preview, reference-image editing, auto-save.",
5
5
  "author": "Francesco Frapporti <effedue@gmail.com>",
6
6
  "license": "MIT",