opencode-pollinations-plugin 6.2.7 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +9 -1
- package/README.es.md +9 -1
- package/README.fr.md +9 -1
- package/README.it.md +9 -1
- package/README.md +35 -16
- package/README.zh.md +9 -1
- package/dist/index.js +11 -7
- package/dist/locales/de.json +94 -6
- package/dist/locales/en.json +94 -6
- package/dist/locales/es.json +94 -6
- package/dist/locales/fr.json +94 -6
- package/dist/locales/index.js +3 -1
- package/dist/locales/it.json +94 -6
- package/dist/locales/zh.json +461 -0
- package/dist/server/commands.d.ts +8 -0
- package/dist/server/commands.js +203 -13
- package/dist/server/connect-response.js +1 -1
- package/dist/server/generate-config.js +4 -6
- package/dist/server/models/cache.js +1 -1
- package/dist/server/models/fetcher.js +24 -2
- package/dist/server/quota.js +29 -8
- package/dist/server/tier-info.d.ts +9 -4
- package/dist/server/tier-info.js +29 -18
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +14 -1
- package/dist/tools/pollinations/beta_discovery.d.ts +11 -4
- package/dist/tools/pollinations/beta_discovery.js +288 -136
- package/dist/tools/pollinations/gen_edit_image_free.d.ts +14 -0
- package/dist/tools/pollinations/gen_edit_image_free.js +146 -0
- package/dist/tools/pollinations/gen_video.js +2 -2
- package/dist/tools/pollinations/gen_video_free.d.ts +19 -0
- package/dist/tools/pollinations/gen_video_free.js +246 -0
- package/dist/tools/pollinations/polli_config.js +1 -1
- package/dist/tools/pollinations/polli_login.d.ts +13 -0
- package/dist/tools/pollinations/polli_login.js +30 -0
- package/dist/tools/pollinations/polli_quests.d.ts +3 -0
- package/dist/tools/pollinations/polli_quests.js +135 -0
- package/package.json +2 -2
- package/dist/server/index.d.ts +0 -2
- package/dist/server/index.js +0 -158
- package/dist/server/scripts/test_cost_endpoints.d.ts +0 -1
- package/dist/server/scripts/test_cost_endpoints.js +0 -61
- package/dist/server/scripts/test_dynamic_pricing.d.ts +0 -1
- package/dist/server/scripts/test_dynamic_pricing.js +0 -39
- package/dist/server/scripts/test_freetier_audit.d.ts +0 -11
- package/dist/server/scripts/test_freetier_audit.js +0 -215
- package/dist/server/scripts/test_parallel_cost.d.ts +0 -1
- package/dist/server/scripts/test_parallel_cost.js +0 -104
- package/dist/tools/pollinations/deepsearch.d.ts +0 -7
- package/dist/tools/pollinations/deepsearch.js +0 -80
- package/dist/tools/pollinations/search_crawl_scrape.d.ts +0 -7
- package/dist/tools/pollinations/search_crawl_scrape.js +0 -85
- package/dist/tools/pollinations/test_estimators.d.ts +0 -1
- package/dist/tools/pollinations/test_estimators.js +0 -22
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gen_edit_image_free — Always-Free image generation & editing (BONUS tool)
|
|
3
|
+
*
|
|
4
|
+
* Independent FREE-bucket tool: works for ANY OpenCode model, with or WITHOUT a
|
|
5
|
+
* Pollinations API key. Acts as an "always free" fallback for image gen/edit,
|
|
6
|
+
* outside the Pollinations economy (no Pollen, no cost guard).
|
|
7
|
+
*
|
|
8
|
+
* Backed by a public image playground (reverse-engineered open endpoint).
|
|
9
|
+
* Direct-only: the request goes from the END USER's IP, respecting the
|
|
10
|
+
* playground's own 20-generations/IP/day free limit (gen + edit SHARE the same
|
|
11
|
+
* counter). Past the daily quota, prefer Pollinations models.
|
|
12
|
+
*/
|
|
13
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import { httpsGet, httpsPost, ensureDir, generateFilename, getDefaultOutputDir, formatFileSize, sanitizeFilename, } from './shared.js';
|
|
17
|
+
import { emitStatusToast } from '../../server/toast.js';
|
|
18
|
+
import { t } from '../../locales/index.js';
|
|
19
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
20
|
+
const PLAYGROUND = 'https://p-image-playground-production.up.railway.app';
|
|
21
|
+
const STATUS_URL = `${PLAYGROUND}/api/generation-status`;
|
|
22
|
+
const GEN_URL = `${PLAYGROUND}/api/generate-image`;
|
|
23
|
+
const EDIT_URL = `${PLAYGROUND}/api/generate-image-edit`;
|
|
24
|
+
const VALID_RATIOS = ['16:9', '1:1', '9:16'];
|
|
25
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
26
|
+
async function fetchQuota() {
|
|
27
|
+
try {
|
|
28
|
+
const res = await httpsGet(STATUS_URL);
|
|
29
|
+
return JSON.parse(res.data.toString());
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Normalize an image input into a data: URI.
|
|
37
|
+
* Accepts: data: URIs (passthrough), local file paths, http(s) URLs (downloaded).
|
|
38
|
+
*/
|
|
39
|
+
async function toDataUri(img) {
|
|
40
|
+
if (img.startsWith('data:'))
|
|
41
|
+
return img;
|
|
42
|
+
if (/^https?:\/\//i.test(img)) {
|
|
43
|
+
const res = await httpsGet(img);
|
|
44
|
+
const ext = (img.split('?')[0].split('.').pop() || 'jpeg').toLowerCase();
|
|
45
|
+
const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
|
|
46
|
+
return `data:${mime};base64,${res.data.toString('base64')}`;
|
|
47
|
+
}
|
|
48
|
+
if (fs.existsSync(img)) {
|
|
49
|
+
const ext = path.extname(img).toLowerCase().replace('.', '');
|
|
50
|
+
const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
|
|
51
|
+
return `data:${mime};base64,${fs.readFileSync(img).toString('base64')}`;
|
|
52
|
+
}
|
|
53
|
+
// Assume already raw base64
|
|
54
|
+
return img;
|
|
55
|
+
}
|
|
56
|
+
// ─── Tool Definition ──────────────────────────────────────────────────────
|
|
57
|
+
export const genEditImageFreeTool = tool({
|
|
58
|
+
description: t('tools.gen_edit_image_free.desc'),
|
|
59
|
+
args: {
|
|
60
|
+
prompt: tool.schema.string().describe(t('tools.gen_edit_image_free.arg_prompt')),
|
|
61
|
+
images: tool.schema.array(tool.schema.string()).optional()
|
|
62
|
+
.describe(t('tools.gen_edit_image_free.arg_images')),
|
|
63
|
+
aspect_ratio: tool.schema.enum(['16:9', '1:1', '9:16']).optional()
|
|
64
|
+
.describe(t('tools.gen_edit_image_free.arg_aspect')),
|
|
65
|
+
save_to: tool.schema.string().optional().describe(t('tools.gen_edit_image_free.arg_save_to')),
|
|
66
|
+
filename: tool.schema.string().optional().describe(t('tools.gen_edit_image_free.arg_filename')),
|
|
67
|
+
},
|
|
68
|
+
async execute(args, context) {
|
|
69
|
+
const isEdit = Array.isArray(args.images) && args.images.length > 0;
|
|
70
|
+
const mode = isEdit ? 'edit' : 'generate';
|
|
71
|
+
// Validate edit input count (playground supports 1-3)
|
|
72
|
+
if (isEdit && args.images.length > 3) {
|
|
73
|
+
return t('tools.gen_edit_image_free.too_many_images');
|
|
74
|
+
}
|
|
75
|
+
// 1. Check the per-IP daily quota (read-only, free)
|
|
76
|
+
const quota = await fetchQuota();
|
|
77
|
+
if (quota && !quota.canGenerate) {
|
|
78
|
+
return t('tools.gen_edit_image_free.quota_exhausted', { max: quota.max });
|
|
79
|
+
}
|
|
80
|
+
context.metadata({ title: `🆓 ${isEdit ? 'Edit' : 'Image'} (free)` });
|
|
81
|
+
emitStatusToast('info', t('tools.gen_edit_image_free.working', { mode }), '🆓 gen_edit_image_free');
|
|
82
|
+
try {
|
|
83
|
+
// 2. Build request and call the playground (from the user's IP)
|
|
84
|
+
let apiResp;
|
|
85
|
+
if (isEdit) {
|
|
86
|
+
const processed = await Promise.all(args.images.map(toDataUri));
|
|
87
|
+
const res = await httpsPost(EDIT_URL, { prompt: args.prompt, images: processed });
|
|
88
|
+
apiResp = JSON.parse(res.data.toString());
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const aspect = args.aspect_ratio && VALID_RATIOS.includes(args.aspect_ratio)
|
|
92
|
+
? args.aspect_ratio : '16:9';
|
|
93
|
+
const res = await httpsPost(GEN_URL, {
|
|
94
|
+
prompt: args.prompt,
|
|
95
|
+
aspect_ratio: aspect,
|
|
96
|
+
disable_safety_checker: false,
|
|
97
|
+
});
|
|
98
|
+
apiResp = JSON.parse(res.data.toString());
|
|
99
|
+
}
|
|
100
|
+
if (!apiResp || !apiResp.success || !apiResp.imageUrl) {
|
|
101
|
+
const reason = apiResp?.error || 'unknown';
|
|
102
|
+
emitStatusToast('error', t('tools.gen_edit_image_free.failed', { error: String(reason).substring(0, 60) }), '🆓 gen_edit_image_free');
|
|
103
|
+
return t('tools.gen_edit_image_free.api_error', { error: String(reason) });
|
|
104
|
+
}
|
|
105
|
+
// 3. Download the produced image and save it locally
|
|
106
|
+
const dl = await httpsGet(apiResp.imageUrl);
|
|
107
|
+
let outputDir = getDefaultOutputDir('images');
|
|
108
|
+
let filename = args.filename ? sanitizeFilename(args.filename) : undefined;
|
|
109
|
+
if (args.save_to) {
|
|
110
|
+
if (args.save_to.match(/\.(png|jpe?g|webp)$/i)) {
|
|
111
|
+
outputDir = path.dirname(args.save_to);
|
|
112
|
+
filename = path.basename(args.save_to);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
outputDir = args.save_to;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
ensureDir(outputDir);
|
|
119
|
+
filename = filename || generateFilename(isEdit ? 'edit' : 'image', 'free', 'jpg');
|
|
120
|
+
const filePath = path.join(outputDir, filename.includes('.') ? filename : `${filename}.jpg`);
|
|
121
|
+
fs.writeFileSync(filePath, dl.data);
|
|
122
|
+
// 4. Re-read quota for an accurate "remaining" figure
|
|
123
|
+
const after = await fetchQuota();
|
|
124
|
+
const fileSize = fs.statSync(filePath).size;
|
|
125
|
+
const lines = [];
|
|
126
|
+
lines.push(t(isEdit ? 'tools.gen_edit_image_free.res_title_edit' : 'tools.gen_edit_image_free.res_title_gen'));
|
|
127
|
+
lines.push('━━━━━━━━━━━━━━━━━━');
|
|
128
|
+
lines.push(t('tools.gen_edit_image_free.res_prompt', { prompt: args.prompt.substring(0, 100) }));
|
|
129
|
+
lines.push(t('tools.gen_edit_image_free.res_file', { path: filePath }));
|
|
130
|
+
lines.push(t('tools.gen_edit_image_free.res_size', { size: formatFileSize(fileSize) }));
|
|
131
|
+
if (after) {
|
|
132
|
+
lines.push(t('tools.gen_edit_image_free.res_quota', { remaining: after.remaining, max: after.max }));
|
|
133
|
+
}
|
|
134
|
+
lines.push('');
|
|
135
|
+
lines.push(t('tools.gen_edit_image_free.res_note'));
|
|
136
|
+
emitStatusToast('success', t('tools.gen_edit_image_free.success', { mode }), '🆓 gen_edit_image_free', { filePath });
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
const msg = err.message || String(err);
|
|
141
|
+
emitStatusToast('error', t('tools.gen_edit_image_free.failed', { error: msg.substring(0, 60) }), '🆓 gen_edit_image_free');
|
|
142
|
+
// Graceful degradation: explicitly point to Pollinations models
|
|
143
|
+
return t('tools.gen_edit_image_free.degraded', { error: msg.substring(0, 150) });
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
});
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { tool } from '@opencode-ai/plugin/tool';
|
|
16
16
|
import * as fs from 'fs';
|
|
17
17
|
import * as path from 'path';
|
|
18
|
-
import { getApiKey, httpsGet, ensureDir, generateFilename, getDefaultOutputDir, formatCost, formatFileSize, estimateVideoCost, extractCostFromHeaders, isCostEstimatorEnabled, supportsI2V, requiresI2V,
|
|
18
|
+
import { getApiKey, httpsGet, ensureDir, generateFilename, getDefaultOutputDir, formatCost, formatFileSize, estimateVideoCost, extractCostFromHeaders, isCostEstimatorEnabled, supportsI2V, requiresI2V, getDurationRange, getVideoModels, fetchEnterBalance, sanitizeFilename, validateHttpUrl, } from './shared.js';
|
|
19
19
|
import { loadConfig } from '../../server/config.js';
|
|
20
20
|
import { checkCostControl, isTokenBased } from './cost-guard.js';
|
|
21
21
|
import { emitStatusToast } from '../../server/toast.js';
|
|
@@ -58,7 +58,7 @@ export const polliGenVideoTool = tool({
|
|
|
58
58
|
return t('tools.polli_gen_video.invalid_duration', { model, duration, min: minDuration, max: maxDuration });
|
|
59
59
|
}
|
|
60
60
|
// Validate aspect ratio (for known models; beta models accept any)
|
|
61
|
-
if (!isBetaModel && !
|
|
61
|
+
if (!isBetaModel && !modelConfig.aspectRatios.includes(aspectRatio)) {
|
|
62
62
|
return t('tools.polli_gen_video.invalid_aspect', { model, aspect: aspectRatio, supported: modelConfig.aspectRatios.join(', ') });
|
|
63
63
|
}
|
|
64
64
|
// Check I2V requirements & validation
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gen_video_free — Always-Free video generation (BONUS tool)
|
|
3
|
+
*
|
|
4
|
+
* Independent FREE-bucket tool: works for ANY OpenCode model, with or WITHOUT a
|
|
5
|
+
* Pollinations API key. "Always free" video generation outside the Pollinations
|
|
6
|
+
* economy (no Pollen, no cost guard).
|
|
7
|
+
*
|
|
8
|
+
* Backed by a public Pruna playground (reverse-engineered open endpoint),
|
|
9
|
+
* direct-only from the END USER's IP, respecting the playground's own
|
|
10
|
+
* 5-generations/IP/day free limit (SEPARATE from the image 20/day counter).
|
|
11
|
+
*
|
|
12
|
+
* Flow (async): POST multipart /api/p-video/generate -> { id }
|
|
13
|
+
* poll GET /api/p-video/status/{id} -> { status, output }
|
|
14
|
+
* download `output` mp4.
|
|
15
|
+
*
|
|
16
|
+
* Supports optional image (first-frame / reference) and optional audio join.
|
|
17
|
+
*/
|
|
18
|
+
import { type ToolDefinition } from '@opencode-ai/plugin/tool';
|
|
19
|
+
export declare const genVideoFreeTool: ToolDefinition;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gen_video_free — Always-Free video generation (BONUS tool)
|
|
3
|
+
*
|
|
4
|
+
* Independent FREE-bucket tool: works for ANY OpenCode model, with or WITHOUT a
|
|
5
|
+
* Pollinations API key. "Always free" video generation outside the Pollinations
|
|
6
|
+
* economy (no Pollen, no cost guard).
|
|
7
|
+
*
|
|
8
|
+
* Backed by a public Pruna playground (reverse-engineered open endpoint),
|
|
9
|
+
* direct-only from the END USER's IP, respecting the playground's own
|
|
10
|
+
* 5-generations/IP/day free limit (SEPARATE from the image 20/day counter).
|
|
11
|
+
*
|
|
12
|
+
* Flow (async): POST multipart /api/p-video/generate -> { id }
|
|
13
|
+
* poll GET /api/p-video/status/{id} -> { status, output }
|
|
14
|
+
* download `output` mp4.
|
|
15
|
+
*
|
|
16
|
+
* Supports optional image (first-frame / reference) and optional audio join.
|
|
17
|
+
*/
|
|
18
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
19
|
+
import * as https from 'https';
|
|
20
|
+
import * as fs from 'fs';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
import { httpsGet, ensureDir, generateFilename, getDefaultOutputDir, formatFileSize, sanitizeFilename, } from './shared.js';
|
|
23
|
+
import { emitStatusToast } from '../../server/toast.js';
|
|
24
|
+
import { t } from '../../locales/index.js';
|
|
25
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
26
|
+
const PLAYGROUND = 'https://pruna-playground-production-861e.up.railway.app';
|
|
27
|
+
const STATUS_URL = `${PLAYGROUND}/api/generation-status?model=p-video`;
|
|
28
|
+
const GEN_URL = `${PLAYGROUND}/api/p-video/generate`;
|
|
29
|
+
const JOB_STATUS = (id) => `${PLAYGROUND}/api/p-video/status/${id}`;
|
|
30
|
+
const VALID_RATIOS = ['16:9', '9:16', '1:1'];
|
|
31
|
+
const VALID_RES = ['480p', '720p', '1080p'];
|
|
32
|
+
const POLL_INTERVAL_MS = 4000;
|
|
33
|
+
const POLL_MAX_ATTEMPTS = 75; // ~5 min ceiling
|
|
34
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
35
|
+
async function fetchQuota() {
|
|
36
|
+
try {
|
|
37
|
+
const res = await httpsGet(STATUS_URL);
|
|
38
|
+
return JSON.parse(res.data.toString());
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Resolve an image/audio input (local path | http(s) URL | data: URI) to a Buffer + mime + filename. */
|
|
45
|
+
async function resolveAsset(input, kind) {
|
|
46
|
+
// data: URI
|
|
47
|
+
const dataMatch = input.match(/^data:([^;]+);base64,(.+)$/);
|
|
48
|
+
if (dataMatch) {
|
|
49
|
+
const mime = dataMatch[1];
|
|
50
|
+
const ext = mime.split('/')[1] || (kind === 'image' ? 'jpeg' : 'mp3');
|
|
51
|
+
return { buf: Buffer.from(dataMatch[2], 'base64'), mime, filename: `${kind}.${ext}` };
|
|
52
|
+
}
|
|
53
|
+
// http(s) URL
|
|
54
|
+
if (/^https?:\/\//i.test(input)) {
|
|
55
|
+
const res = await httpsGet(input);
|
|
56
|
+
const ext = (input.split('?')[0].split('.').pop() || (kind === 'image' ? 'jpeg' : 'mp3')).toLowerCase();
|
|
57
|
+
return { buf: res.data, mime: mimeFor(ext, kind), filename: `${kind}.${ext}` };
|
|
58
|
+
}
|
|
59
|
+
// local file
|
|
60
|
+
if (fs.existsSync(input)) {
|
|
61
|
+
const ext = path.extname(input).toLowerCase().replace('.', '') || (kind === 'image' ? 'jpeg' : 'mp3');
|
|
62
|
+
return { buf: fs.readFileSync(input), mime: mimeFor(ext, kind), filename: path.basename(input) };
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Asset not found: ${input}`);
|
|
65
|
+
}
|
|
66
|
+
function mimeFor(ext, kind) {
|
|
67
|
+
const e = ext.toLowerCase();
|
|
68
|
+
if (kind === 'image') {
|
|
69
|
+
if (e === 'png')
|
|
70
|
+
return 'image/png';
|
|
71
|
+
if (e === 'webp')
|
|
72
|
+
return 'image/webp';
|
|
73
|
+
return 'image/jpeg';
|
|
74
|
+
}
|
|
75
|
+
if (e === 'wav')
|
|
76
|
+
return 'audio/wav';
|
|
77
|
+
if (e === 'ogg')
|
|
78
|
+
return 'audio/ogg';
|
|
79
|
+
return 'audio/mpeg';
|
|
80
|
+
}
|
|
81
|
+
/** Custom multipart POST: handles text fields AND typed file parts (image/audio). */
|
|
82
|
+
function multipartPost(url, textFields, files) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const parsed = new URL(url);
|
|
85
|
+
const boundary = `----OCFormBoundary${Date.now()}${Math.random().toString(16).slice(2)}`;
|
|
86
|
+
const parts = [];
|
|
87
|
+
for (const [k, v] of Object.entries(textFields)) {
|
|
88
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
|
89
|
+
}
|
|
90
|
+
for (const f of files) {
|
|
91
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${f.field}"; filename="${f.filename}"\r\nContent-Type: ${f.mime}\r\n\r\n`));
|
|
92
|
+
parts.push(f.buf);
|
|
93
|
+
parts.push(Buffer.from('\r\n'));
|
|
94
|
+
}
|
|
95
|
+
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
|
96
|
+
const body = Buffer.concat(parts);
|
|
97
|
+
const req = https.request({
|
|
98
|
+
hostname: parsed.hostname,
|
|
99
|
+
path: parsed.pathname + parsed.search,
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: {
|
|
102
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
103
|
+
'Content-Length': body.length,
|
|
104
|
+
'User-Agent': 'OpenCode-Pollinations-Plugin',
|
|
105
|
+
'Origin': PLAYGROUND,
|
|
106
|
+
},
|
|
107
|
+
}, (res) => {
|
|
108
|
+
const chunks = [];
|
|
109
|
+
res.on('data', (c) => chunks.push(c));
|
|
110
|
+
res.on('end', () => {
|
|
111
|
+
const txt = Buffer.concat(chunks).toString();
|
|
112
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
113
|
+
try {
|
|
114
|
+
resolve(JSON.parse(txt));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
reject(new Error('Bad JSON: ' + txt.slice(0, 150)));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
reject(new Error(`HTTP ${res.statusCode}: ${txt.slice(0, 150)}`));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
req.on('error', reject);
|
|
126
|
+
req.setTimeout(120000, () => { req.destroy(); reject(new Error('Upload timeout')); });
|
|
127
|
+
req.write(body);
|
|
128
|
+
req.end();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async function pollJob(id) {
|
|
132
|
+
for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) {
|
|
133
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
|
|
134
|
+
try {
|
|
135
|
+
const res = await httpsGet(JOB_STATUS(id));
|
|
136
|
+
const j = JSON.parse(res.data.toString());
|
|
137
|
+
if (j.status === 'succeeded' && j.output)
|
|
138
|
+
return j.output;
|
|
139
|
+
if (j.status === 'failed' || j.status === 'canceled' || j.error) {
|
|
140
|
+
throw new Error(j.error || `job ${j.status}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
// transient network error during polling: keep trying unless it's a job failure
|
|
145
|
+
if (/job (failed|canceled)/.test(e.message))
|
|
146
|
+
throw e;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
throw new Error('Polling timeout (video took too long)');
|
|
150
|
+
}
|
|
151
|
+
// ─── Tool Definition ──────────────────────────────────────────────────────
|
|
152
|
+
export const genVideoFreeTool = tool({
|
|
153
|
+
description: t('tools.gen_video_free.desc'),
|
|
154
|
+
args: {
|
|
155
|
+
prompt: tool.schema.string().describe(t('tools.gen_video_free.arg_prompt')),
|
|
156
|
+
image: tool.schema.string().optional().describe(t('tools.gen_video_free.arg_image')),
|
|
157
|
+
audio: tool.schema.string().optional().describe(t('tools.gen_video_free.arg_audio')),
|
|
158
|
+
duration: tool.schema.number().min(1).max(10).optional().describe(t('tools.gen_video_free.arg_duration')),
|
|
159
|
+
aspect_ratio: tool.schema.enum(['16:9', '9:16', '1:1']).optional().describe(t('tools.gen_video_free.arg_aspect')),
|
|
160
|
+
resolution: tool.schema.enum(['480p', '720p', '1080p']).optional().describe(t('tools.gen_video_free.arg_resolution')),
|
|
161
|
+
save_to: tool.schema.string().optional().describe(t('tools.gen_video_free.arg_save_to')),
|
|
162
|
+
filename: tool.schema.string().optional().describe(t('tools.gen_video_free.arg_filename')),
|
|
163
|
+
},
|
|
164
|
+
async execute(args, context) {
|
|
165
|
+
// 1. Quota check (read-only, free, separate 5/day counter)
|
|
166
|
+
const quota = await fetchQuota();
|
|
167
|
+
if (quota && !quota.canGenerate) {
|
|
168
|
+
return t('tools.gen_video_free.quota_exhausted', { max: quota.max });
|
|
169
|
+
}
|
|
170
|
+
context.metadata({ title: '🆓 Video (free)' });
|
|
171
|
+
emitStatusToast('info', t('tools.gen_video_free.working'), '🆓 gen_video_free');
|
|
172
|
+
try {
|
|
173
|
+
// 2. Build multipart payload (contract captured from the playground)
|
|
174
|
+
const aspect = args.aspect_ratio && VALID_RATIOS.includes(args.aspect_ratio) ? args.aspect_ratio : '16:9';
|
|
175
|
+
const resolution = args.resolution && VALID_RES.includes(args.resolution) ? args.resolution : '720p';
|
|
176
|
+
const duration = String(args.duration ?? 5);
|
|
177
|
+
const textFields = {
|
|
178
|
+
prompt: args.prompt,
|
|
179
|
+
duration,
|
|
180
|
+
aspect_ratio: aspect,
|
|
181
|
+
resolution,
|
|
182
|
+
fps: '24',
|
|
183
|
+
draft: 'false',
|
|
184
|
+
prompt_upsampling: 'true',
|
|
185
|
+
save_audio: 'true',
|
|
186
|
+
};
|
|
187
|
+
const files = [];
|
|
188
|
+
if (args.image) {
|
|
189
|
+
const a = await resolveAsset(args.image, 'image');
|
|
190
|
+
files.push({ field: 'image', buf: a.buf, mime: a.mime, filename: a.filename });
|
|
191
|
+
}
|
|
192
|
+
if (args.audio) {
|
|
193
|
+
const a = await resolveAsset(args.audio, 'audio');
|
|
194
|
+
files.push({ field: 'audio', buf: a.buf, mime: a.mime, filename: a.filename });
|
|
195
|
+
}
|
|
196
|
+
// 3. Launch job
|
|
197
|
+
const job = await multipartPost(GEN_URL, textFields, files);
|
|
198
|
+
if (!job || !job.id) {
|
|
199
|
+
return t('tools.gen_video_free.api_error', { error: JSON.stringify(job).slice(0, 120) });
|
|
200
|
+
}
|
|
201
|
+
// 4. Poll until ready
|
|
202
|
+
const videoUrl = await pollJob(job.id);
|
|
203
|
+
// 5. Download the mp4
|
|
204
|
+
const dl = await httpsGet(videoUrl);
|
|
205
|
+
let outputDir = getDefaultOutputDir('videos');
|
|
206
|
+
let filename = args.filename ? sanitizeFilename(args.filename) : undefined;
|
|
207
|
+
if (args.save_to) {
|
|
208
|
+
if (args.save_to.match(/\.(mp4|webm|mov)$/i)) {
|
|
209
|
+
outputDir = path.dirname(args.save_to);
|
|
210
|
+
filename = path.basename(args.save_to);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
outputDir = args.save_to;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
ensureDir(outputDir);
|
|
217
|
+
filename = filename || generateFilename('video', 'free', 'mp4');
|
|
218
|
+
const filePath = path.join(outputDir, filename.includes('.') ? filename : `${filename}.mp4`);
|
|
219
|
+
fs.writeFileSync(filePath, dl.data);
|
|
220
|
+
const after = await fetchQuota();
|
|
221
|
+
const fileSize = fs.statSync(filePath).size;
|
|
222
|
+
const lines = [];
|
|
223
|
+
lines.push(t('tools.gen_video_free.res_title'));
|
|
224
|
+
lines.push('━━━━━━━━━━━━━━━━━━');
|
|
225
|
+
lines.push(t('tools.gen_video_free.res_prompt', { prompt: args.prompt.substring(0, 100) }));
|
|
226
|
+
lines.push(t('tools.gen_video_free.res_params', { duration, resolution, aspect }));
|
|
227
|
+
if (args.image)
|
|
228
|
+
lines.push(t('tools.gen_video_free.res_image'));
|
|
229
|
+
if (args.audio)
|
|
230
|
+
lines.push(t('tools.gen_video_free.res_audio'));
|
|
231
|
+
lines.push(t('tools.gen_video_free.res_file', { path: filePath }));
|
|
232
|
+
lines.push(t('tools.gen_video_free.res_size', { size: formatFileSize(fileSize) }));
|
|
233
|
+
if (after)
|
|
234
|
+
lines.push(t('tools.gen_video_free.res_quota', { remaining: after.remaining, max: after.max }));
|
|
235
|
+
lines.push('');
|
|
236
|
+
lines.push(t('tools.gen_video_free.res_note'));
|
|
237
|
+
emitStatusToast('success', t('tools.gen_video_free.success'), '🆓 gen_video_free', { filePath });
|
|
238
|
+
return lines.join('\n');
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
const msg = err.message || String(err);
|
|
242
|
+
emitStatusToast('error', t('tools.gen_video_free.failed', { error: msg.substring(0, 60) }), '🆓 gen_video_free');
|
|
243
|
+
return t('tools.gen_video_free.degraded', { error: msg.substring(0, 150) });
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
});
|
|
@@ -36,7 +36,7 @@ Use 'action=update' to change these. NEVER confuse Chat Mode with Tools Protecti
|
|
|
36
36
|
costThreshold: tool.schema.number().optional().describe('Cost threshold in USD/🌼 above which confirmation is required'),
|
|
37
37
|
thresholdsTier: tool.schema.number().optional().describe('Warning threshold PERCENTAGE (e.g. 10 for 10%) for Free Tier.'),
|
|
38
38
|
thresholdsWallet: tool.schema.number().optional().describe('Warning threshold PERCENTAGE (e.g. 50 for 50%) for Wallet balance.'),
|
|
39
|
-
lang: tool.schema.enum(['en', 'fr', 'es', 'de', 'it']).optional().describe('Plugin language for commands and toasts (en, fr, es, de, it).')
|
|
39
|
+
lang: tool.schema.enum(['en', 'fr', 'es', 'de', 'it', 'zh']).optional().describe('Plugin language for commands and toasts (en, fr, es, de, it, zh).')
|
|
40
40
|
},
|
|
41
41
|
async execute(args, context) {
|
|
42
42
|
if (args.action === 'view') {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ToolDefinition } from '@opencode-ai/plugin/tool';
|
|
2
|
+
/**
|
|
3
|
+
* polli_login — Device-flow login as a TOOL (callable by ANY OpenCode model).
|
|
4
|
+
*
|
|
5
|
+
* Two-step pattern so the agent can both SHOW the code and KNOW when connected:
|
|
6
|
+
* 1. polli_login() -> returns code + URL (show it to the user)
|
|
7
|
+
* 2. polli_login(wait:true) -> attaches to the running poller and RETURNS
|
|
8
|
+
* "✅ connected" once the user authorizes.
|
|
9
|
+
*
|
|
10
|
+
* Shares the exact same logic as the `/poll login` command. The login is
|
|
11
|
+
* attributed to this app (embedded pk_) and never spends the developer's Pollen.
|
|
12
|
+
*/
|
|
13
|
+
export declare const polliLoginTool: ToolDefinition;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
2
|
+
import { startDeviceLogin, awaitDeviceLogin } from '../../server/commands.js';
|
|
3
|
+
import { t } from '../../locales/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* polli_login — Device-flow login as a TOOL (callable by ANY OpenCode model).
|
|
6
|
+
*
|
|
7
|
+
* Two-step pattern so the agent can both SHOW the code and KNOW when connected:
|
|
8
|
+
* 1. polli_login() -> returns code + URL (show it to the user)
|
|
9
|
+
* 2. polli_login(wait:true) -> attaches to the running poller and RETURNS
|
|
10
|
+
* "✅ connected" once the user authorizes.
|
|
11
|
+
*
|
|
12
|
+
* Shares the exact same logic as the `/poll login` command. The login is
|
|
13
|
+
* attributed to this app (embedded pk_) and never spends the developer's Pollen.
|
|
14
|
+
*/
|
|
15
|
+
export const polliLoginTool = tool({
|
|
16
|
+
description: t('tools.polli_login.desc'),
|
|
17
|
+
args: {
|
|
18
|
+
wait: tool.schema.boolean().optional()
|
|
19
|
+
.describe(t('tools.polli_login.arg_wait')),
|
|
20
|
+
},
|
|
21
|
+
async execute(args) {
|
|
22
|
+
// Wait mode: attach to an in-progress login and report the final outcome.
|
|
23
|
+
if (args.wait) {
|
|
24
|
+
return await awaitDeviceLogin();
|
|
25
|
+
}
|
|
26
|
+
// Start mode: kick off the device flow and return the code + URL to show.
|
|
27
|
+
const res = await startDeviceLogin();
|
|
28
|
+
return res.response || res.error || 'Login could not be started.';
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
2
|
+
import * as https from 'https';
|
|
3
|
+
import { loadConfig } from '../../server/config.js';
|
|
4
|
+
import { t } from '../../locales/index.js';
|
|
5
|
+
// ─── API ───────────────────────────────────────────────────────────────────
|
|
6
|
+
function fetchQuests(apiKey) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const req = https.request({
|
|
9
|
+
hostname: 'gen.pollinations.ai',
|
|
10
|
+
path: '/account/quests',
|
|
11
|
+
method: 'GET',
|
|
12
|
+
headers: {
|
|
13
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
14
|
+
'User-Agent': 'opencode-pollinations-plugin'
|
|
15
|
+
}
|
|
16
|
+
}, (res) => {
|
|
17
|
+
let data = '';
|
|
18
|
+
res.on('data', (c) => data += c);
|
|
19
|
+
res.on('end', () => {
|
|
20
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
21
|
+
reject(new Error(`API ${res.statusCode}: ${data.substring(0, 120)}`));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(data);
|
|
26
|
+
resolve(Array.isArray(parsed?.quests) ? parsed.quests : []);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
reject(new Error(`Parse error: ${e.message}`));
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
req.on('error', (e) => reject(new Error(`Network: ${e.message}`)));
|
|
34
|
+
req.setTimeout(10000, () => req.destroy(new Error('Timeout')));
|
|
35
|
+
req.end();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// ─── Category labels (emoji map) ────────────────────────────────────────────
|
|
39
|
+
const CATEGORY_EMOJI = {
|
|
40
|
+
setup: '🔧', grow: '🌱', build: '🏗️', contribute: '🤝', community: '💬', easteregg: '🥚'
|
|
41
|
+
};
|
|
42
|
+
// ─── Shared report builder (used by both the tool and the /poll quests command) ───
|
|
43
|
+
export async function buildQuestsReport(filter = 'all') {
|
|
44
|
+
const config = loadConfig();
|
|
45
|
+
if (!config.apiKey) {
|
|
46
|
+
return t('tools.polli_quests.no_key');
|
|
47
|
+
}
|
|
48
|
+
let quests;
|
|
49
|
+
try {
|
|
50
|
+
quests = await fetchQuests(config.apiKey);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
return t('tools.polli_quests.error', { error: e.message });
|
|
54
|
+
}
|
|
55
|
+
if (quests.length === 0) {
|
|
56
|
+
return t('tools.polli_quests.empty');
|
|
57
|
+
}
|
|
58
|
+
// Compute claimable (earned but not yet claimed) total
|
|
59
|
+
let claimablePollen = 0;
|
|
60
|
+
let claimableCount = 0;
|
|
61
|
+
for (const q of quests) {
|
|
62
|
+
if (q.reward && !q.reward.claimedAt) {
|
|
63
|
+
claimablePollen += q.reward.pollenAmount || 0;
|
|
64
|
+
claimableCount++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const lines = [];
|
|
68
|
+
lines.push(t('tools.polli_quests.title'));
|
|
69
|
+
lines.push('');
|
|
70
|
+
// Highlight claimable Pollen (the porter message)
|
|
71
|
+
if (claimableCount > 0) {
|
|
72
|
+
lines.push(t('tools.polli_quests.claimable', {
|
|
73
|
+
count: claimableCount,
|
|
74
|
+
pollen: claimablePollen.toFixed(2)
|
|
75
|
+
}));
|
|
76
|
+
lines.push('');
|
|
77
|
+
}
|
|
78
|
+
if (filter === 'claimable') {
|
|
79
|
+
const claimables = quests.filter(q => q.reward && !q.reward.claimedAt);
|
|
80
|
+
if (claimables.length === 0)
|
|
81
|
+
return t('tools.polli_quests.none_claimable');
|
|
82
|
+
for (const q of claimables) {
|
|
83
|
+
lines.push(`- 🎁 **${q.title}** — ${(q.reward.pollenAmount).toFixed(2)} 🌻 (${q.balanceBucket})`);
|
|
84
|
+
}
|
|
85
|
+
lines.push('');
|
|
86
|
+
lines.push(t('tools.polli_quests.claim_hint'));
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
89
|
+
// Group by category
|
|
90
|
+
const byCat = new Map();
|
|
91
|
+
for (const q of quests) {
|
|
92
|
+
if (filter === 'available' && q.state !== 'available')
|
|
93
|
+
continue;
|
|
94
|
+
if (!byCat.has(q.category))
|
|
95
|
+
byCat.set(q.category, []);
|
|
96
|
+
byCat.get(q.category).push(q);
|
|
97
|
+
}
|
|
98
|
+
for (const [cat, items] of byCat) {
|
|
99
|
+
const emoji = CATEGORY_EMOJI[cat] || '•';
|
|
100
|
+
lines.push(`### ${emoji} ${cat.charAt(0).toUpperCase() + cat.slice(1)}`);
|
|
101
|
+
for (const q of items) {
|
|
102
|
+
let mark;
|
|
103
|
+
if (q.reward && !q.reward.claimedAt)
|
|
104
|
+
mark = '🎁'; // earned, unclaimed
|
|
105
|
+
else if (q.reward && q.reward.claimedAt)
|
|
106
|
+
mark = '✅'; // claimed
|
|
107
|
+
else if (q.state === 'completed')
|
|
108
|
+
mark = '✔️';
|
|
109
|
+
else if (q.state === 'coming_soon')
|
|
110
|
+
mark = '🔜';
|
|
111
|
+
else
|
|
112
|
+
mark = '⬜'; // available, not done
|
|
113
|
+
const reward = q.rewardAmount > 0 ? ` (+${q.rewardAmount} 🌻)` : '';
|
|
114
|
+
lines.push(`- ${mark} ${q.title}${reward}`);
|
|
115
|
+
}
|
|
116
|
+
lines.push('');
|
|
117
|
+
}
|
|
118
|
+
lines.push(t('tools.polli_quests.legend'));
|
|
119
|
+
if (claimableCount > 0) {
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push(t('tools.polli_quests.claim_hint'));
|
|
122
|
+
}
|
|
123
|
+
return lines.join('\n');
|
|
124
|
+
}
|
|
125
|
+
// ─── Tool ────────────────────────────────────────────────────────────────
|
|
126
|
+
export const polliQuestsTool = tool({
|
|
127
|
+
description: t('tools.polli_quests.desc'),
|
|
128
|
+
args: {
|
|
129
|
+
filter: tool.schema.enum(['all', 'available', 'claimable']).optional()
|
|
130
|
+
.describe(t('tools.polli_quests.arg_filter'))
|
|
131
|
+
},
|
|
132
|
+
async execute(args) {
|
|
133
|
+
return buildQuestsReport(args.filter || 'all');
|
|
134
|
+
}
|
|
135
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-pollinations-plugin",
|
|
3
3
|
"displayName": "Pollinations",
|
|
4
|
-
"version": "6.
|
|
4
|
+
"version": "6.3.0",
|
|
5
5
|
"description": "Native Pollinations.ai Provider Plugin for OpenCode",
|
|
6
6
|
"publisher": "pollinations",
|
|
7
7
|
"repository": {
|
|
@@ -61,4 +61,4 @@
|
|
|
61
61
|
"@types/qrcode": "^1.5.6",
|
|
62
62
|
"typescript": "^5.0.0"
|
|
63
63
|
}
|
|
64
|
-
}
|
|
64
|
+
}
|
package/dist/server/index.d.ts
DELETED