opencode-pollinations-plugin 6.0.0 → 6.1.0-beta.10

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.
Files changed (56) hide show
  1. package/README.md +140 -87
  2. package/dist/index.js +33 -154
  3. package/dist/server/commands.d.ts +2 -0
  4. package/dist/server/commands.js +84 -25
  5. package/dist/server/config.d.ts +6 -0
  6. package/dist/server/config.js +4 -1
  7. package/dist/server/generate-config.d.ts +3 -30
  8. package/dist/server/generate-config.js +172 -100
  9. package/dist/server/index.d.ts +2 -1
  10. package/dist/server/index.js +124 -149
  11. package/dist/server/pollinations-api.d.ts +11 -0
  12. package/dist/server/pollinations-api.js +20 -0
  13. package/dist/server/proxy.js +158 -72
  14. package/dist/server/quota.d.ts +8 -0
  15. package/dist/server/quota.js +106 -61
  16. package/dist/server/toast.d.ts +3 -0
  17. package/dist/server/toast.js +16 -0
  18. package/dist/tools/design/gen_diagram.d.ts +2 -0
  19. package/dist/tools/design/gen_diagram.js +94 -0
  20. package/dist/tools/design/gen_palette.d.ts +2 -0
  21. package/dist/tools/design/gen_palette.js +182 -0
  22. package/dist/tools/design/gen_qrcode.d.ts +2 -0
  23. package/dist/tools/design/gen_qrcode.js +50 -0
  24. package/dist/tools/index.d.ts +22 -0
  25. package/dist/tools/index.js +81 -0
  26. package/dist/tools/pollinations/deepsearch.d.ts +7 -0
  27. package/dist/tools/pollinations/deepsearch.js +80 -0
  28. package/dist/tools/pollinations/gen_audio.d.ts +18 -0
  29. package/dist/tools/pollinations/gen_audio.js +204 -0
  30. package/dist/tools/pollinations/gen_image.d.ts +13 -0
  31. package/dist/tools/pollinations/gen_image.js +239 -0
  32. package/dist/tools/pollinations/gen_music.d.ts +14 -0
  33. package/dist/tools/pollinations/gen_music.js +139 -0
  34. package/dist/tools/pollinations/gen_video.d.ts +16 -0
  35. package/dist/tools/pollinations/gen_video.js +222 -0
  36. package/dist/tools/pollinations/search_crawl_scrape.d.ts +7 -0
  37. package/dist/tools/pollinations/search_crawl_scrape.js +85 -0
  38. package/dist/tools/pollinations/shared.d.ts +170 -0
  39. package/dist/tools/pollinations/shared.js +454 -0
  40. package/dist/tools/pollinations/transcribe_audio.d.ts +17 -0
  41. package/dist/tools/pollinations/transcribe_audio.js +235 -0
  42. package/dist/tools/power/extract_audio.d.ts +2 -0
  43. package/dist/tools/power/extract_audio.js +180 -0
  44. package/dist/tools/power/extract_frames.d.ts +2 -0
  45. package/dist/tools/power/extract_frames.js +240 -0
  46. package/dist/tools/power/file_to_url.d.ts +2 -0
  47. package/dist/tools/power/file_to_url.js +217 -0
  48. package/dist/tools/power/remove_background.d.ts +2 -0
  49. package/dist/tools/power/remove_background.js +365 -0
  50. package/dist/tools/power/rmbg_keys.d.ts +2 -0
  51. package/dist/tools/power/rmbg_keys.js +78 -0
  52. package/dist/tools/shared.d.ts +30 -0
  53. package/dist/tools/shared.js +74 -0
  54. package/package.json +9 -3
  55. package/dist/server/models-seed.d.ts +0 -18
  56. package/dist/server/models-seed.js +0 -55
@@ -0,0 +1,217 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import * as https from 'https';
3
+ import * as http from 'http';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ const PROVIDERS = [
7
+ // Provider 1: Litterbox (catbox.moe) — fiable, anonyme, testé OK
8
+ {
9
+ name: 'litterbox.catbox.moe',
10
+ maxSize: 200 * 1024 * 1024, // 200MB
11
+ expiry: '1h-72h',
12
+ upload: (filePath, fileName, fileData, mimeType, expiry) => httpUpload({
13
+ url: 'https://litterbox.catbox.moe/resources/internals/api.php',
14
+ fields: { reqtype: 'fileupload', time: expiry },
15
+ fileField: 'fileToUpload',
16
+ fileName, fileData, mimeType,
17
+ parseResponse: (body) => {
18
+ const trimmed = body.trim();
19
+ if (trimmed.startsWith('https://'))
20
+ return trimmed;
21
+ throw new Error(`Réponse inattendue: ${trimmed.substring(0, 100)}`);
22
+ },
23
+ }),
24
+ },
25
+ // Provider 2: tmpfile.link — CDN rapide, 100MB max, 7j
26
+ {
27
+ name: 'tmpfile.link',
28
+ maxSize: 100 * 1024 * 1024, // 100MB
29
+ expiry: '7 jours',
30
+ upload: (filePath, fileName, fileData, mimeType) => httpUpload({
31
+ url: 'https://tmpfile.link/api/upload',
32
+ fields: {},
33
+ fileField: 'file',
34
+ fileName, fileData, mimeType,
35
+ parseResponse: (body) => {
36
+ const json = JSON.parse(body);
37
+ if (json.downloadLink)
38
+ return json.downloadLink;
39
+ throw new Error(`Pas de downloadLink: ${body.substring(0, 100)}`);
40
+ },
41
+ }),
42
+ },
43
+ // Provider 3: file.io — auto-destruction après 1er téléchargement
44
+ {
45
+ name: 'file.io',
46
+ maxSize: 2 * 1024 * 1024 * 1024, // 2GB
47
+ expiry: '14 jours (auto-détruit)',
48
+ upload: (filePath, fileName, fileData, mimeType) => httpUpload({
49
+ url: 'https://file.io',
50
+ fields: { expires: '14d' },
51
+ fileField: 'file',
52
+ fileName, fileData, mimeType,
53
+ parseResponse: (body) => {
54
+ const json = JSON.parse(body);
55
+ if (json.success && json.link)
56
+ return json.link;
57
+ throw new Error(`file.io erreur: ${json.message || body.substring(0, 100)}`);
58
+ },
59
+ }),
60
+ },
61
+ ];
62
+ function httpUpload(opts) {
63
+ return new Promise((resolve, reject) => {
64
+ const boundary = `----FormBoundary${Date.now()}${Math.random().toString(36).slice(2)}`;
65
+ const parts = [];
66
+ // Text fields
67
+ for (const [key, value] of Object.entries(opts.fields)) {
68
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`));
69
+ }
70
+ // File field
71
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${opts.fileField}"; filename="${opts.fileName}"\r\nContent-Type: ${opts.mimeType}\r\n\r\n`));
72
+ parts.push(opts.fileData);
73
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
74
+ const body = Buffer.concat(parts);
75
+ const url = new URL(opts.url);
76
+ const isHttps = url.protocol === 'https:';
77
+ const mod = isHttps ? https : http;
78
+ const req = mod.request({
79
+ hostname: url.hostname,
80
+ path: url.pathname,
81
+ method: 'POST',
82
+ headers: {
83
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
84
+ 'Content-Length': body.length,
85
+ 'User-Agent': 'Mozilla/5.0 (compatible; OpenCode-Plugin/6.0)',
86
+ 'Accept': 'application/json, text/plain, */*',
87
+ },
88
+ }, (res) => {
89
+ // Follow redirects (301, 302, 307)
90
+ if (res.statusCode && [301, 302, 307].includes(res.statusCode) && res.headers.location) {
91
+ httpUpload({ ...opts, url: res.headers.location }).then(resolve).catch(reject);
92
+ return;
93
+ }
94
+ let data = '';
95
+ res.on('data', (chunk) => data += chunk);
96
+ res.on('end', () => {
97
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
98
+ try {
99
+ resolve(opts.parseResponse(data));
100
+ }
101
+ catch (err) {
102
+ reject(new Error(`Parse error: ${err.message}`));
103
+ }
104
+ }
105
+ else {
106
+ reject(new Error(`HTTP ${res.statusCode}: ${data.substring(0, 200)}`));
107
+ }
108
+ });
109
+ });
110
+ req.on('error', (err) => reject(new Error(`Réseau: ${err.message}`)));
111
+ req.setTimeout(45000, () => {
112
+ req.destroy();
113
+ reject(new Error('Timeout (45s)'));
114
+ });
115
+ req.write(body);
116
+ req.end();
117
+ });
118
+ }
119
+ // ─── Upload with Cascade Fallback ───────────────────────────────────────────
120
+ async function uploadWithFallback(filePath, fileSize, expiry) {
121
+ const fileName = path.basename(filePath);
122
+ const fileData = fs.readFileSync(filePath);
123
+ const mimeType = getMimeType(fileName);
124
+ const attempts = [];
125
+ for (const provider of PROVIDERS) {
126
+ if (fileSize > provider.maxSize) {
127
+ attempts.push(`⏭️ ${provider.name}: fichier trop gros (max ${formatFileSize(provider.maxSize)})`);
128
+ continue;
129
+ }
130
+ try {
131
+ const url = await provider.upload(filePath, fileName, fileData, mimeType, expiry);
132
+ return { url, provider: provider.name, expiry: provider.expiry, attempts };
133
+ }
134
+ catch (err) {
135
+ attempts.push(`❌ ${provider.name}: ${err.message}`);
136
+ }
137
+ }
138
+ throw new Error(`Tous les services d'upload ont échoué:\n${attempts.join('\n')}`);
139
+ }
140
+ // ─── Utils ──────────────────────────────────────────────────────────────────
141
+ function getMimeType(filename) {
142
+ const ext = path.extname(filename).toLowerCase();
143
+ const types = {
144
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
145
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
146
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg',
147
+ '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac',
148
+ '.pdf': 'application/pdf', '.txt': 'text/plain',
149
+ '.json': 'application/json', '.html': 'text/html', '.css': 'text/css',
150
+ '.js': 'application/javascript', '.ts': 'text/typescript',
151
+ '.zip': 'application/zip', '.tar': 'application/x-tar',
152
+ '.gz': 'application/gzip', '.7z': 'application/x-7z-compressed',
153
+ };
154
+ return types[ext] || 'application/octet-stream';
155
+ }
156
+ function formatFileSize(bytes) {
157
+ if (bytes < 1024)
158
+ return `${bytes} B`;
159
+ if (bytes < 1024 * 1024)
160
+ return `${(bytes / 1024).toFixed(1)} KB`;
161
+ if (bytes < 1024 * 1024 * 1024)
162
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
163
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
164
+ }
165
+ // ─── Tool Definition ────────────────────────────────────────────────────────
166
+ export const fileToUrlTool = tool({
167
+ description: `Upload a local file to get a temporary public URL.
168
+ Uses a resilient multi-provider system with automatic fallback:
169
+ 1. litterbox.catbox.moe (200MB, 1h-72h, anonymous)
170
+ 2. tmpfile.link (100MB, 7 days, CDN global)
171
+ 3. file.io (2GB, auto-destruct after 1 download)
172
+ If one service is down, the next one is tried automatically.
173
+ No API key needed, no account required.`,
174
+ args: {
175
+ file_path: tool.schema.string().describe('Absolute path to the local file to upload'),
176
+ expiry: tool.schema.enum(['1h', '12h', '24h', '72h']).optional()
177
+ .describe('How long the URL stays active on primary provider (default: 24h)'),
178
+ },
179
+ async execute(args, context) {
180
+ const expiry = args.expiry || '24h';
181
+ // Validate file exists
182
+ if (!fs.existsSync(args.file_path)) {
183
+ return `❌ Fichier introuvable: ${args.file_path}`;
184
+ }
185
+ const stats = fs.statSync(args.file_path);
186
+ if (stats.size === 0) {
187
+ return `❌ Fichier vide: ${args.file_path}`;
188
+ }
189
+ if (stats.size > 2 * 1024 * 1024 * 1024) {
190
+ return `❌ Fichier trop volumineux (${formatFileSize(stats.size)}). Max: 2 GB`;
191
+ }
192
+ try {
193
+ context.metadata({ title: `📤 Upload: ${path.basename(args.file_path)}` });
194
+ const result = await uploadWithFallback(args.file_path, stats.size, expiry);
195
+ const lines = [
196
+ `📤 Fichier Uploadé`,
197
+ `━━━━━━━━━━━━━━━━━━`,
198
+ `Fichier: ${path.basename(args.file_path)}`,
199
+ `Taille: ${formatFileSize(stats.size)}`,
200
+ `URL: ${result.url}`,
201
+ `Service: ${result.provider}`,
202
+ `Expiration: ${result.expiry}`,
203
+ ``,
204
+ `Coût: Gratuit (hébergement anonyme)`,
205
+ ];
206
+ // Show fallback attempts if any
207
+ if (result.attempts.length > 0) {
208
+ lines.push('', '⚠️ Fallbacks utilisés:');
209
+ lines.push(...result.attempts);
210
+ }
211
+ return lines.join('\n');
212
+ }
213
+ catch (err) {
214
+ return `❌ Erreur Upload: ${err.message}`;
215
+ }
216
+ },
217
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const removeBackgroundTool: ToolDefinition;
@@ -0,0 +1,365 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import { emitStatusToast } from '../../server/toast.js';
3
+ import * as https from 'https';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import { resolveOutputDir, formatFileSize, TOOL_DIRS } from '../shared.js';
7
+ // ─── Provider Defaults ───────────────────────────────────────────────────────
8
+ const CUT_API_URL = 'https://cut.esprit-artificiel.com';
9
+ const CUT_API_KEY = 'sk-cut-fkomEA2026-hybridsoap161200';
10
+ const BACKGROUNDCUT_API_URL = 'https://backgroundcut.co/api/v1/cut/';
11
+ // ─── Key Storage ─────────────────────────────────────────────────────────────
12
+ const KEYS_FILE = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.pollinations', 'backgroundcut_keys.json');
13
+ function loadKeys() {
14
+ try {
15
+ if (fs.existsSync(KEYS_FILE)) {
16
+ return JSON.parse(fs.readFileSync(KEYS_FILE, 'utf-8'));
17
+ }
18
+ }
19
+ catch { }
20
+ return { keys: [], currentIndex: 0 };
21
+ }
22
+ function getRotatedKeys() {
23
+ const store = loadKeys();
24
+ if (store.keys.length === 0)
25
+ return [];
26
+ // Return keys starting from currentIndex looping back to start
27
+ const before = store.keys.slice(store.currentIndex);
28
+ const after = store.keys.slice(0, store.currentIndex);
29
+ return [...before, ...after];
30
+ }
31
+ function advanceKeyIndex() {
32
+ const store = loadKeys();
33
+ if (store.keys.length === 0)
34
+ return;
35
+ store.currentIndex = (store.currentIndex + 1) % store.keys.length;
36
+ try {
37
+ const dir = path.dirname(KEYS_FILE);
38
+ if (!fs.existsSync(dir))
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ fs.writeFileSync(KEYS_FILE, JSON.stringify(store, null, 2));
41
+ }
42
+ catch { }
43
+ }
44
+ // ─── HTTP Helpers ────────────────────────────────────────────────────────────
45
+ function httpRequest(url, options, body) {
46
+ return new Promise((resolve, reject) => {
47
+ const req = https.request(url, options, (res) => {
48
+ const chunks = [];
49
+ res.on('data', (chunk) => chunks.push(chunk));
50
+ res.on('end', () => {
51
+ const responseBody = Buffer.concat(chunks);
52
+ const statusCode = res.statusCode || 500;
53
+ let json;
54
+ try {
55
+ json = JSON.parse(responseBody.toString());
56
+ }
57
+ catch { }
58
+ resolve({ statusCode, body: responseBody, json });
59
+ });
60
+ });
61
+ req.on('error', reject);
62
+ req.setTimeout(60000, () => { req.destroy(); reject(new Error('Request timeout (60s)')); });
63
+ if (body)
64
+ req.write(body);
65
+ req.end();
66
+ });
67
+ }
68
+ function downloadFile(url) {
69
+ return new Promise((resolve, reject) => {
70
+ const protocol = url.startsWith('https') ? https : require('http');
71
+ protocol.get(url, (res) => {
72
+ // Handle redirects
73
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
74
+ return downloadFile(res.headers.location).then(resolve).catch(reject);
75
+ }
76
+ const chunks = [];
77
+ res.on('data', (c) => chunks.push(c));
78
+ res.on('end', () => resolve(Buffer.concat(chunks)));
79
+ }).on('error', reject);
80
+ });
81
+ }
82
+ // ─── Helper: Get Image Size (Linux/Unix 'file' command) ─────────────────────
83
+ function getImageSize(filePath) {
84
+ try {
85
+ const { execSync } = require('child_process');
86
+ const output = execSync(`file "${filePath}"`).toString();
87
+ // Regex for "IDAT, 800 x 600," or ", 800 x 600,"
88
+ const match = output.match(/, (\d+) ?x ?(\d+),/);
89
+ if (match) {
90
+ return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
91
+ }
92
+ }
93
+ catch (e) {
94
+ // Fallback or silence
95
+ }
96
+ return null;
97
+ }
98
+ // ─── Provider: cut.esprit-artificiel.com (returns binary PNG directly) ────────
99
+ async function removeViaCut(imageData, filename, mimeType) {
100
+ const boundary = `----FormBoundary${Date.now()}`;
101
+ const parts = [];
102
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
103
+ parts.push(imageData);
104
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
105
+ const body = Buffer.concat(parts);
106
+ const url = new URL(`${CUT_API_URL}/remove-bg`);
107
+ const res = await httpRequest(url.toString(), {
108
+ method: 'POST',
109
+ headers: {
110
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
111
+ 'Content-Length': body.length,
112
+ 'X-Api-Key': CUT_API_KEY,
113
+ 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0',
114
+ },
115
+ }, body);
116
+ if (res.statusCode >= 400) {
117
+ throw new Error(`CUT API Error ${res.statusCode}: ${res.body.toString().substring(0, 200)}`);
118
+ }
119
+ return res.body;
120
+ }
121
+ // ─── Provider: BackgroundCut.co (returns JSON with output_image_url) ─────────
122
+ async function removeViaBackgroundCut(imageData, filename, mimeType, apiKey, quality = 'medium', returnFormat = 'png', maxResolution) {
123
+ const boundary = `----FormBoundary${Date.now()}`;
124
+ const parts = [];
125
+ // File field
126
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
127
+ parts.push(imageData);
128
+ // Quality
129
+ parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="quality"\r\n\r\n${quality}`));
130
+ // Return format
131
+ parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="return_format"\r\n\r\n${returnFormat.toUpperCase()}`));
132
+ // Max resolution
133
+ if (maxResolution) {
134
+ parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="max_resolution"\r\n\r\n${maxResolution}`));
135
+ }
136
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
137
+ const body = Buffer.concat(parts);
138
+ const res = await httpRequest(BACKGROUNDCUT_API_URL, {
139
+ method: 'POST',
140
+ headers: {
141
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
142
+ 'Content-Length': body.length,
143
+ 'Authorization': `Token ${apiKey}`,
144
+ 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0',
145
+ },
146
+ }, body);
147
+ // Error handling with specific codes
148
+ if (res.statusCode === 401)
149
+ throw new Error('BGCUT_401:Invalid or missing API key');
150
+ if (res.statusCode === 402)
151
+ throw new Error('BGCUT_402:No credits remaining');
152
+ if (res.statusCode === 429)
153
+ throw new Error('BGCUT_429:Rate limit exceeded');
154
+ if (res.statusCode === 413)
155
+ throw new Error('BGCUT_413:File too large (max 12MB)');
156
+ if (res.statusCode >= 400) {
157
+ const msg = res.json?.error || res.body.toString().substring(0, 200);
158
+ throw new Error(`BGCUT_${res.statusCode}:${msg}`);
159
+ }
160
+ // Download the result image from the URL
161
+ const outputUrl = res.json?.output_image_url;
162
+ if (!outputUrl)
163
+ throw new Error('BackgroundCut returned no output URL');
164
+ return await downloadFile(outputUrl);
165
+ }
166
+ // ─── Main Tool ───────────────────────────────────────────────────────────────
167
+ export const removeBackgroundTool = tool({
168
+ description: `Remove the background from an image, producing a transparent PNG or WebP.
169
+
170
+ **Providers:**
171
+ - \`cut\` (default free) — Built-in u2netp AI. Slower. Ignores quality/format/resolution.
172
+ - \`backgroundcut\` — Premium API. Requires API key. Supports all parameters.
173
+
174
+ **Setup:** Use \`rmbg_keys\` tool to manage API keys.
175
+ **Auto mode:** Uses BackgroundCut if key is available, falls back to cut.`,
176
+ args: {
177
+ image_path: tool.schema.string().describe('Absolute path to the image file'),
178
+ filename: tool.schema.string().optional().describe('Custom output filename (e.g. "my_image.png") or name without extension'),
179
+ output_path: tool.schema.string().optional().describe('Custom output directory. Default: ~/Downloads/pollinations/rembg/'),
180
+ provider: tool.schema.string().optional().describe('Provider: "auto" (default), "cut" (free), or "backgroundcut" (premium)'), // Removed .enum() as it caused errors
181
+ api_key: tool.schema.string().optional().describe('BackgroundCut API key (overrides stored keys)'),
182
+ quality: tool.schema.string().optional().describe('BackgroundCut only: "low", "medium" (default), "high"'),
183
+ return_format: tool.schema.string().optional().describe('BackgroundCut only: "png" (default), "webp"'),
184
+ max_resolution: tool.schema.number().optional().describe('BackgroundCut only: Max output resolution in pixels'),
185
+ },
186
+ async execute(args, context) {
187
+ // ── Validate input ──
188
+ if (!fs.existsSync(args.image_path)) {
189
+ return `❌ File not found: ${args.image_path}`;
190
+ }
191
+ const ext = path.extname(args.image_path).toLowerCase();
192
+ if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
193
+ return `❌ Unsupported format: ${ext}. Use PNG, JPEG, or WebP.`;
194
+ }
195
+ const inputStats = fs.statSync(args.image_path);
196
+ if (inputStats.size > 12 * 1024 * 1024) {
197
+ return `❌ File too large (${formatFileSize(inputStats.size)}). Max: 12MB`;
198
+ }
199
+ // ── Resolve provider ──
200
+ const provider = (args.provider || 'auto');
201
+ const quality = (args.quality || 'medium');
202
+ const returnFormat = (args.return_format || 'png');
203
+ const imageData = fs.readFileSync(args.image_path);
204
+ const mimeType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
205
+ const basename = path.basename(args.image_path);
206
+ // ─── Resolve API keys ──
207
+ let keysToCheck = [];
208
+ if (args.api_key) {
209
+ keysToCheck = [args.api_key];
210
+ }
211
+ else {
212
+ keysToCheck = getRotatedKeys();
213
+ }
214
+ // ── Determine initial provider strategy ──
215
+ // If specific provider requested, we stick to it (unless auto fallback)
216
+ // If auto, we try keys then fallback to cut
217
+ let effectiveProvider = provider;
218
+ if (provider === 'auto') {
219
+ // If we have keys, start with backgroundcut, else cut
220
+ effectiveProvider = keysToCheck.length > 0 ? 'backgroundcut' : 'cut';
221
+ }
222
+ // ── Info message when no BackgroundCut key ──
223
+ if (keysToCheck.length === 0 && (provider === 'auto' || provider === 'backgroundcut')) {
224
+ // console.log("No BackgroundCut key found. Using free provider."); // SILENCED
225
+ context.metadata({
226
+ title: "RMBG (Free)",
227
+ metadata: { type: 'info', message: "Mode Gratuit (Pas de clé détectée)" }
228
+ });
229
+ const noKeyMsg = [
230
+ `ℹ️ **No BackgroundCut API key configured** — using free provider (slower, rate-limited).`,
231
+ ``,
232
+ `🚀 **Want faster, higher-quality results?**`,
233
+ `1. Sign up at https://backgroundcut.co (5$ free credits, 60 days)`,
234
+ `2. Run: \`rmbg_keys action=add key=YOUR_API_KEY\``,
235
+ `3. Multiple keys supported — they rotate automatically!`,
236
+ ].join('\n');
237
+ if (provider === 'backgroundcut' && !args.api_key) {
238
+ return `❌ BackgroundCut provider selected but no API keys stored.\n\n${noKeyMsg}`;
239
+ }
240
+ // In auto mode, we continue with free provider
241
+ context.metadata({ title: `ℹ️ Using free provider (no BackgroundCut key)` });
242
+ }
243
+ // ── Resolve output filename ──
244
+ const outputDir = resolveOutputDir(TOOL_DIRS.rembg, args.output_path);
245
+ let finalFilename = '';
246
+ const targetExt = (returnFormat === 'webp' && effectiveProvider === 'backgroundcut') ? '.webp' : '.png';
247
+ if (args.filename) {
248
+ if (args.filename.toLowerCase().endsWith(targetExt)) {
249
+ finalFilename = args.filename;
250
+ }
251
+ else if (args.filename.match(/\.[a-z0-9]+$/i)) {
252
+ // Force proper extension
253
+ finalFilename = args.filename.replace(/\.[a-z0-9]+$/i, targetExt);
254
+ }
255
+ else {
256
+ finalFilename = `${args.filename}${targetExt}`;
257
+ }
258
+ }
259
+ else {
260
+ finalFilename = `${path.basename(args.image_path, ext)}_nobg${targetExt}`;
261
+ }
262
+ const outputPath = path.join(outputDir, finalFilename);
263
+ const outputExt = path.extname(outputPath).replace('.', ''); // Fix for log display
264
+ // ── Execute ──
265
+ try {
266
+ let resultBuffer = null;
267
+ let usedProvider = 'cut'; // Default
268
+ let fallbackUsed = false;
269
+ let successKey = '';
270
+ // 1. Try BackgroundCut loop if applicable
271
+ if (effectiveProvider === 'backgroundcut' && keysToCheck.length > 0) {
272
+ emitStatusToast('info', `Démarrage: ${basename}`, '✂️ BackgroundCut');
273
+ for (const key of keysToCheck) {
274
+ try {
275
+ emitStatusToast('info', `Clé ${key.substring(0, 8)}...`, '>>> Rotation RMBG');
276
+ // console.log(`[RMBG] Trying key ${key.substring(0, 8)}...`); // SILENCED
277
+ resultBuffer = await removeViaBackgroundCut(imageData, basename, mimeType, key, quality, returnFormat, args.max_resolution);
278
+ // Success!
279
+ usedProvider = 'backgroundcut';
280
+ successKey = key;
281
+ // Advance index globally so next call uses next key (fair rotation)
282
+ advanceKeyIndex();
283
+ // Only set final metadata on success
284
+ context.metadata({
285
+ title: "RMBG (Premium)",
286
+ metadata: { type: 'success', message: "Détourage HD réussi (BackgroundCut)" }
287
+ });
288
+ break; // Exit loop on success
289
+ }
290
+ catch (err) {
291
+ const isFallbackable = err.message.startsWith('BGCUT_402') ||
292
+ err.message.startsWith('BGCUT_429') ||
293
+ err.message.startsWith('BGCUT_401');
294
+ if (isFallbackable) {
295
+ // console.log(`⚠️ Key ${key.substring(0, 8)} failed (${err.message}). Rotating...`); // SILENCED
296
+ continue; // Try next key
297
+ }
298
+ else {
299
+ throw err; // Fatal error (file size etc)
300
+ }
301
+ }
302
+ }
303
+ }
304
+ // 2. Fallback to Free Provider if no result yet
305
+ if (!resultBuffer) {
306
+ if (effectiveProvider === 'backgroundcut' && provider !== 'auto') {
307
+ throw new Error('All provided keys failed or are expired.');
308
+ }
309
+ if (effectiveProvider === 'backgroundcut') {
310
+ emitStatusToast('warning', 'Toutes les clés ont échoué. Mode Gratuit activé.', '⚠️ Fallback');
311
+ // console.log('⚠️ All BackgroundCut keys failed. Falling back to free provider.'); // SILENCED
312
+ fallbackUsed = true;
313
+ }
314
+ else {
315
+ emitStatusToast('info', `Détourage via API Gratuite: ${basename}`, '✂️ Free RMBG');
316
+ }
317
+ resultBuffer = await removeViaCut(imageData, basename, mimeType);
318
+ context.metadata({
319
+ title: "RMBG (Free)",
320
+ metadata: { type: 'success', message: "Détourage Standard réussi" }
321
+ });
322
+ usedProvider = 'cut (free)';
323
+ }
324
+ if (!resultBuffer || resultBuffer.length < 100) {
325
+ return `❌ Background removal returned invalid data.`;
326
+ }
327
+ const finalPath = path.resolve(outputPath);
328
+ fs.writeFileSync(finalPath, resultBuffer);
329
+ // Double check existence
330
+ if (!fs.existsSync(finalPath)) {
331
+ throw new Error(`File write failed at: ${finalPath}`);
332
+ }
333
+ const dims = getImageSize(finalPath);
334
+ const dimStr = dims ? `${dims.width}×${dims.height}` : 'N/A';
335
+ const lines = [
336
+ `✂️ Background Removed`,
337
+ `━━━━━━━━━━━━━━━━━━━━━`,
338
+ `Input: ${basename} (${formatFileSize(inputStats.size)})`,
339
+ `Output: ${finalPath}`,
340
+ `Size: ${formatFileSize(resultBuffer.length)}`,
341
+ `Dimensions: ${dimStr}`,
342
+ `Format: ${outputExt.toUpperCase()} (transparent)`,
343
+ `Provider: ${usedProvider}`,
344
+ `Cost: Free`,
345
+ ];
346
+ if (fallbackUsed) {
347
+ lines.push(``, `⚠️ BackgroundCut key may be expired/rate-limited. Check with \`rmbg_keys action=list\``);
348
+ }
349
+ // Add info message for users without key
350
+ if (keysToCheck.length === 0 && usedProvider.includes('free')) {
351
+ lines.push(``, `💡 Tip: Add a BackgroundCut key for faster HD results: \`rmbg_keys action=add key=...\``);
352
+ }
353
+ return lines.join('\n');
354
+ }
355
+ catch (err) {
356
+ if (err.message.includes('429') || err.message.includes('rate')) {
357
+ return `⏳ Rate limited. Please try again in 30 seconds.`;
358
+ }
359
+ if (err.message.startsWith('BGCUT_402')) {
360
+ return `💳 BackgroundCut: No credits remaining. Add a new key or wait for renewal.\nRun: \`rmbg_keys action=add key=...\``;
361
+ }
362
+ return `❌ Background Removal Error: ${err.message}`;
363
+ }
364
+ },
365
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const rmbgKeysTool: ToolDefinition;
@@ -0,0 +1,78 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ // ─── Shared Logic (Duplicated from rmbg to avoid circular deps if not using shared.ts for this) ──
5
+ const KEYS_FILE = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.pollinations', 'backgroundcut_keys.json');
6
+ function loadKeys() {
7
+ try {
8
+ if (fs.existsSync(KEYS_FILE)) {
9
+ return JSON.parse(fs.readFileSync(KEYS_FILE, 'utf-8'));
10
+ }
11
+ }
12
+ catch { }
13
+ return { keys: [], currentIndex: 0 };
14
+ }
15
+ function saveKeys(store) {
16
+ try {
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
+ catch { }
23
+ }
24
+ // ─── Tool Definition ────────────────────────────────────────────────────────
25
+ export const rmbgKeysTool = tool({
26
+ description: `Manage BackgroundCut API keys for the remove_background tool.
27
+ Allows adding, listing, removing, and clearing keys.
28
+ Keys are stored locally in ~/.pollinations/backgroundcut_keys.json`,
29
+ args: {
30
+ action: tool.schema.string().describe('Action to perform: "list", "add", "remove", "clear"'), // Removed .enum()
31
+ key: tool.schema.string().optional().describe('API Key to add or remove (required for add/remove)'),
32
+ },
33
+ async execute(args, context) {
34
+ const action = args.action.toLowerCase();
35
+ let store = loadKeys();
36
+ switch (action) {
37
+ case 'list':
38
+ if (store.keys.length === 0) {
39
+ return `🔑 No keys stored. Using free provider (cut).`;
40
+ }
41
+ const maskedKeys = store.keys.map((k, i) => {
42
+ const active = i === store.currentIndex ? ' (active)' : '';
43
+ return ` ${i + 1}. ${k.substring(0, 8)}...${k.substring(k.length - 4)}${active}`;
44
+ });
45
+ return `🔑 BackgroundCut Keys: ${store.keys.length} stored\n${maskedKeys.join('\n')}`;
46
+ case 'add':
47
+ if (!args.key)
48
+ return `❌ Error: Missing 'key' argument for add action.`;
49
+ if (store.keys.includes(args.key))
50
+ return `⚠️ Key already exists.`;
51
+ store.keys.push(args.key);
52
+ saveKeys(store);
53
+ context.metadata({
54
+ title: '🔑 Key Added',
55
+ metadata: { type: 'success', message: 'BackgroundCut key stored successfully' }
56
+ });
57
+ return `✅ Key added! Total keys: ${store.keys.length}.`;
58
+ case 'remove':
59
+ if (!args.key)
60
+ return `❌ Error: Missing 'key' argument for remove action.`;
61
+ const initialLen = store.keys.length;
62
+ store.keys = store.keys.filter(k => k !== args.key);
63
+ if (store.keys.length === initialLen)
64
+ return `⚠️ Key not found.`;
65
+ // Reset index if out of bounds
66
+ if (store.currentIndex >= store.keys.length)
67
+ store.currentIndex = 0;
68
+ saveKeys(store);
69
+ return `🗑️ Key removed. Remaining: ${store.keys.length}`;
70
+ case 'clear':
71
+ store = { keys: [], currentIndex: 0 };
72
+ saveKeys(store);
73
+ return `🗑️ All keys cleared. Reverting to free provider.`;
74
+ default:
75
+ return `❌ Unknown action: ${action}. Use list, add, remove, clear.`;
76
+ }
77
+ }
78
+ });