opencode-pollinations-plugin 6.4.9 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.de.md +67 -54
  2. package/README.es.md +79 -66
  3. package/README.fr.md +70 -57
  4. package/README.it.md +78 -65
  5. package/README.md +33 -29
  6. package/README.zh.md +77 -64
  7. package/dist/locales/de.json +82 -47
  8. package/dist/locales/en.json +84 -49
  9. package/dist/locales/es.json +82 -47
  10. package/dist/locales/fr.json +81 -46
  11. package/dist/locales/it.json +82 -47
  12. package/dist/locales/zh.json +82 -47
  13. package/dist/server/commands.js +118 -178
  14. package/dist/server/config.d.ts +22 -6
  15. package/dist/server/config.js +45 -5
  16. package/dist/server/connect-response.js +7 -7
  17. package/dist/server/generate-config.js +2 -2
  18. package/dist/server/models/cache.d.ts +23 -10
  19. package/dist/server/models/cache.js +46 -24
  20. package/dist/server/models/fetcher.js +2 -0
  21. package/dist/server/models/types.d.ts +2 -0
  22. package/dist/server/models/worker.js +4 -4
  23. package/dist/server/proxy.d.ts +6 -0
  24. package/dist/server/proxy.js +318 -218
  25. package/dist/server/quota.d.ts +23 -32
  26. package/dist/server/quota.js +44 -184
  27. package/dist/server/scripts/pollinations_pricing.js +6 -3
  28. package/dist/server/status.js +1 -2
  29. package/dist/server/toast.js +1 -1
  30. package/dist/tools/index.d.ts +2 -1
  31. package/dist/tools/index.js +3 -1
  32. package/dist/tools/pollinations/artifact-core.d.ts +53 -0
  33. package/dist/tools/pollinations/artifact-core.js +159 -0
  34. package/dist/tools/pollinations/beta_discovery.js +2 -1
  35. package/dist/tools/pollinations/cost-guard.d.ts +2 -2
  36. package/dist/tools/pollinations/error-parser.d.ts +38 -0
  37. package/dist/tools/pollinations/error-parser.js +112 -0
  38. package/dist/tools/pollinations/gen_3d.d.ts +17 -0
  39. package/dist/tools/pollinations/gen_3d.js +207 -0
  40. package/dist/tools/pollinations/gen_image.js +29 -10
  41. package/dist/tools/pollinations/gen_music.js +3 -2
  42. package/dist/tools/pollinations/gen_video.js +13 -2
  43. package/dist/tools/pollinations/polli_config.js +15 -21
  44. package/dist/tools/pollinations/polli_gen_confirm.js +2 -0
  45. package/dist/tools/pollinations/shared.d.ts +1 -1
  46. package/dist/tools/pollinations/shared.js +53 -142
  47. package/dist/tools/pollinations/timeout-policy.d.ts +80 -0
  48. package/dist/tools/pollinations/timeout-policy.js +124 -0
  49. package/dist/tools/pollinations/tool-capability-registry.d.ts +51 -0
  50. package/dist/tools/pollinations/tool-capability-registry.js +215 -0
  51. package/dist/tools/pollinations/transcribe_audio.js +5 -24
  52. package/package.json +64 -62
  53. package/dist/server/tier-info.d.ts +0 -36
  54. package/dist/server/tier-info.js +0 -107
@@ -36,7 +36,8 @@ async function fetchOpenApiSchema() {
36
36
  if (cachedSchema)
37
37
  return cachedSchema;
38
38
  try {
39
- const response = await fetch(OPENAPI_URL);
39
+ // v6.5: bound the OpenAPI fetch (was unbounded → hang risk).
40
+ const response = await fetch(OPENAPI_URL, { signal: AbortSignal.timeout(10000) });
40
41
  if (!response.ok)
41
42
  throw new Error(`HTTP ${response.status}`);
42
43
  cachedSchema = await response.json();
@@ -24,7 +24,7 @@ export interface PendingRequest {
24
24
  export declare function savePendingRequest(req: PendingRequest): void;
25
25
  export declare function getPendingRequest(id: string): PendingRequest | null;
26
26
  export declare function removePendingRequest(id: string): void;
27
- export declare function isTokenBased(category: 'image' | 'video' | 'audio' | 'text', modelName: string): boolean;
27
+ export declare function isTokenBased(category: 'image' | 'video' | 'audio' | 'text' | '3d', modelName: string): boolean;
28
28
  /**
29
29
  * Check if a generation should proceed based on cost control settings.
30
30
  *
@@ -35,4 +35,4 @@ export declare function isTokenBased(category: 'image' | 'video' | 'audio' | 'te
35
35
  * @param category - The model category ('image' | 'video' | 'audio')
36
36
  * @returns CostCheckResult
37
37
  */
38
- export declare function checkCostControl(toolName: string, args: any, modelName: string, estimatedCost: number, category?: 'image' | 'video' | 'audio'): CostCheckResult;
38
+ export declare function checkCostControl(toolName: string, args: any, modelName: string, estimatedCost: number, category?: 'image' | 'video' | 'audio' | '3d'): CostCheckResult;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Structured error parsing (v6.5) — single parser for Pollinations error
3
+ * envelopes instead of `includes('402')` scattered across tools.
4
+ *
5
+ * Live envelope (Phase 2 T11):
6
+ * { "success": false, "code": "BAD_REQUEST", "timestamp": "...",
7
+ * "details": { "name": "UpstreamError", "upstreamStatus": 400,
8
+ * "upstreamHost": "...", "upstreamBody": "..." } }
9
+ *
10
+ * Safety: `upstreamHost` / `upstreamBody` reveal the real backend and are
11
+ * NEVER exposed to the user — kept only in the sanitized debug fields.
12
+ */
13
+ export type PolliErrorKind = 'payment' | 'auth' | 'rate_limit' | 'bad_request' | 'not_found' | 'upstream' | 'timeout' | 'network' | 'unknown';
14
+ export interface ParsedPolliError {
15
+ kind: PolliErrorKind;
16
+ code?: string;
17
+ status?: number;
18
+ message: string;
19
+ /** Sanitized debug info (backend host/body redacted). */
20
+ debug?: {
21
+ upstreamStatus?: number;
22
+ upstreamHost?: string;
23
+ upstreamBodyTruncated?: string;
24
+ };
25
+ }
26
+ /** Map an HTTP status code to an error kind. */
27
+ export declare function kindForStatus(status: number): PolliErrorKind;
28
+ /**
29
+ * Parse an error body string (or object) into a structured error.
30
+ * Handles the Pollinations envelope, OpenAI-style {error:{message,code}},
31
+ * and plain text. upstreamHost/upstreamBody are never exposed in `message`.
32
+ */
33
+ export declare function parsePolliError(body: string | Record<string, any> | null | undefined, status?: number): ParsedPolliError;
34
+ /**
35
+ * Parse a thrown error (Error object or string) into a structured error.
36
+ * Detects the envelope inside messages like "HTTP 402: {...json...}".
37
+ */
38
+ export declare function parsePolliErrorFromThrow(error: unknown): ParsedPolliError;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Structured error parsing (v6.5) — single parser for Pollinations error
3
+ * envelopes instead of `includes('402')` scattered across tools.
4
+ *
5
+ * Live envelope (Phase 2 T11):
6
+ * { "success": false, "code": "BAD_REQUEST", "timestamp": "...",
7
+ * "details": { "name": "UpstreamError", "upstreamStatus": 400,
8
+ * "upstreamHost": "...", "upstreamBody": "..." } }
9
+ *
10
+ * Safety: `upstreamHost` / `upstreamBody` reveal the real backend and are
11
+ * NEVER exposed to the user — kept only in the sanitized debug fields.
12
+ */
13
+ /** Map an HTTP status code to an error kind. */
14
+ export function kindForStatus(status) {
15
+ if (status === 402)
16
+ return 'payment';
17
+ if (status === 401 || status === 403)
18
+ return 'auth';
19
+ if (status === 429)
20
+ return 'rate_limit';
21
+ if (status === 400 || status === 422)
22
+ return 'bad_request';
23
+ if (status === 404)
24
+ return 'not_found';
25
+ if (status >= 500)
26
+ return 'upstream';
27
+ return 'unknown';
28
+ }
29
+ /**
30
+ * Parse an error body string (or object) into a structured error.
31
+ * Handles the Pollinations envelope, OpenAI-style {error:{message,code}},
32
+ * and plain text. upstreamHost/upstreamBody are never exposed in `message`.
33
+ */
34
+ export function parsePolliError(body, status) {
35
+ const kind = status !== undefined && status !== 0 ? kindForStatus(status) : 'unknown';
36
+ if (body === null || body === undefined || body === '') {
37
+ return { kind, status, message: `HTTP ${status ?? 'error'}` };
38
+ }
39
+ let parsed = null;
40
+ if (typeof body === 'string') {
41
+ try {
42
+ parsed = JSON.parse(body);
43
+ }
44
+ catch {
45
+ parsed = null;
46
+ }
47
+ }
48
+ else {
49
+ parsed = body;
50
+ }
51
+ if (parsed && typeof parsed === 'object') {
52
+ // Pollinations envelope
53
+ if (parsed.success === false && (parsed.code || parsed.details)) {
54
+ const details = parsed.details || {};
55
+ const cleanMessage = typeof parsed.message === 'string'
56
+ ? parsed.message
57
+ : (parsed.code || 'Upstream error');
58
+ return {
59
+ kind,
60
+ code: parsed.code,
61
+ status,
62
+ message: `${cleanMessage}${details.upstreamStatus !== undefined ? ` (upstream ${details.upstreamStatus})` : ''}`,
63
+ debug: {
64
+ upstreamStatus: details.upstreamStatus,
65
+ upstreamHost: typeof details.upstreamHost === 'string' ? details.upstreamHost : undefined,
66
+ upstreamBodyTruncated: typeof details.upstreamBody === 'string' ? details.upstreamBody.slice(0, 200) : undefined,
67
+ },
68
+ };
69
+ }
70
+ // OpenAI-style
71
+ if (parsed.error && typeof parsed.error === 'object') {
72
+ return {
73
+ kind,
74
+ code: parsed.error.code,
75
+ status,
76
+ message: parsed.error.message || `HTTP ${status ?? 'error'}`,
77
+ };
78
+ }
79
+ if (typeof parsed.message === 'string') {
80
+ return { kind, status, message: parsed.message };
81
+ }
82
+ }
83
+ // Plain text
84
+ const text = typeof body === 'string' ? body.slice(0, 300) : JSON.stringify(body).slice(0, 300);
85
+ return { kind, status, message: text };
86
+ }
87
+ /**
88
+ * Parse a thrown error (Error object or string) into a structured error.
89
+ * Detects the envelope inside messages like "HTTP 402: {...json...}".
90
+ */
91
+ export function parsePolliErrorFromThrow(error) {
92
+ if (error instanceof Error) {
93
+ const msg = error.message || '';
94
+ // Timeout markers
95
+ if (/timeout/i.test(msg) && !/HTTP \d/.test(msg)) {
96
+ return { kind: 'timeout', message: msg };
97
+ }
98
+ // "HTTP 402: {envelope}" pattern from shared.ts httpsGet
99
+ const httpMatch = msg.match(/HTTP (\d{3})/);
100
+ if (httpMatch) {
101
+ const status = parseInt(httpMatch[1], 10);
102
+ const body = msg.slice(msg.indexOf(':', msg.indexOf(httpMatch[0])) + 1).trim();
103
+ const parsed = parsePolliError(body || null, status);
104
+ return parsed;
105
+ }
106
+ if (/Network Error|fetch failed|ECONN|ETIMEDOUT/i.test(msg)) {
107
+ return { kind: 'network', message: msg };
108
+ }
109
+ return { kind: 'unknown', message: msg };
110
+ }
111
+ return { kind: 'unknown', message: String(error) };
112
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * polli_gen_3d Tool — Pollinations 3D Generation (v6.5)
3
+ *
4
+ * Models (live-validated Phase 2):
5
+ * - trellis-2 — default, ~0.24 pollen (low), GLB, LONG_BLOCKING
6
+ * - hyper3d-rodin — paid_only, ~0.10 pollen, GLB, LONG_BLOCKING
7
+ *
8
+ * Endpoint: GET /3d/{prompt}?model=...&resolution=...&image=<url>&seed=...
9
+ * Artifact: GLB (glTF-binary) — validated via magic bytes, never written
10
+ * with a wrong extension.
11
+ *
12
+ * Retry policy: after a client timeout the generation may STILL be running
13
+ * and billed upstream. We never auto-resubmit; we return recovery metadata
14
+ * (same request = cache hit, not rebilled).
15
+ */
16
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
17
+ export declare const polliGen3dTool: ToolDefinition;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * polli_gen_3d Tool — Pollinations 3D Generation (v6.5)
3
+ *
4
+ * Models (live-validated Phase 2):
5
+ * - trellis-2 — default, ~0.24 pollen (low), GLB, LONG_BLOCKING
6
+ * - hyper3d-rodin — paid_only, ~0.10 pollen, GLB, LONG_BLOCKING
7
+ *
8
+ * Endpoint: GET /3d/{prompt}?model=...&resolution=...&image=<url>&seed=...
9
+ * Artifact: GLB (glTF-binary) — validated via magic bytes, never written
10
+ * with a wrong extension.
11
+ *
12
+ * Retry policy: after a client timeout the generation may STILL be running
13
+ * and billed upstream. We never auto-resubmit; we return recovery metadata
14
+ * (same request = cache hit, not rebilled).
15
+ */
16
+ import { tool } from '@opencode-ai/plugin/tool';
17
+ import * as path from 'path';
18
+ import { getApiKey, hasApiKey, httpsGet, ensureDir, generateFilename, getDefaultOutputDir, formatCost, formatFileSize, extractCostFromHeaders, isCostEstimatorEnabled, fetchEnterBalance, sanitizeFilename, validateHttpUrl, } from './shared.js';
19
+ import { ModelRegistry } from '../../server/models/index.js';
20
+ import { checkCostControl } from './cost-guard.js';
21
+ import { detectArtifactType, persistArtifact } from './artifact-core.js';
22
+ import { resolveCapabilityTimeout } from './tool-capability-registry.js';
23
+ import { validateTimeoutSeconds } from './timeout-policy.js';
24
+ import { loadConfig } from '../../server/config.js';
25
+ import { emitStatusToast } from '../../server/toast.js';
26
+ import { t } from '../../locales/index.js';
27
+ // ─── Constants ─────────────────────────────────────────────────────────────
28
+ const DEFAULT_MODEL = 'trellis-2';
29
+ const DEFAULT_RESOLUTION = 'low';
30
+ const VALID_RESOLUTIONS = ['low', 'medium', 'high'];
31
+ // Live-observed flat costs (Phase 2 / 2.2). Registry pricing wins when present.
32
+ const THREE_D_COST_FALLBACK = {
33
+ 'trellis-2': 0.24,
34
+ 'hyper3d-rodin': 0.10,
35
+ };
36
+ // ─── Helpers ──────────────────────────────────────────────────────────────
37
+ function estimate3DCost(model) {
38
+ const m = ModelRegistry.getByNameOrAlias('3d', model);
39
+ if (m?.pricing?.completionImageTokens !== undefined)
40
+ return m.pricing.completionImageTokens;
41
+ if (m?.averageCost !== undefined)
42
+ return m.averageCost;
43
+ return THREE_D_COST_FALLBACK[model] ?? 0.24;
44
+ }
45
+ // ─── Tool Definition ──────────────────────────────────────────────────────
46
+ export const polliGen3dTool = tool({
47
+ description: t('tools.gen3d.desc'),
48
+ args: {
49
+ prompt: tool.schema.string().optional().describe(t('tools.gen3d.arg_prompt')),
50
+ image: tool.schema.string().optional().describe(t('tools.gen3d.arg_image')),
51
+ model: tool.schema.string().optional().describe(t('tools.gen3d.arg_model', { model: DEFAULT_MODEL })),
52
+ resolution: tool.schema.enum(VALID_RESOLUTIONS).optional().describe(t('tools.gen3d.arg_resolution', { res: DEFAULT_RESOLUTION })),
53
+ seed: tool.schema.number().optional().describe(t('tools.gen3d.arg_seed')),
54
+ save_to: tool.schema.string().optional().describe(t('tools.gen3d.arg_save_to')),
55
+ filename: tool.schema.string().optional().describe(t('tools.gen3d.arg_filename')),
56
+ timeout_seconds: tool.schema.number().optional().describe(t('tools.gen3d.arg_timeout')),
57
+ },
58
+ async execute(args, context) {
59
+ const apiKey = getApiKey();
60
+ if (!hasApiKey()) {
61
+ return t('tools.gen3d.req_key');
62
+ }
63
+ const model = args.model || DEFAULT_MODEL;
64
+ const resolution = args.resolution || DEFAULT_RESOLUTION;
65
+ const prompt = args.prompt || (args.image ? '3d model from reference image' : 'a simple 3d object');
66
+ // Known model check (beta passthrough for unknown ids)
67
+ const knownModels = ModelRegistry.list('3d');
68
+ const knownModel = knownModels.some(m => m.name === model || m.aliases.includes(model));
69
+ if (!knownModel) {
70
+ emitStatusToast('warning', t('tools.gen3d.unknown_model', { model }), '🧊 polli_gen_3d');
71
+ }
72
+ // Reference image (1 ref max — trellis uses the image only)
73
+ if (args.image && !validateHttpUrl(args.image)) {
74
+ return t('tools.gen3d.invalid_image_url');
75
+ }
76
+ // Per-call timeout validation (>= 10s, <= 3600s)
77
+ const timeoutCheck = validateTimeoutSeconds(args.timeout_seconds);
78
+ if (!timeoutCheck.ok) {
79
+ return t('tools.gen3d.invalid_timeout', { reason: timeoutCheck.reason || '' });
80
+ }
81
+ // Seed: resolved up-front (recovery metadata — same seed = cache hit).
82
+ const seed = args.seed !== undefined ? args.seed : Math.floor(Math.random() * 1000000);
83
+ // Estimate cost + Cost Guard
84
+ const estimatedCost = estimate3DCost(model);
85
+ const costCheck = checkCostControl('polli_gen_3d', args, model, estimatedCost, '3d');
86
+ if (!costCheck.allowed) {
87
+ return costCheck.message || t('tools.gen3d.blocked');
88
+ }
89
+ const config = loadConfig();
90
+ const argsStr = config.gui?.logs === 'verbose' ? `\nParameters: ${JSON.stringify(args)}` : '';
91
+ emitStatusToast('info', t('tools.gen3d.generating', { model, resolution }) + argsStr, '🧊 polli_gen_3d');
92
+ context.metadata({ title: `🧊 3D: ${model} (${resolution})` });
93
+ // Effective timeout via hierarchy: per-call > model override > capability > global
94
+ const timeoutSeconds = resolveCapabilityTimeout('gen_3d', model, args.timeout_seconds, config.timeouts ?? null);
95
+ const timeoutMs = timeoutSeconds * 1000;
96
+ try {
97
+ // Build canonical GET /3d/{prompt}
98
+ const params = new URLSearchParams({
99
+ model,
100
+ resolution,
101
+ seed: String(seed),
102
+ nologo: 'true',
103
+ private: 'true',
104
+ });
105
+ if (args.image)
106
+ params.set('image', args.image);
107
+ const promptEncoded = encodeURIComponent(prompt);
108
+ const url = `https://gen.pollinations.ai/3d/${promptEncoded}?${params}`;
109
+ const headers = {};
110
+ if (apiKey)
111
+ headers['Authorization'] = `Bearer ${apiKey}`;
112
+ const balBefore = await fetchEnterBalance();
113
+ const result = await httpsGet(url, headers, timeoutMs);
114
+ const glbData = result.data;
115
+ const responseHeaders = result.headers;
116
+ // Artifact validation: GLB magic bytes ('glTF'). Never write a
117
+ // fake format with an arbitrary extension.
118
+ const detected = detectArtifactType(glbData);
119
+ if (!detected || detected.format !== 'glb') {
120
+ return t('tools.gen3d.err_format', {
121
+ detected: detected ? detected.format : 'unknown',
122
+ });
123
+ }
124
+ // Persist with the detected extension
125
+ let outputDir = getDefaultOutputDir('3d');
126
+ let filename = args.filename ? sanitizeFilename(args.filename) : undefined;
127
+ if (args.save_to) {
128
+ if (args.save_to.match(/\.glb$/i)) {
129
+ outputDir = path.dirname(args.save_to);
130
+ filename = path.basename(args.save_to);
131
+ }
132
+ else {
133
+ outputDir = args.save_to;
134
+ }
135
+ }
136
+ ensureDir(outputDir);
137
+ if (!filename)
138
+ filename = generateFilename('3d', model, 'glb');
139
+ const persisted = persistArtifact(glbData, {
140
+ outputDir,
141
+ filename,
142
+ preferredExt: 'glb',
143
+ detectExt: true,
144
+ });
145
+ // Real cost via balance delta (ledger sync delay) + headers fallback
146
+ let realCost;
147
+ if (balBefore !== null) {
148
+ await new Promise(r => setTimeout(r, 1000));
149
+ const balAfter = await fetchEnterBalance();
150
+ if (balAfter !== null) {
151
+ realCost = Math.round((balBefore - balAfter) * 10000) / 10000;
152
+ }
153
+ }
154
+ const costTracking = extractCostFromHeaders(responseHeaders);
155
+ const lines = [];
156
+ lines.push(t('tools.gen3d.res_title'));
157
+ lines.push(`━━━━━━━━━━━━━━━━━━`);
158
+ lines.push(t('tools.gen3d.res_prompt', { prompt: prompt.substring(0, 100) + (prompt.length > 100 ? '...' : '') }));
159
+ lines.push(t('tools.gen3d.res_model', { model }));
160
+ lines.push(t('tools.gen3d.res_resolution', { resolution }));
161
+ lines.push(t('tools.gen3d.res_seed', { seed }));
162
+ if (args.image) {
163
+ lines.push(t('tools.gen3d.res_image', { src: args.image.substring(0, 60) + '...' }));
164
+ }
165
+ lines.push(t('tools.gen3d.res_file', { path: persisted.filePath }));
166
+ lines.push(t('tools.gen3d.res_size', { size: formatFileSize(persisted.size) }));
167
+ lines.push(t('tools.gen3d.res_format', { ext: persisted.ext.toUpperCase() }));
168
+ if (isCostEstimatorEnabled()) {
169
+ lines.push(t('tools.gen3d.res_cost_est', { cost: formatCost(estimatedCost) }));
170
+ if (realCost !== undefined) {
171
+ lines.push(t('tools.gen3d.res_cost_real', { cost: formatCost(realCost) }));
172
+ }
173
+ else if (costTracking.costUsd !== undefined) {
174
+ lines.push(t('tools.gen3d.res_cost_real', { cost: formatCost(costTracking.costUsd) }));
175
+ }
176
+ }
177
+ if (responseHeaders['x-model-used']) {
178
+ lines.push(t('tools.gen3d.res_model_used', { model: responseHeaders['x-model-used'] }));
179
+ }
180
+ if (responseHeaders['x-request-id']) {
181
+ lines.push(t('tools.gen3d.res_request_id', { id: responseHeaders['x-request-id'] }));
182
+ }
183
+ emitStatusToast('success', t('tools.gen3d.success', { model }), '🧊 gen_3d', { filePath: persisted.filePath });
184
+ return lines.join('\n');
185
+ }
186
+ catch (err) {
187
+ emitStatusToast('error', t('tools.gen3d.err_toast', { error: String(err.message || err).substring(0, 60) }), '🧊 gen_3d');
188
+ const isTimeout = /timeout/i.test(String(err.message || err));
189
+ if (isTimeout) {
190
+ // NO AUTOMATIC RESUBMIT: the generation may still be running
191
+ // and billed upstream. Offer cache recovery with the same seed.
192
+ return t('tools.gen3d.err_timeout', {
193
+ model,
194
+ seed,
195
+ seconds: timeoutSeconds,
196
+ });
197
+ }
198
+ if (err.message?.includes('402') || err.message?.includes('Payment')) {
199
+ return t('tools.gen3d.err_pollen');
200
+ }
201
+ if (err.message?.includes('401') || err.message?.includes('403')) {
202
+ return t('tools.gen3d.err_auth');
203
+ }
204
+ return t('tools.gen3d.err_gen', { error: String(err.message || err) });
205
+ }
206
+ },
207
+ });
@@ -13,6 +13,10 @@ import * as path from 'path';
13
13
  import { getApiKey, hasApiKey, httpsGet, ensureDir, generateFilename, getDefaultOutputDir, formatCost, formatFileSize, estimateImageCost, extractCostFromHeaders, isCostEstimatorEnabled, supportsI2I, getPaidImageModels, fetchEnterBalance, sanitizeFilename, validateHttpUrl, } from './shared.js';
14
14
  import { loadConfig } from '../../server/config.js';
15
15
  import { checkCostControl, isTokenBased } from './cost-guard.js';
16
+ import { validateTimeoutSeconds } from './timeout-policy.js';
17
+ import { resolveCapabilityTimeout } from './tool-capability-registry.js';
18
+ import { persistArtifact } from './artifact-core.js';
19
+ import { parsePolliErrorFromThrow } from './error-parser.js';
16
20
  import { emitStatusToast } from '../../server/toast.js';
17
21
  import { t } from '../../locales/index.js';
18
22
  // ─── Constants ─────────────────────────────────────────────────────────────
@@ -31,6 +35,7 @@ export const polliGenImageTool = tool({
31
35
  transparent: tool.schema.boolean().optional().describe(t('tools.image.arg_trans')),
32
36
  save_to: tool.schema.string().optional().describe(t('tools.image.arg_save_to')),
33
37
  filename: tool.schema.string().optional().describe(t('tools.image.arg_filename')),
38
+ timeout_seconds: tool.schema.number().optional().describe(t('tools.image.arg_timeout')),
34
39
  },
35
40
  async execute(args, context) {
36
41
  const apiKey = getApiKey();
@@ -64,6 +69,11 @@ export const polliGenImageTool = tool({
64
69
  return t('tools.image.no_i2i', { model, models });
65
70
  }
66
71
  }
72
+ // Per-call timeout validation (v6.5: >= 10s, <= 3600s)
73
+ const timeoutCheck = validateTimeoutSeconds(args.timeout_seconds);
74
+ if (!timeoutCheck.ok) {
75
+ return t('tools.image.invalid_timeout', { reason: timeoutCheck.reason || '' });
76
+ }
67
77
  // Estimate cost
68
78
  const estimatedCost = estimateImageCost(model);
69
79
  // Cost Guard check V2
@@ -113,14 +123,16 @@ export const polliGenImageTool = tool({
113
123
  headers['Authorization'] = `Bearer ${apiKey}`;
114
124
  // 1. Fetch balance avant génération
115
125
  const balBefore = await fetchEnterBalance();
116
- const result = await httpsGet(url, headers);
126
+ const timeoutSeconds = resolveCapabilityTimeout('gen_image', model, args.timeout_seconds, config.timeouts ?? null);
127
+ const result = await httpsGet(url, headers, timeoutSeconds * 1000);
117
128
  imageData = result.data;
118
129
  responseHeaders = result.headers;
119
130
  // Update used model from response if available
120
131
  if (responseHeaders['x-model-used']) {
121
132
  usedModel = responseHeaders['x-model-used'];
122
133
  }
123
- // Save the image
134
+ // Save the image — extension follows REAL magic bytes (a b64
135
+ // response can be JPEG even when the caller assumed PNG).
124
136
  let outputDir = getDefaultOutputDir('images');
125
137
  let filename = args.filename ? sanitizeFilename(args.filename) : undefined;
126
138
  if (args.save_to) {
@@ -133,9 +145,15 @@ export const polliGenImageTool = tool({
133
145
  }
134
146
  }
135
147
  ensureDir(outputDir);
136
- filename = filename || generateFilename('image', usedModel, 'png');
137
- const filePath = path.join(outputDir, filename.includes('.') ? filename : `${filename}.png`);
138
- fs.writeFileSync(filePath, imageData);
148
+ if (!filename)
149
+ filename = generateFilename('image', usedModel, 'png');
150
+ const persisted = persistArtifact(imageData, {
151
+ outputDir,
152
+ filename,
153
+ preferredExt: 'png',
154
+ detectExt: true,
155
+ });
156
+ const filePath = persisted.filePath;
139
157
  // 2. Fetch balance après génération (delay for API sync)
140
158
  let balAfter = null;
141
159
  let realCost;
@@ -196,16 +214,17 @@ export const polliGenImageTool = tool({
196
214
  }
197
215
  catch (err) {
198
216
  emitStatusToast('error', t('tools.image.error', { error: err.message?.substring(0, 60) }), '🎨 gen_image');
199
- if (err.message?.includes('402') || err.message?.includes('Payment')) {
217
+ const parsed = parsePolliErrorFromThrow(err);
218
+ if (parsed.kind === 'payment') {
200
219
  return t('tools.image.insufficient_funds', { model });
201
220
  }
202
- if (err.message?.includes('401') || err.message?.includes('403')) {
221
+ if (parsed.kind === 'auth') {
203
222
  return t('tools.image.invalid_key');
204
223
  }
205
- if (err.message?.includes('400')) {
206
- return t('tools.image.invalid_params', { error: err.message });
224
+ if (parsed.kind === 'bad_request') {
225
+ return t('tools.image.invalid_params', { error: parsed.message });
207
226
  }
208
- return t('tools.image.gen_error_msg', { error: err.message });
227
+ return t('tools.image.gen_error_msg', { error: parsed.message });
209
228
  }
210
229
  },
211
230
  });
@@ -79,8 +79,9 @@ export const polliGenMusicTool = tool({
79
79
  const headers = {
80
80
  'Authorization': `Bearer ${apiKey}`,
81
81
  };
82
- // Music generation takes time
83
- const result = await httpsGet(url, headers);
82
+ // Music generation takes time (~1.2x duration). v6.5: bound generously.
83
+ const musicTimeoutMs = Math.min(Math.ceil((duration + 60) * 1000), 3600000);
84
+ const result = await httpsGet(url, headers, musicTimeoutMs);
84
85
  const audioData = result.data;
85
86
  const responseHeaders = result.headers;
86
87
  // Save audio
@@ -18,6 +18,8 @@ import * as path from 'path';
18
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
+ import { validateTimeoutSeconds } from './timeout-policy.js';
22
+ import { resolveCapabilityTimeout } from './tool-capability-registry.js';
21
23
  import { emitStatusToast } from '../../server/toast.js';
22
24
  import { t } from '../../locales/index.js';
23
25
  // ─── Constants ─────────────────────────────────────────────────────────────
@@ -34,6 +36,7 @@ export const polliGenVideoTool = tool({
34
36
  aspect_ratio: tool.schema.enum(['16:9', '9:16', '1:1', '4:3']).optional().describe(t('tools.polli_gen_video.arg_aspect')),
35
37
  reference_image: tool.schema.string().optional().describe(t('tools.polli_gen_video.arg_ref')),
36
38
  seed: tool.schema.number().optional().describe(t('tools.polli_gen_video.arg_seed')),
39
+ timeout_seconds: tool.schema.number().optional().describe(t('tools.polli_gen_video.arg_timeout')),
37
40
  save_to: tool.schema.string().optional().describe(t('tools.polli_gen_video.arg_save_to')),
38
41
  filename: tool.schema.string().optional().describe(t('tools.polli_gen_video.arg_filename')),
39
42
  },
@@ -82,6 +85,11 @@ export const polliGenVideoTool = tool({
82
85
  .join(', ');
83
86
  return t('tools.polli_gen_video.no_i2v', { model, models });
84
87
  }
88
+ // Per-call timeout validation (v6.5: >= 10s, <= 3600s, no auto resubmit)
89
+ const timeoutCheck = validateTimeoutSeconds(args.timeout_seconds);
90
+ if (!timeoutCheck.ok) {
91
+ return t('tools.polli_gen_video.invalid_timeout', { reason: timeoutCheck.reason || '' });
92
+ }
85
93
  // Estimate cost
86
94
  const estimatedCost = estimateVideoCost(model, duration);
87
95
  // Cost Guard check V2
@@ -125,14 +133,17 @@ export const polliGenVideoTool = tool({
125
133
  params.set('seed', String(args.seed));
126
134
  }
127
135
  const promptEncoded = encodeURIComponent(args.prompt);
128
- const url = `https://gen.pollinations.ai/image/${promptEncoded}?${params}`;
136
+ // v6.5: /video/{prompt} is the canonical route (SDK/CLI). /image/{prompt}
137
+ // remains compatible upstream but the plugin follows the semantic route.
138
+ const url = `https://gen.pollinations.ai/video/${promptEncoded}?${params}`;
129
139
  const headers = {
130
140
  'Authorization': `Bearer ${apiKey}`,
131
141
  };
132
142
  // 1. Fetch balance avant génération
133
143
  const balBefore = await fetchEnterBalance();
134
144
  // Video generation takes time (30-70 seconds depending on model)
135
- const result = await httpsGet(url, headers);
145
+ const timeoutSeconds = resolveCapabilityTimeout('gen_video', model, args.timeout_seconds, config.timeouts ?? null);
146
+ const result = await httpsGet(url, headers, timeoutSeconds * 1000);
136
147
  const videoData = result.data;
137
148
  const responseHeaders = result.headers;
138
149
  // Save video
@@ -9,16 +9,17 @@ You must strictly understand the 3 INDEPENDENT categories of settings before exp
9
9
  === 1. CHAT MODELS & FALLBACKS (Applies ONLY to conversational chat models) ===
10
10
  - mode: Dictates fallback rules for the chat.
11
11
  * 'manual': No automatic rules.
12
- * 'alwaysfree': Free tiers first. If 'thresholdsTier' is reached -> fallbacks to Free Universe. NEVER uses Wallet.
13
- * 'pro': Uses Wallet. If 'thresholdsWallet' is reached -> fallbacks to Free Universe.
14
- - thresholdsTier: WARNING PERCENTAGE (e.g. 10 for 10%) that triggers chat fallback in 'alwaysfree' mode.
15
- - thresholdsWallet: WARNING PERCENTAGE (e.g. 50 for 50%) that triggers chat fallback in 'pro' mode.
12
+ * 'quest' (QUEST_PREFERRED): Quest pollen first. Server may fall back to Paid if Quest is insufficient. Falls back to the Free Universe when BOTH Quest and Paid look exhausted.
13
+ * 'quest_only' (QUEST_ELIGIBLE_ONLY): Blocks paid_only models locally, only sends when the client considers the call Quest-eligible. BEST-EFFORT a Paid (pack) debit can still occur server-side (race/real cost). No paid re-route.
14
+ * 'paid' (PAID_ALLOWED): Paid allowed, paid_only allowed per Cost Guard. Falls back to Free Universe when the wallet drops below thresholdsWallet.
15
+ - thresholdsQuest: absolute Quest pollen floor (e.g. 0.05) that triggers chat fallback in 'quest_only' mode.
16
+ - thresholdsWallet: absolute Paid pollen floor (e.g. 0.5) that triggers chat fallback in 'paid' mode.
16
17
  *Note: 'enter.agent' or 'free.agent' are fallback conversational models for logic reasoning, THEY DO NOT GENERATE IMAGES OR VIDEOS!*
17
18
 
18
19
  === 2. TOOLS PROTECTION (Applies ONLY to independent 'polli_' tools like image, video, search) ===
19
- - enablePaidTools: Allow tools to execute models that consume 'Wallet' pollen. If false, tools can only use models that consume 'Freetier' pollen.
20
+ - enablePaidTools: When false, tools that would use 'Paid' pollen are BLOCKED LOCALLY — models flagged paid_only are rejected before sending. IMPORTANT: this is a LOCAL client-side guard, NOT a server guarantee — a Paid (pack) debit can still occur in a race or on real-cost overage (the server picks the billing bucket at debit time).
20
21
  - costConfirmationRequired: Safety lock for tools. If true, the user MUST manually confirm BEFORE executing ANY tool whose cost estimate exceeds the 'costThreshold'.
21
- - costThreshold: USD/🌼 limit (cost of the tool execution) that triggers the confirmation lock.
22
+ - costThreshold: 🌼 limit (cost of the tool execution) that triggers the confirmation lock.
22
23
  - costEstimator: Shows live cost estimates IN TOOL OUTPUTS (false = Silent Mode).
23
24
 
24
25
  === 3. UI & NOTIFICATIONS (General display) ===
@@ -28,17 +29,15 @@ Use 'action=update' to change these. NEVER confuse Chat Mode with Tools Protecti
28
29
  args: {
29
30
  action: tool.schema.enum(['view', 'update'])
30
31
  .describe('Action to perform: "view" to see current configuration, "update" to modify it.'),
31
- mode: tool.schema.enum(['manual', 'alwaysfree', 'pro']).optional().describe('CHAT ONLY: Dictates automatic fallback rules (manual/alwaysfree/pro).'),
32
+ mode: tool.schema.enum(['manual', 'quest', 'quest_only', 'paid']).optional().describe('CHAT ONLY: Dictates automatic fallback rules (manual/quest/quest_only/paid).'),
32
33
  costEstimator: tool.schema.boolean().optional().describe('Set to true to show cost estimates auto. Set to false for "Manual Mode" (hide estimates).'),
33
34
  statusBar: tool.schema.boolean().optional().describe('Enable/disable status bar visibility (true/false)'),
34
35
  costConfirmationRequired: tool.schema.boolean().optional().describe('Safety Lock: Set to true to ask user confirmation before spending money. Set to false to spend automatically.'),
35
- enablePaidTools: tool.schema.boolean().optional().describe('Allow execution of paid or premium models using user Wallet balance (true/false)'),
36
- costThreshold: tool.schema.number().optional().describe('Cost threshold in USD/🌼 above which confirmation is required'),
37
- thresholdsTier: tool.schema.number().optional().describe('Warning threshold PERCENTAGE (e.g. 10 for 10%) for Free Tier.'),
38
- thresholdsWallet: tool.schema.number().optional().describe('Warning threshold PERCENTAGE (e.g. 50 for 50%) for Wallet balance.'),
36
+ enablePaidTools: tool.schema.boolean().optional().describe('Allow execution of paid or premium models using Paid pollen (true/false)'),
37
+ costThreshold: tool.schema.number().optional().describe('Cost threshold in 🌼 above which confirmation is required'),
38
+ thresholdsQuest: tool.schema.number().optional().describe('Absolute Quest pollen floor (e.g. 0.05) for quest_only fallback.'),
39
+ thresholdsWallet: tool.schema.number().optional().describe('Absolute Paid pollen floor (e.g. 0.5) for paid mode fallback.'),
39
40
  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
- refillOverride: tool.schema.number().optional().describe('Manual Quest Pollen hourly refill override (0.01, 0.15, 0.4, 0.8, or 10). Set to 0 for auto-deduction.'),
41
- questStashInFreeMode: tool.schema.boolean().optional().describe('Count accumulated quest stash as free pollen in alwaysfree Safety Net (default: true).')
42
41
  },
43
42
  async execute(args, context) {
44
43
  if (args.action === 'view') {
@@ -65,17 +64,12 @@ Use 'action=update' to change these. NEVER confuse Chat Mode with Tools Protecti
65
64
  updates.enablePaidTools = args.enablePaidTools;
66
65
  if (args.costThreshold !== undefined)
67
66
  updates.costThreshold = args.costThreshold;
68
- if (args.refillOverride !== undefined) {
69
- updates.refillOverride = args.refillOverride === 0 ? undefined : args.refillOverride;
70
- }
71
- if (args.questStashInFreeMode !== undefined)
72
- updates.questStashInFreeMode = args.questStashInFreeMode;
73
67
  if (args.lang !== undefined)
74
68
  updates.lang = args.lang;
75
- if (args.thresholdsTier !== undefined || args.thresholdsWallet !== undefined) {
69
+ if (args.thresholdsQuest !== undefined || args.thresholdsWallet !== undefined) {
76
70
  updates.thresholds = { ...currentConfig.thresholds };
77
- if (args.thresholdsTier !== undefined)
78
- updates.thresholds.tier = args.thresholdsTier;
71
+ if (args.thresholdsQuest !== undefined)
72
+ updates.thresholds.quest = args.thresholdsQuest;
79
73
  if (args.thresholdsWallet !== undefined)
80
74
  updates.thresholds.wallet = args.thresholdsWallet;
81
75
  }