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 +18 -1
- package/extensions/index.ts +85 -23
- package/extensions/providers.ts +212 -0
- package/extensions/wizard.ts +98 -0
- package/package.json +1 -1
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
|
|
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 |
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// `callWithModelFallback`
|
|
63
|
-
//
|
|
64
|
-
const MODEL_FOR_QUALITY: Record<
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
|
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:
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
+
"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",
|