opencode-pollinations-plugin 6.1.0-beta.9 → 6.2.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.
Files changed (109) hide show
  1. package/README.de.md +130 -0
  2. package/README.es.md +130 -0
  3. package/README.fr.md +130 -0
  4. package/README.it.md +130 -0
  5. package/README.md +87 -73
  6. package/dist/index.js +52 -161
  7. package/dist/locales/de.json +374 -0
  8. package/dist/locales/en.json +373 -0
  9. package/dist/locales/es.json +374 -0
  10. package/dist/locales/fr.json +373 -0
  11. package/dist/locales/index.d.ts +1 -0
  12. package/dist/locales/index.js +37 -0
  13. package/dist/locales/it.json +374 -0
  14. package/dist/server/commands.d.ts +6 -0
  15. package/dist/server/commands.js +394 -125
  16. package/dist/server/config.d.ts +34 -23
  17. package/dist/server/config.js +200 -108
  18. package/dist/server/connect-response.d.ts +2 -0
  19. package/dist/server/connect-response.js +59 -0
  20. package/dist/server/generate-config.d.ts +3 -30
  21. package/dist/server/generate-config.js +164 -106
  22. package/dist/server/index.d.ts +2 -1
  23. package/dist/server/index.js +124 -149
  24. package/dist/server/logger.d.ts +8 -0
  25. package/dist/server/logger.js +38 -0
  26. package/dist/server/models/cache.d.ts +35 -0
  27. package/dist/server/models/cache.js +160 -0
  28. package/dist/server/models/fetcher.d.ts +18 -0
  29. package/dist/server/models/fetcher.js +194 -0
  30. package/dist/server/models/index.d.ts +6 -0
  31. package/dist/server/models/index.js +5 -0
  32. package/dist/server/models/manual.d.ts +15 -0
  33. package/dist/server/models/manual.js +92 -0
  34. package/dist/server/models/types.d.ts +55 -0
  35. package/dist/server/models/types.js +7 -0
  36. package/dist/server/models/worker.d.ts +22 -0
  37. package/dist/server/models/worker.js +174 -0
  38. package/dist/server/pollinations-api.d.ts +11 -0
  39. package/dist/server/pollinations-api.js +21 -8
  40. package/dist/server/proxy.js +222 -307
  41. package/dist/server/quota.d.ts +2 -0
  42. package/dist/server/quota.js +89 -86
  43. package/dist/server/scripts/pollinations_pricing.d.ts +8 -0
  44. package/dist/server/scripts/pollinations_pricing.js +246 -0
  45. package/dist/server/scripts/test_cost_endpoints.d.ts +1 -0
  46. package/dist/server/scripts/test_cost_endpoints.js +61 -0
  47. package/dist/server/scripts/test_dynamic_pricing.d.ts +1 -0
  48. package/dist/server/scripts/test_dynamic_pricing.js +39 -0
  49. package/dist/server/scripts/test_freetier_audit.d.ts +11 -0
  50. package/dist/server/scripts/test_freetier_audit.js +215 -0
  51. package/dist/server/scripts/test_parallel_cost.d.ts +1 -0
  52. package/dist/server/scripts/test_parallel_cost.js +104 -0
  53. package/dist/server/toast.d.ts +7 -1
  54. package/dist/server/toast.js +43 -10
  55. package/dist/tools/design/gen_diagram.d.ts +2 -0
  56. package/dist/tools/design/gen_diagram.js +94 -0
  57. package/dist/tools/design/gen_palette.d.ts +2 -0
  58. package/dist/tools/design/gen_palette.js +182 -0
  59. package/dist/tools/design/gen_qrcode.d.ts +2 -0
  60. package/dist/tools/design/gen_qrcode.js +50 -0
  61. package/dist/tools/ffmpeg.d.ts +24 -0
  62. package/dist/tools/ffmpeg.js +54 -0
  63. package/dist/tools/index.d.ts +25 -0
  64. package/dist/tools/index.js +86 -0
  65. package/dist/tools/pollinations/beta_discovery.d.ts +9 -0
  66. package/dist/tools/pollinations/beta_discovery.js +201 -0
  67. package/dist/tools/pollinations/cost-guard.d.ts +38 -0
  68. package/dist/tools/pollinations/cost-guard.js +136 -0
  69. package/dist/tools/pollinations/deepsearch.d.ts +7 -0
  70. package/dist/tools/pollinations/deepsearch.js +80 -0
  71. package/dist/tools/pollinations/gen_audio.d.ts +18 -0
  72. package/dist/tools/pollinations/gen_audio.js +220 -0
  73. package/dist/tools/pollinations/gen_image.d.ts +11 -0
  74. package/dist/tools/pollinations/gen_image.js +211 -0
  75. package/dist/tools/pollinations/gen_music.d.ts +14 -0
  76. package/dist/tools/pollinations/gen_music.js +157 -0
  77. package/dist/tools/pollinations/gen_video.d.ts +16 -0
  78. package/dist/tools/pollinations/gen_video.js +249 -0
  79. package/dist/tools/pollinations/polli_config.d.ts +2 -0
  80. package/dist/tools/pollinations/polli_config.js +95 -0
  81. package/dist/tools/pollinations/polli_gen_confirm.d.ts +2 -0
  82. package/dist/tools/pollinations/polli_gen_confirm.js +48 -0
  83. package/dist/tools/pollinations/polli_status.d.ts +2 -0
  84. package/dist/tools/pollinations/polli_status.js +31 -0
  85. package/dist/tools/pollinations/polli_web_search.d.ts +15 -0
  86. package/dist/tools/pollinations/polli_web_search.js +126 -0
  87. package/dist/tools/pollinations/search_crawl_scrape.d.ts +7 -0
  88. package/dist/tools/pollinations/search_crawl_scrape.js +85 -0
  89. package/dist/tools/pollinations/shared.d.ts +181 -0
  90. package/dist/tools/pollinations/shared.js +758 -0
  91. package/dist/tools/pollinations/test_estimators.d.ts +1 -0
  92. package/dist/tools/pollinations/test_estimators.js +22 -0
  93. package/dist/tools/pollinations/transcribe_audio.d.ts +13 -0
  94. package/dist/tools/pollinations/transcribe_audio.js +171 -0
  95. package/dist/tools/power/extract_audio.d.ts +2 -0
  96. package/dist/tools/power/extract_audio.js +179 -0
  97. package/dist/tools/power/extract_frames.d.ts +2 -0
  98. package/dist/tools/power/extract_frames.js +237 -0
  99. package/dist/tools/power/file_to_url.d.ts +2 -0
  100. package/dist/tools/power/file_to_url.js +217 -0
  101. package/dist/tools/power/remove_background.d.ts +2 -0
  102. package/dist/tools/power/remove_background.js +404 -0
  103. package/dist/tools/power/rmbg_keys.d.ts +2 -0
  104. package/dist/tools/power/rmbg_keys.js +79 -0
  105. package/dist/tools/shared.d.ts +30 -0
  106. package/dist/tools/shared.js +80 -0
  107. package/package.json +10 -4
  108. package/dist/server/models-seed.d.ts +0 -18
  109. 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,404 @@
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
+ import { sanitizeFilename } from '../pollinations/shared.js';
8
+ import { getConfigDir } from '../../server/config.js';
9
+ // ─── Provider Defaults ───────────────────────────────────────────────────────
10
+ const CUT_API_URL = 'https://cut.esprit-artificiel.com';
11
+ const BACKGROUNDCUT_API_URL = 'https://backgroundcut.co/api/v1/cut/';
12
+ const HMAC_SECRET = "super_secret_community_key_2026"; // Sel caché dans le code transpilé
13
+ // ─── Key Storage ─────────────────────────────────────────────────────────────
14
+ const KEYS_FILE = path.join(getConfigDir(), 'backgroundcut_keys.json');
15
+ function loadKeys() {
16
+ try {
17
+ if (fs.existsSync(KEYS_FILE)) {
18
+ return JSON.parse(fs.readFileSync(KEYS_FILE, 'utf-8'));
19
+ }
20
+ }
21
+ catch (e) {
22
+ console.error(`[BackgroundCut] Error loading keys: ${e}`);
23
+ }
24
+ return { keys: [], currentIndex: 0 };
25
+ }
26
+ function getRotatedKeys() {
27
+ const store = loadKeys();
28
+ if (store.keys.length === 0)
29
+ return [];
30
+ // Return keys starting from currentIndex looping back to start
31
+ const before = store.keys.slice(store.currentIndex);
32
+ const after = store.keys.slice(0, store.currentIndex);
33
+ return [...before, ...after];
34
+ }
35
+ function advanceKeyIndex() {
36
+ const store = loadKeys();
37
+ if (store.keys.length === 0)
38
+ return;
39
+ store.currentIndex = (store.currentIndex + 1) % store.keys.length;
40
+ try {
41
+ const dir = path.dirname(KEYS_FILE);
42
+ if (!fs.existsSync(dir))
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ fs.writeFileSync(KEYS_FILE, JSON.stringify(store, null, 2));
45
+ }
46
+ catch (e) {
47
+ console.error(`[BackgroundCut] Error saving key rotation: ${e}`);
48
+ }
49
+ }
50
+ // ─── HTTP Helpers ────────────────────────────────────────────────────────────
51
+ function httpRequest(url, options, body) {
52
+ return new Promise((resolve, reject) => {
53
+ const req = https.request(url, options, (res) => {
54
+ const chunks = [];
55
+ res.on('data', (chunk) => chunks.push(chunk));
56
+ res.on('end', () => {
57
+ const responseBody = Buffer.concat(chunks);
58
+ const statusCode = res.statusCode || 500;
59
+ let json;
60
+ try {
61
+ json = JSON.parse(responseBody.toString());
62
+ }
63
+ catch { }
64
+ resolve({ statusCode, body: responseBody, json });
65
+ });
66
+ });
67
+ req.on('error', reject);
68
+ req.setTimeout(60000, () => { req.destroy(); reject(new Error('Request timeout (60s)')); });
69
+ if (body)
70
+ req.write(body);
71
+ req.end();
72
+ });
73
+ }
74
+ function downloadFile(url) {
75
+ return new Promise((resolve, reject) => {
76
+ const protocol = url.startsWith('https') ? https : require('http');
77
+ protocol.get(url, (res) => {
78
+ // Handle redirects
79
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
80
+ return downloadFile(res.headers.location).then(resolve).catch(reject);
81
+ }
82
+ const chunks = [];
83
+ res.on('data', (c) => chunks.push(c));
84
+ res.on('end', () => resolve(Buffer.concat(chunks)));
85
+ }).on('error', reject);
86
+ });
87
+ }
88
+ // ─── Helper: Get Image Size (Linux/Unix 'file' command) ─────────────────────
89
+ function getImageSize(filePath) {
90
+ try {
91
+ const { spawnSync } = require('child_process');
92
+ const result = spawnSync('file', ['-b', filePath], { encoding: 'utf-8', shell: false });
93
+ if (result.stdout) {
94
+ // Regex for "IDAT, 800 x 600," or ", 800 x 600,"
95
+ const match = result.stdout.match(/, (\d+) ?x ?(\d+),/);
96
+ if (match) {
97
+ return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
98
+ }
99
+ }
100
+ }
101
+ catch (e) {
102
+ // Fallback or silence
103
+ }
104
+ return null;
105
+ }
106
+ // ─── Provider: cut.esprit-artificiel.com (returns binary PNG directly) ────────
107
+ async function removeViaCut(imageData, filename, mimeType) {
108
+ const boundary = `----FormBoundary${Date.now()}`;
109
+ const parts = [];
110
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
111
+ parts.push(imageData);
112
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
113
+ const body = Buffer.concat(parts);
114
+ const url = new URL(`${CUT_API_URL}/remove-bg`);
115
+ const headers = {
116
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
117
+ 'Content-Length': body.length,
118
+ 'User-Agent': 'OpenCode-Pollinations-Plugin/6.1',
119
+ };
120
+ // 1. Vérifier si l'utilisateur (Franck) a configuré une clé VIP localement
121
+ let vipKey = null;
122
+ try {
123
+ const vipPath = path.join(getConfigDir(), 'cut_vip.json');
124
+ if (fs.existsSync(vipPath)) {
125
+ const data = JSON.parse(fs.readFileSync(vipPath, 'utf-8'));
126
+ if (data.vip_key)
127
+ vipKey = data.vip_key;
128
+ }
129
+ }
130
+ catch (e) {
131
+ console.error(`[Cut VIP] Error loading vip key: ${e}`);
132
+ }
133
+ if (vipKey) {
134
+ // Mode Fast-Lane (VIP) : la requête passe directement en tête de file
135
+ headers['X-Api-Key'] = vipKey;
136
+ }
137
+ else {
138
+ // Mode Communauté (Plugin public) : Génération de la signature dynamique courte durée (Anti-leech)
139
+ const timestamp = Date.now().toString();
140
+ const payloadToSign = `request-rembg-v1:${timestamp}`;
141
+ const signature = require('crypto')
142
+ .createHmac('sha256', HMAC_SECRET)
143
+ .update(payloadToSign)
144
+ .digest('hex');
145
+ headers['X-Cut-Timestamp'] = timestamp;
146
+ headers['Authorization'] = `Bearer community:${signature}`;
147
+ }
148
+ const res = await httpRequest(url.toString(), {
149
+ method: 'POST',
150
+ headers
151
+ }, body);
152
+ if (res.statusCode >= 400) {
153
+ throw new Error(`CUT API Error ${res.statusCode}: ${res.body.toString().substring(0, 200)}`);
154
+ }
155
+ return res.body;
156
+ }
157
+ // ─── Provider: BackgroundCut.co (returns JSON with output_image_url) ─────────
158
+ async function removeViaBackgroundCut(imageData, filename, mimeType, apiKey, quality = 'medium', returnFormat = 'png', maxResolution) {
159
+ const boundary = `----FormBoundary${Date.now()}`;
160
+ const parts = [];
161
+ // File field
162
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
163
+ parts.push(imageData);
164
+ // Quality
165
+ parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="quality"\r\n\r\n${quality}`));
166
+ // Return format
167
+ parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="return_format"\r\n\r\n${returnFormat.toUpperCase()}`));
168
+ // Max resolution
169
+ if (maxResolution) {
170
+ parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="max_resolution"\r\n\r\n${maxResolution}`));
171
+ }
172
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
173
+ const body = Buffer.concat(parts);
174
+ const res = await httpRequest(BACKGROUNDCUT_API_URL, {
175
+ method: 'POST',
176
+ headers: {
177
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
178
+ 'Content-Length': body.length,
179
+ 'Authorization': `Token ${apiKey}`,
180
+ 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0',
181
+ },
182
+ }, body);
183
+ // Error handling with specific codes
184
+ if (res.statusCode === 401)
185
+ throw new Error('BGCUT_401:Invalid or missing API key');
186
+ if (res.statusCode === 402)
187
+ throw new Error('BGCUT_402:No credits remaining');
188
+ if (res.statusCode === 429)
189
+ throw new Error('BGCUT_429:Rate limit exceeded');
190
+ if (res.statusCode === 413)
191
+ throw new Error('BGCUT_413:File too large (max 12MB)');
192
+ if (res.statusCode >= 400) {
193
+ const msg = res.json?.error || res.body.toString().substring(0, 200);
194
+ throw new Error(`BGCUT_${res.statusCode}:${msg}`);
195
+ }
196
+ // Download the result image from the URL
197
+ const outputUrl = res.json?.output_image_url;
198
+ if (!outputUrl)
199
+ throw new Error('BackgroundCut returned no output URL');
200
+ return await downloadFile(outputUrl);
201
+ }
202
+ // ─── Main Tool ───────────────────────────────────────────────────────────────
203
+ export const removeBackgroundTool = tool({
204
+ description: `Remove the background from an image, producing a transparent PNG or WebP.
205
+
206
+ **Providers:**
207
+ - \`cut\` (default free) — Built-in u2netp AI. Slower. Ignores quality/format/resolution.
208
+ - \`backgroundcut\` — Premium API. Requires API key. Supports all parameters.
209
+
210
+ **Setup:** Use \`rmbg_keys\` tool to manage API keys.
211
+ **Auto mode:** Uses BackgroundCut if key is available, falls back to cut.`,
212
+ args: {
213
+ image_path: tool.schema.string().describe('Absolute path to the image file'),
214
+ filename: tool.schema.string().optional().describe('Custom output filename (e.g. "my_image.png") or name without extension'),
215
+ output_path: tool.schema.string().optional().describe('Custom output directory. Default: ~/Downloads/pollinations/rembg/'),
216
+ provider: tool.schema.string().optional().describe('Provider: "auto" (default), "cut" (free), or "backgroundcut" (premium)'), // Removed .enum() as it caused errors
217
+ api_key: tool.schema.string().optional().describe('BackgroundCut API key (overrides stored keys)'),
218
+ quality: tool.schema.string().optional().describe('BackgroundCut only: "low", "medium" (default), "high"'),
219
+ return_format: tool.schema.string().optional().describe('BackgroundCut only: "png" (default), "webp"'),
220
+ max_resolution: tool.schema.number().optional().describe('BackgroundCut only: Max output resolution in pixels'),
221
+ },
222
+ async execute(args, context) {
223
+ // ── Validate input ──
224
+ if (!fs.existsSync(args.image_path)) {
225
+ return `❌ File not found: ${args.image_path}`;
226
+ }
227
+ const ext = path.extname(args.image_path).toLowerCase();
228
+ if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
229
+ return `❌ Unsupported format: ${ext}. Use PNG, JPEG, or WebP.`;
230
+ }
231
+ const inputStats = fs.statSync(args.image_path);
232
+ if (inputStats.size > 12 * 1024 * 1024) {
233
+ return `❌ File too large (${formatFileSize(inputStats.size)}). Max: 12MB`;
234
+ }
235
+ // ── Resolve provider ──
236
+ const provider = (args.provider || 'auto');
237
+ const quality = (args.quality || 'medium');
238
+ const returnFormat = (args.return_format || 'png');
239
+ const imageData = fs.readFileSync(args.image_path);
240
+ const mimeType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
241
+ const basename = path.basename(args.image_path);
242
+ // ─── Resolve API keys ──
243
+ let keysToCheck = [];
244
+ if (args.api_key) {
245
+ keysToCheck = [args.api_key];
246
+ }
247
+ else {
248
+ keysToCheck = getRotatedKeys();
249
+ }
250
+ // ── Determine initial provider strategy ──
251
+ // If specific provider requested, we stick to it (unless auto fallback)
252
+ // If auto, we try keys then fallback to cut
253
+ let effectiveProvider = provider;
254
+ if (provider === 'auto') {
255
+ // If we have keys, start with backgroundcut, else cut
256
+ effectiveProvider = keysToCheck.length > 0 ? 'backgroundcut' : 'cut';
257
+ }
258
+ // ── Info message when no BackgroundCut key ──
259
+ if (keysToCheck.length === 0 && (provider === 'auto' || provider === 'backgroundcut')) {
260
+ // console.log("No BackgroundCut key found. Using free provider."); // SILENCED
261
+ context.metadata({
262
+ title: "RMBG (Free)",
263
+ metadata: { type: 'info', message: "Mode Gratuit (Pas de clé détectée)" }
264
+ });
265
+ const noKeyMsg = [
266
+ `ℹ️ **No BackgroundCut API key configured** — using free provider (slower, rate-limited).`,
267
+ ``,
268
+ `🚀 **Want faster, higher-quality results?**`,
269
+ `1. Sign up at https://backgroundcut.co (5$ free credits, 60 days)`,
270
+ `2. Run: \`rmbg_keys action=add key=YOUR_API_KEY\``,
271
+ `3. Multiple keys supported — they rotate automatically!`,
272
+ ].join('\n');
273
+ if (provider === 'backgroundcut' && !args.api_key) {
274
+ return `❌ BackgroundCut provider selected but no API keys stored.\n\n${noKeyMsg}`;
275
+ }
276
+ // In auto mode, we continue with free provider
277
+ context.metadata({ title: `ℹ️ Using free provider (no BackgroundCut key)` });
278
+ }
279
+ // ── Resolve output filename ──
280
+ const outputDir = resolveOutputDir(TOOL_DIRS.rembg, args.output_path);
281
+ let finalFilename = '';
282
+ const targetExt = (returnFormat === 'webp' && effectiveProvider === 'backgroundcut') ? '.webp' : '.png';
283
+ if (args.filename) {
284
+ if (args.filename.toLowerCase().endsWith(targetExt)) {
285
+ finalFilename = args.filename;
286
+ }
287
+ else if (args.filename.match(/\.[a-z0-9]+$/i)) {
288
+ // Force proper extension
289
+ finalFilename = args.filename.replace(/\.[a-z0-9]+$/i, targetExt);
290
+ }
291
+ else {
292
+ finalFilename = `${args.filename}${targetExt}`;
293
+ }
294
+ }
295
+ else {
296
+ finalFilename = `${path.basename(args.image_path, ext)}_nobg${targetExt}`;
297
+ }
298
+ if (args.filename) {
299
+ finalFilename = sanitizeFilename(finalFilename);
300
+ }
301
+ const outputPath = path.join(outputDir, finalFilename);
302
+ const outputExt = path.extname(outputPath).replace('.', ''); // Fix for log display
303
+ // ── Execute ──
304
+ try {
305
+ let resultBuffer = null;
306
+ let usedProvider = 'cut'; // Default
307
+ let fallbackUsed = false;
308
+ let successKey = '';
309
+ // 1. Try BackgroundCut loop if applicable
310
+ if (effectiveProvider === 'backgroundcut' && keysToCheck.length > 0) {
311
+ emitStatusToast('info', `Démarrage: ${basename}`, '✂️ BackgroundCut');
312
+ for (const key of keysToCheck) {
313
+ try {
314
+ emitStatusToast('info', `Clé ${key.substring(0, 8)}...`, '>>> Rotation RMBG');
315
+ // console.log(`[RMBG] Trying key ${key.substring(0, 8)}...`); // SILENCED
316
+ resultBuffer = await removeViaBackgroundCut(imageData, basename, mimeType, key, quality, returnFormat, args.max_resolution);
317
+ // Success!
318
+ usedProvider = 'backgroundcut';
319
+ successKey = key;
320
+ // Advance index globally so next call uses next key (fair rotation)
321
+ advanceKeyIndex();
322
+ // Only set final metadata on success
323
+ context.metadata({
324
+ title: "RMBG (Premium)",
325
+ metadata: { type: 'success', message: "Détourage HD réussi (BackgroundCut)" }
326
+ });
327
+ break; // Exit loop on success
328
+ }
329
+ catch (err) {
330
+ const isFallbackable = err.message.startsWith('BGCUT_402') ||
331
+ err.message.startsWith('BGCUT_429') ||
332
+ err.message.startsWith('BGCUT_401');
333
+ if (isFallbackable) {
334
+ // console.log(`⚠️ Key ${key.substring(0, 8)} failed (${err.message}). Rotating...`); // SILENCED
335
+ continue; // Try next key
336
+ }
337
+ else {
338
+ throw err; // Fatal error (file size etc)
339
+ }
340
+ }
341
+ }
342
+ }
343
+ // 2. Fallback to Free Provider if no result yet
344
+ if (!resultBuffer) {
345
+ if (effectiveProvider === 'backgroundcut' && provider !== 'auto') {
346
+ throw new Error('All provided keys failed or are expired.');
347
+ }
348
+ if (effectiveProvider === 'backgroundcut') {
349
+ emitStatusToast('warning', 'Toutes les clés ont échoué. Mode Gratuit activé.', '⚠️ Fallback');
350
+ // console.log('⚠️ All BackgroundCut keys failed. Falling back to free provider.'); // SILENCED
351
+ fallbackUsed = true;
352
+ }
353
+ else {
354
+ emitStatusToast('info', `Détourage via API Gratuite: ${basename}`, '✂️ Free RMBG');
355
+ }
356
+ resultBuffer = await removeViaCut(imageData, basename, mimeType);
357
+ context.metadata({
358
+ title: "RMBG (Free)",
359
+ metadata: { type: 'success', message: "Détourage Standard réussi" }
360
+ });
361
+ usedProvider = 'cut (free)';
362
+ }
363
+ if (!resultBuffer || resultBuffer.length < 100) {
364
+ return `❌ Background removal returned invalid data.`;
365
+ }
366
+ const finalPath = path.resolve(outputPath);
367
+ fs.writeFileSync(finalPath, resultBuffer);
368
+ // Double check existence
369
+ if (!fs.existsSync(finalPath)) {
370
+ throw new Error(`File write failed at: ${finalPath}`);
371
+ }
372
+ const dims = getImageSize(finalPath);
373
+ const dimStr = dims ? `${dims.width}×${dims.height}` : 'N/A';
374
+ const lines = [
375
+ `✂️ Background Removed`,
376
+ `━━━━━━━━━━━━━━━━━━━━━`,
377
+ `Input: ${basename} (${formatFileSize(inputStats.size)})`,
378
+ `Output: ${finalPath}`,
379
+ `Size: ${formatFileSize(resultBuffer.length)}`,
380
+ `Dimensions: ${dimStr}`,
381
+ `Format: ${outputExt.toUpperCase()} (transparent)`,
382
+ `Provider: ${usedProvider}`,
383
+ `Cost: Free`,
384
+ ];
385
+ if (fallbackUsed) {
386
+ lines.push(``, `⚠️ BackgroundCut key may be expired/rate-limited. Check with \`rmbg_keys action=list\``);
387
+ }
388
+ // Add info message for users without key
389
+ if (keysToCheck.length === 0 && usedProvider.includes('free')) {
390
+ lines.push(``, `💡 Tip: Add a BackgroundCut key for faster HD results: \`rmbg_keys action=add key=...\``);
391
+ }
392
+ return lines.join('\n');
393
+ }
394
+ catch (err) {
395
+ if (err.message.includes('429') || err.message.includes('rate')) {
396
+ return `⏳ Rate limited. Please try again in 30 seconds.`;
397
+ }
398
+ if (err.message.startsWith('BGCUT_402')) {
399
+ return `💳 BackgroundCut: No credits remaining. Add a new key or wait for renewal.\nRun: \`rmbg_keys action=add key=...\``;
400
+ }
401
+ return `❌ Background Removal Error: ${err.message}`;
402
+ }
403
+ },
404
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const rmbgKeysTool: ToolDefinition;