opencode-pollinations-plugin 6.0.0-beta.3 β†’ 6.0.0-beta.6

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/dist/index.js CHANGED
@@ -3,9 +3,9 @@ import * as fs from 'fs';
3
3
  import { generatePollinationsConfig } from './server/generate-config.js';
4
4
  import { loadConfig } from './server/config.js';
5
5
  import { handleChatCompletion } from './server/proxy.js';
6
- import { createToastHooks, setGlobalClient } from './server/toast.js';
6
+ import { createToastHooks, createToolHooks, setGlobalClient } from './server/toast.js';
7
7
  import { createStatusHooks } from './server/status.js';
8
- import { createCommandHooks } from './server/commands.js';
8
+ import { createCommandHooks, setClientForCommands } from './server/commands.js';
9
9
  import { createToolRegistry } from './tools/index.js';
10
10
  import { createRequire } from 'module';
11
11
  const require = createRequire(import.meta.url);
@@ -83,6 +83,7 @@ export const PollinationsPlugin = async (ctx) => {
83
83
  const port = await startProxy();
84
84
  const localBaseUrl = `http://127.0.0.1:${port}/v1`;
85
85
  setGlobalClient(ctx.client);
86
+ setClientForCommands(ctx.client);
86
87
  const toastHooks = createToastHooks(ctx.client);
87
88
  const commandHooks = createCommandHooks();
88
89
  // Build tool registry (conditional on API key presence)
@@ -112,6 +113,7 @@ export const PollinationsPlugin = async (ctx) => {
112
113
  log(`[Hook] Registered ${Object.keys(modelsObj).length} models.`);
113
114
  },
114
115
  ...toastHooks,
116
+ ...createToolHooks(ctx.client),
115
117
  ...createStatusHooks(ctx.client),
116
118
  ...commandHooks
117
119
  };
@@ -9,8 +9,10 @@ interface CommandResult {
9
9
  response?: string;
10
10
  error?: string;
11
11
  }
12
+ export declare function setClientForCommands(client: any): void;
12
13
  export declare function handleCommand(command: string): Promise<CommandResult>;
13
14
  export declare function createCommandHooks(): {
14
15
  'tui.command.execute': (input: any, output: any) => Promise<void>;
16
+ 'command.execute.before': (input: any, output: any) => Promise<void>;
15
17
  };
16
18
  export {};
@@ -130,6 +130,10 @@ function calculateCurrentPeriodStats(usage, lastReset, tierLimit) {
130
130
  };
131
131
  }
132
132
  // === COMMAND HANDLER ===
133
+ let globalClient = null;
134
+ export function setClientForCommands(client) {
135
+ globalClient = client;
136
+ }
133
137
  export async function handleCommand(command) {
134
138
  const parts = command.trim().split(/\s+/);
135
139
  if (!parts[0].startsWith('/poll')) {
@@ -150,6 +154,22 @@ export async function handleCommand(command) {
150
154
  return handleConfigCommand(args);
151
155
  case 'help':
152
156
  return handleHelpCommand();
157
+ case 'addKey': // Internal command for UI
158
+ if (globalClient) {
159
+ globalClient.tui.appendPrompt({ value: "/pollinations rmbg_key_add " }); // Using the old command? No, use tool?
160
+ // Wait, tools are not commands.
161
+ // But we removed rmbg_key_add command!
162
+ // We should instruct user to use the tool?
163
+ // Or user types: rmbg_keys { action: "add", key: "..." }
164
+ // That's painful to type.
165
+ // Can we alias a command to a tool call?
166
+ // /pollinations rmbg_key_add is GONE.
167
+ // Let's re-introduce a helper command just for this UI flow?
168
+ // Or better: appendPrompt({ value: 'rmbg_keys { action: "add", key: "' });
169
+ globalClient.tui.appendPrompt({ value: 'rmbg_keys { action: "add", key: "' });
170
+ return { handled: true };
171
+ }
172
+ return { handled: true, error: "TUI not available" };
153
173
  default:
154
174
  return {
155
175
  handled: true,
@@ -442,17 +462,21 @@ function handleConfigCommand(args) {
442
462
  }
443
463
  function handleHelpCommand() {
444
464
  const help = `
445
- ### 🌸 Pollinations Plugin - Commandes V5
465
+ ### 🌸 Pollinations Plugin - Commandes V6
446
466
 
467
+ **Mode & Usage**
447
468
  - **\`/pollinations mode [mode]\`**: Change le mode (manual, alwaysfree, pro).
448
469
  - **\`/pollinations usage [full]\`**: Affiche le dashboard (full = dΓ©tail).
449
- - **\`/pollinations fallback <main> [agent]\`**: Configure le Safety Net (Free).
470
+ - **\`/pollinations fallback <main> [agent]\`**: Configure le Safety Net.
471
+
472
+ **Configuration**
450
473
  - **\`/pollinations config [key] [value]\`**:
451
- - \`status_gui\`: none, alert, all (Status Dashboard).
452
- - \`logs_gui\`: none, error, verbose (Logs Techniques).
453
- - \`threshold_tier\`: 0-100 (Alerte %).
454
- - \`threshold_wallet\`: 0-100 (Safety Net %).
455
- - \`status_bar\`: true/false (Widget).
474
+ - \`status_gui\`: none, alert, all
475
+ - \`logs_gui\`: none, error, verbose
476
+ - \`threshold_tier\` / \`threshold_wallet\`: 0-100
477
+ - \`status_bar\`: true/false
478
+
479
+ > πŸ’‘ **RMBG keys**: Use the \`rmbg_keys\` tool (works with any model).
456
480
  `.trim();
457
481
  return { handled: true, response: help };
458
482
  }
@@ -460,16 +484,44 @@ function handleHelpCommand() {
460
484
  export function createCommandHooks() {
461
485
  return {
462
486
  'tui.command.execute': async (input, output) => {
463
- const result = await handleCommand(input.command);
464
- if (result.handled) {
465
- output.handled = true;
466
- if (result.response) {
467
- output.response = result.response;
468
- }
469
- if (result.error) {
470
- output.error = result.error;
487
+ if (!input.command.startsWith('/pollinations')) {
488
+ return;
489
+ }
490
+ try {
491
+ // Parse command
492
+ const rawArgs = input.command.replace('/pollinations', '').trim();
493
+ const result = await handleCommand(rawArgs);
494
+ if (result.handled) {
495
+ if (result.error) {
496
+ output.error = `❌ **Erreur:** ${result.error}`;
497
+ }
498
+ else if (result.response) {
499
+ output.response = result.response;
500
+ }
501
+ // If no response and no error, assume handled silently (like appendPrompt)
471
502
  }
472
503
  }
504
+ catch (err) {
505
+ output.error = `❌ **Erreur Critique:** ${err.message}`;
506
+ }
507
+ },
508
+ // Hook for UI Commands (Palette / Buttons)
509
+ 'command.execute.before': async (input, output) => {
510
+ const cmd = input.command;
511
+ if (cmd === 'pollinations.addKey') {
512
+ handleCommand('addKey'); // Trigger UI helper
513
+ }
514
+ else if (cmd === 'pollinations.usage') {
515
+ const res = await handleCommand('usage');
516
+ // For native commands, we might need a different way to show output if not in chat context
517
+ // But here we are likely in a context where we can't easily hijack output unless we use a toast or open a file?
518
+ // Let's use toast for simple feedback or appendPrompt for complex interaction.
519
+ if (res.response)
520
+ globalClient?.tui.showToast({ title: "Pollinations Usage", metadata: { type: 'info', message: "Usage info sent to chat/logs" } });
521
+ }
522
+ else if (cmd === 'pollinations.mode') {
523
+ globalClient?.tui.appendPrompt({ value: "/pollinations mode " });
524
+ }
473
525
  }
474
526
  };
475
527
  }
@@ -4,3 +4,6 @@ export declare function emitStatusToast(type: 'info' | 'warning' | 'error' | 'su
4
4
  export declare function createToastHooks(client: any): {
5
5
  'session.idle': ({ event }: any) => Promise<void>;
6
6
  };
7
+ export declare function createToolHooks(client: any): {
8
+ 'tool.execute.after': (input: any, output: any) => Promise<void>;
9
+ };
@@ -76,3 +76,19 @@ export function createToastHooks(client) {
76
76
  }
77
77
  };
78
78
  }
79
+ // 3. CANAL TOOLS (Natif)
80
+ export function createToolHooks(client) {
81
+ return {
82
+ 'tool.execute.after': async (input, output) => {
83
+ // Check for metadata in the output
84
+ if (output.metadata && output.metadata.message) {
85
+ const meta = output.metadata;
86
+ const type = meta.type || 'info';
87
+ // If title is not in metadata, try to use the one from output or default
88
+ const title = meta.title || output.title || 'Pollinations Tool';
89
+ // Emit the toast
90
+ emitStatusToast(type, meta.message, title);
91
+ }
92
+ }
93
+ };
94
+ }
@@ -15,6 +15,7 @@ import { fileToUrlTool } from './power/file_to_url.js';
15
15
  import { removeBackgroundTool } from './power/remove_background.js';
16
16
  import { extractFramesTool } from './power/extract_frames.js';
17
17
  import { extractAudioTool } from './power/extract_audio.js';
18
+ import { rmbgKeysTool } from './power/rmbg_keys.js';
18
19
  // === ENTER TOOLS (Require API key) ===
19
20
  // Phase 4D: Pollinations tools β€” TO BE IMPLEMENTED
20
21
  // import { genImageTool } from './pollinations/gen_image.js';
@@ -57,6 +58,7 @@ export function createToolRegistry() {
57
58
  tools['remove_background'] = removeBackgroundTool;
58
59
  tools['extract_frames'] = extractFramesTool;
59
60
  tools['extract_audio'] = extractAudioTool;
61
+ tools['rmbg_keys'] = rmbgKeysTool;
60
62
  // gen_image (free version) β€” TODO Phase 4D
61
63
  // tools['gen_image'] = genImageTool;
62
64
  log(`Free tools injected: ${Object.keys(tools).length}`);
@@ -3,66 +3,167 @@ import * as https from 'https';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import { resolveOutputDir, formatFileSize, TOOL_DIRS } from '../shared.js';
6
- const REMBG_API_URL = 'https://cut.esprit-artificiel.com';
7
- const REMBG_API_KEY = 'sk-cut-fkomEA2026-hybridsoap161200';
8
- /**
9
- * Send image to rembg API for background removal
10
- */
11
- function removeBackground(imagePath) {
6
+ // ─── Provider Defaults ───────────────────────────────────────────────────────
7
+ const CUT_API_URL = 'https://cut.esprit-artificiel.com';
8
+ const CUT_API_KEY = 'sk-cut-fkomEA2026-hybridsoap161200';
9
+ const BACKGROUNDCUT_API_URL = 'https://backgroundcut.co/api/v1/cut/';
10
+ // ─── Key Storage ─────────────────────────────────────────────────────────────
11
+ const KEYS_FILE = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.pollinations', 'backgroundcut_keys.json');
12
+ function loadKeys() {
13
+ try {
14
+ if (fs.existsSync(KEYS_FILE)) {
15
+ return JSON.parse(fs.readFileSync(KEYS_FILE, 'utf-8'));
16
+ }
17
+ }
18
+ catch { }
19
+ return { keys: [], currentIndex: 0 };
20
+ }
21
+ function getNextKey() {
22
+ const store = loadKeys();
23
+ if (store.keys.length === 0)
24
+ return null;
25
+ const key = store.keys[store.currentIndex % store.keys.length];
26
+ // Rotate for next call
27
+ store.currentIndex = (store.currentIndex + 1) % store.keys.length;
28
+ try {
29
+ const dir = path.dirname(KEYS_FILE);
30
+ if (!fs.existsSync(dir))
31
+ fs.mkdirSync(dir, { recursive: true });
32
+ fs.writeFileSync(KEYS_FILE, JSON.stringify(store, null, 2));
33
+ }
34
+ catch { }
35
+ return key;
36
+ }
37
+ // ─── HTTP Helpers ────────────────────────────────────────────────────────────
38
+ function httpRequest(url, options, body) {
12
39
  return new Promise((resolve, reject) => {
13
- const imageData = fs.readFileSync(imagePath);
14
- const boundary = `----FormBoundary${Date.now()}`;
15
- const parts = [];
16
- const ext = path.extname(imagePath).toLowerCase();
17
- const mimeType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
18
- parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${path.basename(imagePath)}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
19
- parts.push(imageData);
20
- parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
21
- const body = Buffer.concat(parts);
22
- const url = new URL(`${REMBG_API_URL}/remove-bg`);
23
- const req = https.request({
24
- hostname: url.hostname,
25
- path: url.pathname,
26
- method: 'POST',
27
- headers: {
28
- 'Content-Type': `multipart/form-data; boundary=${boundary}`,
29
- 'Content-Length': body.length,
30
- 'X-Api-Key': REMBG_API_KEY,
31
- 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0',
32
- },
33
- }, (res) => {
34
- if (res.statusCode && res.statusCode >= 400) {
35
- let errData = '';
36
- res.on('data', (chunk) => errData += chunk);
37
- res.on('end', () => reject(new Error(`API Error ${res.statusCode}: ${errData.substring(0, 200)}`)));
38
- return;
39
- }
40
+ const req = https.request(url, options, (res) => {
40
41
  const chunks = [];
41
42
  res.on('data', (chunk) => chunks.push(chunk));
42
- res.on('end', () => resolve(Buffer.concat(chunks)));
43
+ res.on('end', () => {
44
+ const responseBody = Buffer.concat(chunks);
45
+ const statusCode = res.statusCode || 500;
46
+ let json;
47
+ try {
48
+ json = JSON.parse(responseBody.toString());
49
+ }
50
+ catch { }
51
+ resolve({ statusCode, body: responseBody, json });
52
+ });
43
53
  });
44
54
  req.on('error', reject);
45
- req.setTimeout(60000, () => {
46
- req.destroy();
47
- reject(new Error('Background removal timeout (60s)'));
48
- });
49
- req.write(body);
55
+ req.setTimeout(60000, () => { req.destroy(); reject(new Error('Request timeout (60s)')); });
56
+ if (body)
57
+ req.write(body);
50
58
  req.end();
51
59
  });
52
60
  }
53
- // formatFileSize imported from shared.ts
61
+ function downloadFile(url) {
62
+ return new Promise((resolve, reject) => {
63
+ const protocol = url.startsWith('https') ? https : require('http');
64
+ protocol.get(url, (res) => {
65
+ // Handle redirects
66
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
67
+ return downloadFile(res.headers.location).then(resolve).catch(reject);
68
+ }
69
+ const chunks = [];
70
+ res.on('data', (c) => chunks.push(c));
71
+ res.on('end', () => resolve(Buffer.concat(chunks)));
72
+ }).on('error', reject);
73
+ });
74
+ }
75
+ // ─── Provider: cut.esprit-artificiel.com (returns binary PNG directly) ────────
76
+ async function removeViaCut(imageData, filename, mimeType) {
77
+ const boundary = `----FormBoundary${Date.now()}`;
78
+ const parts = [];
79
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
80
+ parts.push(imageData);
81
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
82
+ const body = Buffer.concat(parts);
83
+ const url = new URL(`${CUT_API_URL}/remove-bg`);
84
+ const res = await httpRequest(url.toString(), {
85
+ method: 'POST',
86
+ headers: {
87
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
88
+ 'Content-Length': body.length,
89
+ 'X-Api-Key': CUT_API_KEY,
90
+ 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0',
91
+ },
92
+ }, body);
93
+ if (res.statusCode >= 400) {
94
+ throw new Error(`CUT API Error ${res.statusCode}: ${res.body.toString().substring(0, 200)}`);
95
+ }
96
+ return res.body;
97
+ }
98
+ // ─── Provider: BackgroundCut.co (returns JSON with output_image_url) ─────────
99
+ async function removeViaBackgroundCut(imageData, filename, mimeType, apiKey, quality = 'medium', returnFormat = 'png', maxResolution) {
100
+ const boundary = `----FormBoundary${Date.now()}`;
101
+ const parts = [];
102
+ // File field
103
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
104
+ parts.push(imageData);
105
+ // Quality
106
+ parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="quality"\r\n\r\n${quality}`));
107
+ // Return format
108
+ parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="return_format"\r\n\r\n${returnFormat.toUpperCase()}`));
109
+ // Max resolution
110
+ if (maxResolution) {
111
+ parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="max_resolution"\r\n\r\n${maxResolution}`));
112
+ }
113
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
114
+ const body = Buffer.concat(parts);
115
+ const res = await httpRequest(BACKGROUNDCUT_API_URL, {
116
+ method: 'POST',
117
+ headers: {
118
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
119
+ 'Content-Length': body.length,
120
+ 'Authorization': `Token ${apiKey}`,
121
+ 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0',
122
+ },
123
+ }, body);
124
+ // Error handling with specific codes
125
+ if (res.statusCode === 401)
126
+ throw new Error('BGCUT_401:Invalid or missing API key');
127
+ if (res.statusCode === 402)
128
+ throw new Error('BGCUT_402:No credits remaining');
129
+ if (res.statusCode === 429)
130
+ throw new Error('BGCUT_429:Rate limit exceeded');
131
+ if (res.statusCode === 413)
132
+ throw new Error('BGCUT_413:File too large (max 12MB)');
133
+ if (res.statusCode >= 400) {
134
+ const msg = res.json?.error || res.body.toString().substring(0, 200);
135
+ throw new Error(`BGCUT_${res.statusCode}:${msg}`);
136
+ }
137
+ // Download the result image from the URL
138
+ const outputUrl = res.json?.output_image_url;
139
+ if (!outputUrl)
140
+ throw new Error('BackgroundCut returned no output URL');
141
+ return await downloadFile(outputUrl);
142
+ }
143
+ // ─── Main Tool ───────────────────────────────────────────────────────────────
54
144
  export const removeBackgroundTool = tool({
55
- description: `Remove the background from an image, producing a transparent PNG.
56
- Uses AI-powered background removal (u2netp model).
57
- Supports PNG, JPEG, and WebP input formats.
58
- Free to use β€” no API key or pollen required.`,
145
+ description: `Remove the background from an image, producing a transparent PNG or WebP.
146
+
147
+ **Providers:**
148
+ - \`cut\` (default free) β€” Built-in u2netp AI, no API key needed. Slower, rate-limited.
149
+ - \`backgroundcut\` β€” Premium API, requires API key (5$ free credits at signup on backgroundcut.co). Much faster, higher quality.
150
+
151
+ **Setup:** Use \`/poll key add backgroundcut <your_key>\` to store your BackgroundCut API key.
152
+ Multiple keys supported with automatic rotation.
153
+
154
+ **Auto mode:** If a BackgroundCut key is stored, uses it first. Falls back to free provider on error (402/429).`,
59
155
  args: {
60
156
  image_path: tool.schema.string().describe('Absolute path to the image file'),
61
- filename: tool.schema.string().optional().describe('Custom output filename (without extension). Auto-generated if omitted'),
157
+ filename: tool.schema.string().optional().describe('Custom output filename (without extension)'),
62
158
  output_path: tool.schema.string().optional().describe('Custom output directory. Default: ~/Downloads/pollinations/rembg/'),
159
+ provider: tool.schema.string().optional().describe('Provider: "auto" (default), "cut" (free), or "backgroundcut" (premium)'),
160
+ api_key: tool.schema.string().optional().describe('BackgroundCut API key (overrides stored keys)'),
161
+ quality: tool.schema.string().optional().describe('BackgroundCut quality: "low", "medium" (default), "high"'),
162
+ return_format: tool.schema.string().optional().describe('Output format: "png" (default), "webp"'),
163
+ max_resolution: tool.schema.number().optional().describe('Max output resolution in pixels (e.g., 4000000 for 4MP). BackgroundCut only.'),
63
164
  },
64
165
  async execute(args, context) {
65
- // Validate input
166
+ // ── Validate input ──
66
167
  if (!fs.existsSync(args.image_path)) {
67
168
  return `❌ File not found: ${args.image_path}`;
68
169
  }
@@ -71,34 +172,124 @@ Free to use β€” no API key or pollen required.`,
71
172
  return `❌ Unsupported format: ${ext}. Use PNG, JPEG, or WebP.`;
72
173
  }
73
174
  const inputStats = fs.statSync(args.image_path);
74
- if (inputStats.size > 10 * 1024 * 1024) {
75
- return `❌ File too large (${formatFileSize(inputStats.size)}). Max: 10MB`;
175
+ if (inputStats.size > 12 * 1024 * 1024) {
176
+ return `❌ File too large (${formatFileSize(inputStats.size)}). Max: 12MB`;
177
+ }
178
+ // ── Resolve provider ──
179
+ const provider = (args.provider || 'auto');
180
+ const quality = (args.quality || 'medium');
181
+ const returnFormat = (args.return_format || 'png');
182
+ const imageData = fs.readFileSync(args.image_path);
183
+ const mimeType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
184
+ const basename = path.basename(args.image_path);
185
+ // ── Resolve API key (arg > stored > none) ──
186
+ let bgcutKey = args.api_key || null;
187
+ if (!bgcutKey)
188
+ bgcutKey = getNextKey();
189
+ // ── Determine effective provider ──
190
+ let effectiveProvider = provider;
191
+ if (provider === 'auto') {
192
+ effectiveProvider = bgcutKey ? 'backgroundcut' : 'cut';
193
+ }
194
+ // ── Info message when no BackgroundCut key ──
195
+ if (!bgcutKey && (provider === 'auto' || provider === 'backgroundcut')) {
196
+ console.log("No BackgroundCut key found. Using free provider.");
197
+ context.metadata({
198
+ title: "RMBG (Free)",
199
+ metadata: { type: 'info', message: "Mode Gratuit (Pas de clΓ© dΓ©tectΓ©e)" }
200
+ });
201
+ const noKeyMsg = [
202
+ `ℹ️ **No BackgroundCut API key configured** β€” using free provider (slower, rate-limited).`,
203
+ ``,
204
+ `πŸš€ **Want faster, higher-quality results?**`,
205
+ `1. Sign up at https://backgroundcut.co (5$ free credits, 60 days)`,
206
+ `2. Run: \`/poll key add backgroundcut YOUR_API_KEY\``,
207
+ `3. Multiple keys supported β€” they rotate automatically!`,
208
+ ].join('\n');
209
+ if (provider === 'backgroundcut') {
210
+ return `❌ BackgroundCut provider selected but no API key found.\n\n${noKeyMsg}`;
211
+ }
212
+ // In auto mode, we continue with free provider but show the message
213
+ context.metadata({ title: `⚑ Using free provider (no BackgroundCut key)` });
76
214
  }
215
+ // ── Output path ──
77
216
  const outputDir = resolveOutputDir(TOOL_DIRS.rembg, args.output_path);
78
217
  const safeName = args.filename
79
218
  ? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
80
219
  : `${path.basename(args.image_path, ext)}_nobg`;
81
- const outputPath = path.join(outputDir, `${safeName}.png`);
220
+ const outputExt = returnFormat === 'webp' && effectiveProvider === 'backgroundcut' ? 'webp' : 'png';
221
+ const outputPath = path.join(outputDir, `${safeName}.${outputExt}`);
222
+ // ── Execute ──
82
223
  try {
83
- context.metadata({ title: `βœ‚οΈ Removing background: ${path.basename(args.image_path)}` });
84
- const resultBuffer = await removeBackground(args.image_path);
224
+ let resultBuffer;
225
+ let usedProvider;
226
+ let fallbackUsed = false;
227
+ if (effectiveProvider === 'backgroundcut' && bgcutKey) {
228
+ context.metadata({ title: `βœ‚οΈ BackgroundCut: ${basename}` }); // 1. TRY BACKGROUNDCUT
229
+ try {
230
+ console.log(`[RMBG] Attempting BackgroundCut with key ${bgcutKey.substring(0, 8)}...`);
231
+ resultBuffer = await removeViaBackgroundCut(imageData, basename, mimeType, bgcutKey, quality, returnFormat, args.max_resolution);
232
+ context.metadata({
233
+ title: "RMBG (Premium)",
234
+ metadata: { type: 'success', message: "DΓ©tourage HD rΓ©ussi (BackgroundCut)" }
235
+ });
236
+ usedProvider = 'backgroundcut';
237
+ }
238
+ catch (err) {
239
+ // Fallback to CUT on specific errors
240
+ const isFallbackable = err.message.startsWith('BGCUT_402') ||
241
+ err.message.startsWith('BGCUT_429') ||
242
+ err.message.startsWith('BGCUT_401');
243
+ if (isFallbackable && provider === 'auto') {
244
+ context.metadata({ title: `⚠️ BackgroundCut failed (${err.message.split(':')[0]}), falling back to free...` });
245
+ resultBuffer = await removeViaCut(imageData, basename, mimeType);
246
+ usedProvider = 'cut (fallback)';
247
+ fallbackUsed = true;
248
+ }
249
+ else {
250
+ throw err;
251
+ }
252
+ }
253
+ }
254
+ else {
255
+ context.metadata({ title: `βœ‚οΈ Free RMBG: ${basename}` });
256
+ resultBuffer = await removeViaCut(imageData, basename, mimeType);
257
+ // 2. FREE PROVIDER EXECUTION
258
+ context.metadata({
259
+ title: "RMBG (Free)",
260
+ metadata: { type: 'success', message: "DΓ©tourage Standard rΓ©ussi" }
261
+ });
262
+ usedProvider = 'cut (free)';
263
+ }
85
264
  if (resultBuffer.length < 100) {
86
265
  return `❌ Background removal returned invalid data. The API might be temporarily unavailable.`;
87
266
  }
88
267
  fs.writeFileSync(outputPath, resultBuffer);
89
- return [
268
+ const lines = [
90
269
  `βœ‚οΈ Background Removed`,
91
270
  `━━━━━━━━━━━━━━━━━━━━━`,
92
- `Input: ${path.basename(args.image_path)} (${formatFileSize(inputStats.size)})`,
271
+ `Input: ${basename} (${formatFileSize(inputStats.size)})`,
93
272
  `Output: ${outputPath}`,
94
273
  `Size: ${formatFileSize(resultBuffer.length)}`,
95
- `Format: PNG (transparent)`,
274
+ `Format: ${outputExt.toUpperCase()} (transparent)`,
275
+ `Provider: ${usedProvider}`,
96
276
  `Cost: Free`,
97
- ].join('\n');
277
+ ];
278
+ if (fallbackUsed) {
279
+ lines.push(``, `⚠️ BackgroundCut key may be expired/rate-limited. Check with \`/poll key list backgroundcut\``);
280
+ }
281
+ // Add info message for users without key
282
+ if (!bgcutKey && usedProvider.includes('free')) {
283
+ lines.push(``, `πŸ’‘ Tip: Add a BackgroundCut key for faster HD results: \`/poll key add backgroundcut <key>\``);
284
+ }
285
+ return lines.join('\n');
98
286
  }
99
287
  catch (err) {
100
288
  if (err.message.includes('429') || err.message.includes('rate')) {
101
- return `⏳ Rate limited. The background removal service is busy. Please try again in 30 seconds.`;
289
+ return `⏳ Rate limited. Please try again in 30 seconds.`;
290
+ }
291
+ if (err.message.startsWith('BGCUT_402')) {
292
+ return `πŸ’³ BackgroundCut: No credits remaining. Add a new key or wait for renewal.\nRun: \`/poll key add backgroundcut <new_key>\``;
102
293
  }
103
294
  return `❌ Background Removal Error: ${err.message}`;
104
295
  }
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const rmbgKeysTool: ToolDefinition;
@@ -0,0 +1,140 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ // ─── Key Storage (shared with remove_background.ts) ──────────────────────────
6
+ const KEYS_FILE = path.join(os.homedir(), '.pollinations', 'backgroundcut_keys.json');
7
+ function loadKeys() {
8
+ try {
9
+ if (fs.existsSync(KEYS_FILE)) {
10
+ return JSON.parse(fs.readFileSync(KEYS_FILE, 'utf-8'));
11
+ }
12
+ }
13
+ catch { }
14
+ return { keys: [], currentIndex: 0 };
15
+ }
16
+ function saveKeys(store) {
17
+ const dir = path.dirname(KEYS_FILE);
18
+ if (!fs.existsSync(dir))
19
+ fs.mkdirSync(dir, { recursive: true });
20
+ fs.writeFileSync(KEYS_FILE, JSON.stringify(store, null, 2));
21
+ }
22
+ // ─── Tool ────────────────────────────────────────────────────────────────────
23
+ export const rmbgKeysTool = tool({
24
+ description: `Manage BackgroundCut API keys for the remove_background tool.
25
+
26
+ **Actions:**
27
+ - \`add\` β€” Add a new API key (supports multiple keys with auto-rotation).
28
+ - \`list\` β€” Show all stored keys and which one is next.
29
+ - \`clear\` β€” Remove all stored keys (reverts to free provider).
30
+
31
+ **Why?** The remove_background tool has 2 providers:
32
+ - **Free** (cut.esprit-artificiel.com): No key needed, but slow and rate-limited.
33
+ - **BackgroundCut** (backgroundcut.co): Fast, HD, up to 12MP. Requires an API key.
34
+
35
+ **Get a key:** Sign up at https://backgroundcut.co β†’ 5$ free credits (60 days).
36
+ Keys rotate automatically β€” add multiple keys for uninterrupted usage.`,
37
+ args: {
38
+ action: tool.schema.string().describe('Action: "add", "list", or "clear"'),
39
+ key: tool.schema.string().optional().describe('API key to add (required for "add" action)'),
40
+ },
41
+ async execute(args, context) {
42
+ const action = (args.action || '').toLowerCase();
43
+ // ── ADD ──
44
+ if (action === 'add') {
45
+ if (!args.key) {
46
+ return [
47
+ `❌ **Missing key.** Usage:`,
48
+ `\`rmbg_keys { action: "add", key: "YOUR_API_KEY" }\``,
49
+ ``,
50
+ `πŸ”‘ Get your key at https://backgroundcut.co (5$ free, 60 days).`,
51
+ ].join('\n');
52
+ }
53
+ // Validate format (hex, 30-60 chars)
54
+ if (!/^[a-f0-9]{30,60}$/i.test(args.key)) {
55
+ context.metadata({
56
+ metadata: { type: 'error', message: "ClΓ© BackgroundCut invalide (format hex incorrect)." }
57
+ });
58
+ return `❌ Invalid key format. BackgroundCut keys are 40-character hexadecimal strings.`;
59
+ }
60
+ const store = loadKeys();
61
+ if (store.keys.includes(args.key)) {
62
+ context.metadata({
63
+ metadata: { type: 'warning', message: "Cette clΓ© est dΓ©jΓ  enregistrΓ©e." }
64
+ });
65
+ return `⚠️ This key is already stored (${store.keys.length} key(s) total).`;
66
+ }
67
+ store.keys.push(args.key);
68
+ saveKeys(store);
69
+ const masked = args.key.substring(0, 8) + '...' + args.key.substring(args.key.length - 4);
70
+ context.metadata({
71
+ title: "Pollinations Keys",
72
+ metadata: { type: 'success', message: `ClΓ© ajoutΓ©e ! (${store.keys.length} actives)` }
73
+ });
74
+ return [
75
+ `βœ… **BackgroundCut key added!**`,
76
+ ``,
77
+ `πŸ”‘ Key: \`${masked}\``,
78
+ `πŸ“¦ Total: ${store.keys.length} key(s) β€” auto-rotation active`,
79
+ ``,
80
+ `The \`remove_background\` tool will now use BackgroundCut automatically.`,
81
+ `Faster processing, HD quality, up to 12MP.`,
82
+ ].join('\n');
83
+ }
84
+ // ── LIST ──
85
+ if (action === 'list') {
86
+ const store = loadKeys();
87
+ if (store.keys.length === 0) {
88
+ return [
89
+ `πŸ“­ **No BackgroundCut keys stored.**`,
90
+ ``,
91
+ `The \`remove_background\` tool uses the free provider (slower, rate-limited).`,
92
+ ``,
93
+ `πŸš€ **Want faster, HD results?**`,
94
+ `1. Sign up at https://backgroundcut.co (5$ free credits, 60 days)`,
95
+ `2. Add your key: \`rmbg_keys { action: "add", key: "YOUR_KEY" }\``,
96
+ `3. Multiple keys supported β€” they rotate automatically!`,
97
+ ].join('\n');
98
+ }
99
+ const lines = [
100
+ `πŸ”‘ **BackgroundCut Keys** (${store.keys.length} stored)`,
101
+ ``,
102
+ ];
103
+ store.keys.forEach((k, i) => {
104
+ const masked = k.substring(0, 8) + '...' + k.substring(k.length - 4);
105
+ const marker = i === (store.currentIndex % store.keys.length) ? ' ← next' : '';
106
+ lines.push(`${i + 1}. \`${masked}\`${marker}`);
107
+ });
108
+ lines.push(``, `Auto-rotation: next call uses key #${(store.currentIndex % store.keys.length) + 1}.`, `To clear all: \`rmbg_keys { action: "clear" }\``);
109
+ return lines.join('\n');
110
+ }
111
+ // ── CLEAR ──
112
+ if (action === 'clear') {
113
+ const store = loadKeys();
114
+ const count = store.keys.length;
115
+ saveKeys({ keys: [], currentIndex: 0 });
116
+ if (count === 0) {
117
+ return `πŸ“­ No keys to clear β€” already empty.`;
118
+ }
119
+ context.metadata({
120
+ title: "Pollinations Keys",
121
+ metadata: { type: 'info', message: "Toutes les clΓ©s BackgroundCut ont Γ©tΓ© supprimΓ©es." }
122
+ });
123
+ return [
124
+ `πŸ—‘οΈ **${count} BackgroundCut key(s) cleared.**`,
125
+ ``,
126
+ `The \`remove_background\` tool will now use the free provider.`,
127
+ `Add keys again anytime: \`rmbg_keys { action: "add", key: "..." }\``,
128
+ ].join('\n');
129
+ }
130
+ // ── UNKNOWN ──
131
+ return [
132
+ `❌ Unknown action: "${action}"`,
133
+ ``,
134
+ `Available actions:`,
135
+ `- \`add\` β€” Add a BackgroundCut API key`,
136
+ `- \`list\` β€” Show stored keys`,
137
+ `- \`clear\` β€” Remove all keys`,
138
+ ].join('\n');
139
+ },
140
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-pollinations-plugin",
3
3
  "displayName": "Pollinations AI (V5.9)",
4
- "version": "6.0.0-beta.3",
4
+ "version": "6.0.0-beta.6",
5
5
  "description": "Native Pollinations.ai Provider Plugin for OpenCode",
6
6
  "publisher": "pollinations",
7
7
  "repository": {
@@ -41,6 +41,10 @@
41
41
  {
42
42
  "command": "pollinations.usage",
43
43
  "title": "Pollinations: Show Usage"
44
+ },
45
+ {
46
+ "command": "pollinations.addKey",
47
+ "title": "Pollinations: Add BackgroundCut Key"
44
48
  }
45
49
  ]
46
50
  },