pi-banana 2.4.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 +17 -0
- package/extensions/index.ts +63 -0
- package/extensions/providers.ts +212 -0
- package/extensions/wizard.ts +98 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
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,
|
|
@@ -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",
|
|
@@ -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.
|
|
@@ -594,6 +638,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
594
638
|
throw new Error("At least one image path must be provided in imagePaths.");
|
|
595
639
|
}
|
|
596
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
|
+
|
|
597
660
|
for (const imgPath of params.imagePaths) {
|
|
598
661
|
const ref = await loadReferenceImage(cwd, imgPath);
|
|
599
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",
|