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
@@ -1,48 +1,39 @@
1
1
  import { DetailedUsageEntry } from './pollinations-api.js';
2
- interface ResetInfo {
3
- nextReset: Date;
4
- lastReset: Date;
5
- timeUntilReset: number;
6
- timeSinceReset: number;
7
- resetHour: number;
8
- resetMinute: number;
9
- resetSecond: number;
10
- progressPercent: number;
11
- }
2
+ /**
3
+ * v6.5 — Quest/Paid semantics.
4
+ * The old tier/refill model (KNOWN_REFILLS, hourly allowance deduction,
5
+ * tierMetaForAllowance) has been removed: upstream deleted the hourly refill
6
+ * (cron disabled 2026-06, code removed 2026-07). The client cannot read a
7
+ * reliable Quest/Paid split from /account/balance in prod, so `questBalance`
8
+ * is a BEST-EFFORT estimate (claimed quest pollen minus tier-metered usage
9
+ * since claim). `walletBalance` is Paid pollen when exposed (pack), otherwise
10
+ * estimated as total minus quest. meter_source in /account/usage remains the
11
+ * only authoritative retrospective split.
12
+ */
12
13
  export interface QuotaStatus {
13
- tierRemaining: number;
14
- tierUsed: number;
15
- tierLimit: number;
16
- questStash: number;
14
+ questBalance: number;
17
15
  walletBalance: number;
18
- nextResetAt: Date;
19
- timeUntilReset: number;
16
+ totalBalance: number;
20
17
  canUseEnterprise: boolean;
21
18
  isUsingWallet: boolean;
22
19
  needsAlert: boolean;
23
- tier: string;
24
- tierEmoji: string;
25
20
  errorType?: 'auth_limited' | 'network' | 'unknown';
26
21
  }
27
22
  export declare function fetchUsageForPeriod(apiKey: string, lastReset: Date): Promise<DetailedUsageEntry[]>;
28
- /** Map hourly refill amount → display meta (exported for unit tests). */
29
- export declare function tierMetaForAllowance(allowance: number): {
30
- label: string;
31
- emoji: string;
32
- };
33
- /** Known hourly refill ladder (read-only, for tests / UI). */
34
- export declare function getKnownRefills(): ReadonlyArray<{
35
- pollen: number;
36
- emoji: string;
37
- label: string;
38
- }>;
39
23
  export declare function getQuotaStatus(forceRefresh?: boolean): Promise<QuotaStatus>;
40
- /** Next top-of-hour UTC reset window (exported for unit tests). */
41
- export declare function calculateResetInfo(): ResetInfo;
24
+ /** Next top-of-hour UTC reset window (used for usage period windows only). */
25
+ export declare function calculateResetInfo(): {
26
+ nextReset: Date;
27
+ lastReset: Date;
28
+ };
42
29
  export declare function formatQuotaForToast(quota: QuotaStatus): string;
30
+ /**
31
+ * Best-effort Quest balance: claimed quest pollen (tier bucket) minus
32
+ * tier-metered consumption since the earliest claim. This is NOT a server
33
+ * guarantee — the authoritative split is meter_source in /account/usage.
34
+ */
43
35
  export declare function fetchQuestStash(apiKey: string): Promise<{
44
36
  questStash: number;
45
37
  claimedQuestTier: number;
46
38
  tierConsumedSinceClaim: number;
47
39
  }>;
48
- export {};
@@ -4,18 +4,10 @@ import { loadConfig } from './config.js';
4
4
  const CACHE_TTL = 30000;
5
5
  let cachedQuota = null;
6
6
  let lastQuotaFetch = 0;
7
- const STASH_CACHE_TTL = 5 * 60 * 1000; // 5 min — le stash Quest change rarement
7
+ const STASH_CACHE_TTL = 5 * 60 * 1000; // 5 min — Quest stash changes rarely
8
8
  let cachedStash = null;
9
9
  let lastStashFetch = 0;
10
10
  const ONE_HOUR_MS = 60 * 60 * 1000;
11
- const KNOWN_REFILLS = [
12
- { pollen: 0, emoji: '👤', label: 'anonymous' },
13
- { pollen: 0.01, emoji: '🍄', label: 'spore' },
14
- { pollen: 0.15, emoji: '🌱', label: 'seed' },
15
- { pollen: 0.4, emoji: '🌸', label: 'flower' },
16
- { pollen: 0.8, emoji: '🍯', label: 'nectar' },
17
- { pollen: 10, emoji: '🐝', label: 'router' },
18
- ];
19
11
  // === LOGGING ===
20
12
  import { logApi } from './logger.js';
21
13
  function logQuota(msg) {
@@ -65,96 +57,11 @@ export async function fetchUsageForPeriod(apiKey, lastReset) {
65
57
  logQuota(`SmartFetch: Retrieved ${allUsage.length} transactions for current period.`);
66
58
  return allUsage;
67
59
  }
68
- // === ALLOWANCE DEDUCTION ===
69
- async function fetchUsageForAllowance(apiKey) {
70
- const sevenDaysAgo = new Date(Date.now() - 7 * 24 * ONE_HOUR_MS);
71
- const allUsage = [];
72
- let cursorEventId = null;
73
- let pageCount = 0;
74
- const maxPages = 20;
75
- while (pageCount < maxPages) {
76
- let path = `/account/usage?days=7&limit=500`;
77
- if (cursorEventId) {
78
- path += `&before_event_id=${encodeURIComponent(cursorEventId)}`;
79
- }
80
- const res = await fetchAPI(path, apiKey);
81
- if (!res.usage || res.usage.length === 0)
82
- break;
83
- let reachedCutoff = false;
84
- for (const entry of res.usage) {
85
- const ts = (entry.timestamp.includes('Z') ? entry.timestamp : entry.timestamp.replace(' ', 'T') + 'Z');
86
- if (new Date(ts) < sevenDaysAgo) {
87
- reachedCutoff = true;
88
- break;
89
- }
90
- allUsage.push(entry);
91
- }
92
- if (reachedCutoff || res.usage.length < 500)
93
- break;
94
- const last = res.usage[res.usage.length - 1];
95
- cursorEventId = last?.cursor_event_id || null;
96
- if (!cursorEventId)
97
- break;
98
- pageCount++;
99
- }
100
- logQuota(`deduceAllowance: fetched ${allUsage.length} records over ${pageCount + 1} pages`);
101
- return allUsage;
102
- }
103
- async function deduceAllowanceFromApi(apiKey) {
104
- try {
105
- const allUsage = await fetchUsageForAllowance(apiKey);
106
- if (allUsage.length === 0)
107
- return 0;
108
- const sevenDaysAgo = new Date(Date.now() - 7 * 24 * ONE_HOUR_MS);
109
- const hourlyBuckets = new Map();
110
- for (const entry of allUsage) {
111
- if (entry.meter_source !== 'tier')
112
- continue;
113
- const ts = entry.timestamp.includes('Z') ? entry.timestamp : entry.timestamp.replace(' ', 'T') + 'Z';
114
- const entryTime = new Date(ts);
115
- if (entryTime < sevenDaysAgo)
116
- continue;
117
- const hourKey = entryTime.getUTCFullYear() * 1000000
118
- + entryTime.getUTCMonth() * 10000
119
- + entryTime.getUTCDate() * 100
120
- + entryTime.getUTCHours();
121
- hourlyBuckets.set(hourKey, (hourlyBuckets.get(hourKey) || 0) + entry.cost_usd);
122
- }
123
- const maxHourlyTier = Math.max(0, ...hourlyBuckets.values());
124
- const match = KNOWN_REFILLS.slice().reverse().find(r => r.pollen <= maxHourlyTier + 0.02);
125
- logQuota(`deduceAllowance: ${allUsage.length} records, ${hourlyBuckets.size} hourly buckets, max=${maxHourlyTier.toFixed(4)}, deduced=${match?.pollen ?? 0} (${match?.label})`);
126
- return match ? match.pollen : 0;
127
- }
128
- catch (e) {
129
- logQuota(`deduceAllowance failed: ${e}`);
130
- return 0;
131
- }
132
- }
133
- function deduceAllowanceFromUsage(usage) {
134
- const tierCosts = usage
135
- .filter(u => u.meter_source === 'tier')
136
- .map(u => u.cost_usd);
137
- const maxHourlyTier = Math.max(0, ...tierCosts);
138
- const match = KNOWN_REFILLS.slice().reverse().find(r => r.pollen <= maxHourlyTier + 0.02);
139
- return match ? match.pollen : 0;
140
- }
141
- /** Map hourly refill amount → display meta (exported for unit tests). */
142
- export function tierMetaForAllowance(allowance) {
143
- const match = KNOWN_REFILLS.find(r => r.pollen === allowance)
144
- || KNOWN_REFILLS.findLast(r => r.pollen <= allowance);
145
- return match
146
- ? { label: match.label, emoji: match.emoji }
147
- : { label: 'unknown', emoji: '❓' };
148
- }
149
- /** Known hourly refill ladder (read-only, for tests / UI). */
150
- export function getKnownRefills() {
151
- return KNOWN_REFILLS;
152
- }
153
60
  // === MAIN QUOTA FUNCTION ===
154
61
  export async function getQuotaStatus(forceRefresh = false) {
155
62
  const config = loadConfig();
156
63
  if (!config.apiKey) {
157
- return createDefaultQuota('none', 0);
64
+ return createDefaultQuota(0);
158
65
  }
159
66
  const now = Date.now();
160
67
  if (!forceRefresh && cachedQuota && (now - lastQuotaFetch) < CACHE_TTL) {
@@ -163,19 +70,6 @@ export async function getQuotaStatus(forceRefresh = false) {
163
70
  try {
164
71
  logQuota("Fetching Quota Data...");
165
72
  const balanceRes = await fetchAPI('/account/balance', config.apiKey);
166
- const resetInfo = calculateResetInfo();
167
- const periodUsage = await fetchUsageForPeriod(config.apiKey, resetInfo.lastReset);
168
- const allowance = balanceRes.allowance
169
- ?? config.refillOverride
170
- ?? await deduceAllowanceFromApi(config.apiKey);
171
- const tierMeta = tierMetaForAllowance(allowance);
172
- const tierLimit = allowance;
173
- const { tierUsed } = calculateCurrentPeriodUsage(periodUsage, resetInfo);
174
- const tierRemaining = Math.max(0, tierLimit - tierUsed);
175
- const cleanTierRemaining = Math.max(0, parseFloat(tierRemaining.toFixed(4)));
176
- const totalBalance = balanceRes.total
177
- ?? balanceRes.balance
178
- ?? 0;
179
73
  // Fetch quest stash BEFORE paidPollen calculation (cached 5 min)
180
74
  const nowStash = Date.now();
181
75
  if (!cachedStash || (nowStash - lastStashFetch) > STASH_CACHE_TTL) {
@@ -185,29 +79,33 @@ export async function getQuotaStatus(forceRefresh = false) {
185
79
  }
186
80
  catch { /* keep previous */ }
187
81
  }
188
- const questStash = cachedStash?.questStash ?? 0;
82
+ const questEstimate = cachedStash?.questStash ?? 0;
83
+ const totalBalance = balanceRes.total
84
+ ?? balanceRes.balance
85
+ ?? 0;
86
+ // Quest balance: native allowance when available (PR #12541 not in prod),
87
+ // else best-effort stash estimate.
88
+ const questBalance = balanceRes.allowance !== undefined
89
+ ? Math.max(0, balanceRes.allowance)
90
+ : questEstimate;
91
+ // Paid pollen: native pack when available, else total minus quest estimate.
189
92
  const paidPollenNative = balanceRes.pack;
190
- const paidPollen = paidPollenNative !== undefined
191
- ? paidPollenNative
192
- : Math.max(0, totalBalance - cleanTierRemaining - questStash);
193
- const cleanWalletBalance = Math.max(0, parseFloat(paidPollen.toFixed(4)));
194
- const tierAlertPercent = tierLimit > 0 ? (cleanTierRemaining / tierLimit * 100) : 0;
195
- const tierNeedsAlert = tierLimit > 0 && tierAlertPercent <= config.thresholds.tier;
93
+ const walletBalance = paidPollenNative !== undefined
94
+ ? Math.max(0, paidPollenNative)
95
+ : Math.max(0, totalBalance - questBalance);
96
+ const cleanQuestBalance = Math.max(0, parseFloat(questBalance.toFixed(4)));
97
+ const cleanWalletBalance = Math.max(0, parseFloat(walletBalance.toFixed(4)));
98
+ // Alerts (v6.5): thresholds are absolute pollen floors, not percentages.
99
+ const questNeedsAlert = cleanQuestBalance > 0 && cleanQuestBalance < (config.thresholds.quest ?? 0.05);
196
100
  const walletNeedsAlert = cleanWalletBalance > 0 && cleanWalletBalance < (config.thresholds.wallet || 0.5);
197
- logQuota(`Fetch Success. Allowance: ${allowance}, Stash: ${questStash}, Paid: ${cleanWalletBalance}, Total: ${totalBalance}`);
101
+ logQuota(`Fetch Success. Quest: ${cleanQuestBalance}, Paid: ${cleanWalletBalance}, Total: ${totalBalance}`);
198
102
  cachedQuota = {
199
- tierRemaining: cleanTierRemaining,
200
- tierUsed,
201
- tierLimit,
202
- questStash,
103
+ questBalance: cleanQuestBalance,
203
104
  walletBalance: cleanWalletBalance,
204
- nextResetAt: resetInfo.nextReset,
205
- timeUntilReset: resetInfo.timeUntilReset,
206
- canUseEnterprise: cleanTierRemaining > 0.05 || cleanWalletBalance > 0.05,
207
- isUsingWallet: cleanTierRemaining <= 0.05 && cleanWalletBalance > 0.05,
208
- needsAlert: tierNeedsAlert || walletNeedsAlert,
209
- tier: tierMeta.label,
210
- tierEmoji: tierMeta.emoji
105
+ totalBalance,
106
+ canUseEnterprise: cleanQuestBalance > 0.05 || cleanWalletBalance > 0.05,
107
+ isUsingWallet: cleanQuestBalance <= 0.05 && cleanWalletBalance > 0.05,
108
+ needsAlert: questNeedsAlert || walletNeedsAlert
211
109
  };
212
110
  lastQuotaFetch = now;
213
111
  return cachedQuota;
@@ -219,24 +117,17 @@ export async function getQuotaStatus(forceRefresh = false) {
219
117
  errorType = 'auth_limited';
220
118
  else if (e.message && e.message.includes('Network Error'))
221
119
  errorType = 'network';
222
- return cachedQuota || { ...createDefaultQuota('error', 1), errorType };
120
+ return cachedQuota || { ...createDefaultQuota(0), errorType };
223
121
  }
224
122
  }
225
- function createDefaultQuota(tierName, limit) {
226
- const meta = tierMetaForAllowance(limit);
123
+ function createDefaultQuota(_limit) {
227
124
  return {
228
- tierRemaining: 0,
229
- tierUsed: 0,
230
- tierLimit: limit,
231
- questStash: 0,
125
+ questBalance: 0,
232
126
  walletBalance: 0,
233
- nextResetAt: new Date(),
234
- timeUntilReset: 0,
127
+ totalBalance: 0,
235
128
  canUseEnterprise: false,
236
129
  isUsingWallet: false,
237
- needsAlert: false,
238
- tier: tierName !== 'none' ? meta.label : 'none',
239
- tierEmoji: meta.emoji
130
+ needsAlert: false
240
131
  };
241
132
  }
242
133
  // === HELPERS ===
@@ -249,7 +140,7 @@ function fetchAPI(endpoint, apiKey) {
249
140
  method: 'GET',
250
141
  headers: {
251
142
  'Authorization': `Bearer ${apiKey}`,
252
- 'User-Agent': 'opencode-pollinations-plugin/6.4.1'
143
+ 'User-Agent': 'opencode-pollinations-plugin/6.5.0'
253
144
  }
254
145
  };
255
146
  const req = https.request(options, (res) => {
@@ -272,62 +163,31 @@ function fetchAPI(endpoint, apiKey) {
272
163
  req.on('error', (e) => {
273
164
  reject(new Error(`Network Error: ${e.message}`));
274
165
  });
166
+ // v6.5: bound the request (quota read was previously unbounded → hang risk).
167
+ req.setTimeout(10000, () => {
168
+ req.destroy(new Error('Timeout: quota API fetch exceeded 10s'));
169
+ });
275
170
  req.end();
276
171
  });
277
172
  }
278
- /** Next top-of-hour UTC reset window (exported for unit tests). */
173
+ /** Next top-of-hour UTC reset window (used for usage period windows only). */
279
174
  export function calculateResetInfo() {
280
175
  const now = new Date();
281
176
  const nextReset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours() + 1, 0, 0, 0));
282
177
  const lastReset = new Date(nextReset.getTime() - ONE_HOUR_MS);
283
- const timeUntilReset = Math.max(0, nextReset.getTime() - now.getTime());
284
- const timeSinceReset = Math.max(0, now.getTime() - lastReset.getTime());
285
- const progressPercent = Math.min(100, (timeSinceReset / ONE_HOUR_MS) * 100);
286
- return {
287
- nextReset,
288
- lastReset,
289
- timeUntilReset,
290
- timeSinceReset,
291
- resetHour: nextReset.getUTCHours(),
292
- resetMinute: 0,
293
- resetSecond: 0,
294
- progressPercent
295
- };
296
- }
297
- function calculateCurrentPeriodUsage(usage, resetInfo) {
298
- let tierUsed = 0;
299
- let packUsed = 0;
300
- const entriesAfterReset = usage.filter(entry => {
301
- const timestamp = entry.timestamp.replace(' ', 'T') + 'Z';
302
- const entryTime = new Date(timestamp);
303
- return entryTime >= resetInfo.lastReset;
304
- });
305
- for (const entry of entriesAfterReset) {
306
- if (entry.meter_source === 'tier') {
307
- tierUsed += entry.cost_usd;
308
- }
309
- else if (entry.meter_source === 'pack') {
310
- packUsed += entry.cost_usd;
311
- }
312
- }
313
- return { tierUsed, packUsed };
178
+ return { nextReset, lastReset };
314
179
  }
315
180
  export function formatQuotaForToast(quota) {
316
181
  if (quota.errorType === 'auth_limited') {
317
- return `🔑 CLE LIMITÉE (Génération Seule) | 💎 Wallet: N/A | Reset: N/A`;
182
+ return `🔑 CLE LIMITÉE (Génération Seule) | 💎 Paid: N/A | 🎁 Quest: N/A`;
318
183
  }
319
- const tierPercent = quota.tierLimit > 0
320
- ? Math.round((quota.tierRemaining / quota.tierLimit) * 100)
321
- : 0;
322
- const ms = quota.timeUntilReset;
323
- const hours = Math.floor(ms / (1000 * 60 * 60));
324
- const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
325
- const resetIn = `${hours}h${minutes}m`;
326
- const stashStr = quota.questStash > 0
327
- ? ` | 🎁 ~${quota.questStash.toFixed(2)} (stash)`
328
- : '';
329
- return `${quota.tierEmoji} ${quota.tierRemaining.toFixed(2)}/${quota.tierLimit} (${tierPercent}%)${stashStr} | 💎 $${quota.walletBalance.toFixed(2)} | ⏰ ${resetIn}`;
184
+ return `🎁 Quest: ~${quota.questBalance.toFixed(2)} | 💎 Paid: ~${quota.walletBalance.toFixed(2)}${quota.needsAlert ? ' | ⚠️' : ''}`;
330
185
  }
186
+ /**
187
+ * Best-effort Quest balance: claimed quest pollen (tier bucket) minus
188
+ * tier-metered consumption since the earliest claim. This is NOT a server
189
+ * guarantee — the authoritative split is meter_source in /account/usage.
190
+ */
331
191
  export async function fetchQuestStash(apiKey) {
332
192
  let claimedQuestTier = 0;
333
193
  let firstClaimMs = Infinity;
@@ -109,12 +109,15 @@ function getCost(m, type) {
109
109
  }
110
110
  // ─── RENDU VISUEL ────────────────────────────────────────────────────────────
111
111
  function parseNameDesc(m) {
112
- const fullDesc = m.description || m.name;
113
- const parts = fullDesc.split(" - ");
112
+ if (m.title && m.description) {
113
+ return { nom: m.title, desc: m.description };
114
+ }
115
+ const displayName = m.title || m.description || m.name;
116
+ const parts = displayName.split(" - ");
114
117
  if (parts.length > 1) {
115
118
  return { nom: parts[0].trim(), desc: parts.slice(1).join(" - ").trim() };
116
119
  }
117
- return { nom: fullDesc, desc: "" };
120
+ return { nom: displayName, desc: "" };
118
121
  }
119
122
  function flags(m, overrides = []) {
120
123
  const f = [];
@@ -5,6 +5,5 @@ export function createStatusHooks(client) {
5
5
  };
6
6
  }
7
7
  function formatStatus(quota) {
8
- const tierName = quota.tier === 'alwaysfree' ? 'Free' : quota.tier;
9
- return `${tierName} ${quota.tierRemaining.toFixed(2)}/${quota.tierLimit} 🌼 | Wallet $${quota.walletBalance.toFixed(2)}`;
8
+ return `🎁 Quest ~${quota.questBalance.toFixed(2)} | 💎 Paid ~${quota.walletBalance.toFixed(2)}`;
10
9
  }
@@ -41,7 +41,7 @@ export function emitStatusToast(type, message, title, metadata) {
41
41
  getQuotaStatus(true).then(quota => {
42
42
  const quotaMsg = formatQuotaForToast
43
43
  ? formatQuotaForToast(quota)
44
- : `🌻 Freetier: ${quota.tierRemaining.toFixed(2)}/${quota.tierLimit} | Wallet: $${quota.walletBalance.toFixed(2)}`;
44
+ : `🎁 Quest: ~${quota.questBalance.toFixed(2)} | 💎 Paid: ~${quota.walletBalance.toFixed(2)}`;
45
45
  finalMessage += `\n${quotaMsg}`;
46
46
  dispatchToast('status', type, finalMessage, title || 'Pollinations Status');
47
47
  }).catch(() => {
@@ -11,6 +11,7 @@ import { polliGenVideoTool } from './pollinations/gen_video.js';
11
11
  import { polliGenAudioTool } from './pollinations/gen_audio.js';
12
12
  import { polliSttTool } from './pollinations/transcribe_audio.js';
13
13
  import { polliGenMusicTool } from './pollinations/gen_music.js';
14
+ import { polliGen3dTool } from './pollinations/gen_3d.js';
14
15
  import { polliWebSearchTool } from './pollinations/polli_web_search.js';
15
16
  import { polliBetaDiscoveryTool } from './pollinations/beta_discovery.js';
16
17
  import { polliGenConfirmTool } from './pollinations/polli_gen_confirm.js';
@@ -23,4 +24,4 @@ import { polliQuestsTool } from './pollinations/polli_quests.js';
23
24
  * @returns Record<string, Tool> to be spread into the plugin's tool: {} property
24
25
  */
25
26
  export declare function createToolRegistry(): Record<string, any>;
26
- export { polliGenImageTool, polliGenVideoTool, polliGenAudioTool, polliSttTool, polliGenMusicTool, polliWebSearchTool, polliBetaDiscoveryTool, polliGenConfirmTool, polliStatusTool, polliConfigTool, polliQuestsTool };
27
+ export { polliGenImageTool, polliGenVideoTool, polliGenAudioTool, polliSttTool, polliGenMusicTool, polliGen3dTool, polliWebSearchTool, polliBetaDiscoveryTool, polliGenConfirmTool, polliStatusTool, polliConfigTool, polliQuestsTool };
@@ -30,6 +30,7 @@ import { polliGenVideoTool } from './pollinations/gen_video.js';
30
30
  import { polliGenAudioTool } from './pollinations/gen_audio.js';
31
31
  import { polliSttTool } from './pollinations/transcribe_audio.js';
32
32
  import { polliGenMusicTool } from './pollinations/gen_music.js';
33
+ import { polliGen3dTool } from './pollinations/gen_3d.js';
33
34
  import { polliWebSearchTool } from './pollinations/polli_web_search.js';
34
35
  import { polliBetaDiscoveryTool } from './pollinations/beta_discovery.js';
35
36
  import { polliGenConfirmTool } from './pollinations/polli_gen_confirm.js';
@@ -82,6 +83,7 @@ export function createToolRegistry() {
82
83
  tools['polli_gen_audio'] = polliGenAudioTool;
83
84
  tools['polli_stt'] = polliSttTool;
84
85
  tools['polli_gen_music'] = polliGenMusicTool;
86
+ tools['polli_gen_3d'] = polliGen3dTool;
85
87
  // Unified search tool
86
88
  tools['polli_web_search'] = polliWebSearchTool;
87
89
  // Cost Guard Confirmation tool
@@ -104,4 +106,4 @@ export function createToolRegistry() {
104
106
  return tools;
105
107
  }
106
108
  // Re-export for convenience
107
- export { polliGenImageTool, polliGenVideoTool, polliGenAudioTool, polliSttTool, polliGenMusicTool, polliWebSearchTool, polliBetaDiscoveryTool, polliGenConfirmTool, polliStatusTool, polliConfigTool, polliQuestsTool };
109
+ export { polliGenImageTool, polliGenVideoTool, polliGenAudioTool, polliSttTool, polliGenMusicTool, polliGen3dTool, polliWebSearchTool, polliBetaDiscoveryTool, polliGenConfirmTool, polliStatusTool, polliConfigTool, polliQuestsTool };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Artifact Core (v6.5) — shared input/output primitives.
3
+ *
4
+ * Extracted from the Free Tools (gen_video_free.resolveAsset/mimeFor,
5
+ * imgtools buildMultipart/getDims) and generalized for Pollinations tools,
6
+ * Free services and local operations.
7
+ *
8
+ * Pipeline: resolveArtifactInput → executeOperation → retrieve bytes
9
+ * → detectArtifactType (magic bytes) → persistArtifact
10
+ *
11
+ * Invariant: saved extension follows REAL detected bytes, never a
12
+ * caller-requested format (a b64 edit response can be JPEG while the
13
+ * caller assumes PNG — see Phase 2 live evidence T14).
14
+ */
15
+ export interface ResolvedAsset {
16
+ buf: Buffer;
17
+ mime: string;
18
+ ext: string;
19
+ filename: string;
20
+ }
21
+ export interface DetectedArtifact {
22
+ format: string;
23
+ ext: string;
24
+ mime: string;
25
+ }
26
+ /** Magic-bytes detection. Returns null when the format is unknown. */
27
+ export declare function detectArtifactType(buf: Buffer): DetectedArtifact | null;
28
+ export declare function mimeForExt(ext: string): string;
29
+ export declare function mimeFor(ext: string, kind: 'image' | 'audio' | 'video' | 'model'): string;
30
+ /**
31
+ * Resolve an asset input (local path | http(s) URL | data: URI) into a
32
+ * Buffer + mime + filename. URL downloads are bounded (60s default).
33
+ */
34
+ export declare function resolveArtifactInput(input: string, kind?: 'image' | 'audio' | 'video' | 'model', timeoutMs?: number): Promise<ResolvedAsset>;
35
+ export declare function sanitizeFilename(name: string): string;
36
+ export interface PersistOptions {
37
+ outputDir: string;
38
+ filename?: string;
39
+ preferredExt?: string;
40
+ /** When true (default), real magic bytes override the preferred extension. */
41
+ detectExt?: boolean;
42
+ }
43
+ export interface PersistedArtifact {
44
+ filePath: string;
45
+ size: number;
46
+ detected: DetectedArtifact | null;
47
+ ext: string;
48
+ }
49
+ /**
50
+ * Persist artifact bytes. The written extension follows the DETECTED magic
51
+ * bytes (detectExt !== false) — never a blind caller assumption.
52
+ */
53
+ export declare function persistArtifact(buf: Buffer, opts: PersistOptions): PersistedArtifact;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Artifact Core (v6.5) — shared input/output primitives.
3
+ *
4
+ * Extracted from the Free Tools (gen_video_free.resolveAsset/mimeFor,
5
+ * imgtools buildMultipart/getDims) and generalized for Pollinations tools,
6
+ * Free services and local operations.
7
+ *
8
+ * Pipeline: resolveArtifactInput → executeOperation → retrieve bytes
9
+ * → detectArtifactType (magic bytes) → persistArtifact
10
+ *
11
+ * Invariant: saved extension follows REAL detected bytes, never a
12
+ * caller-requested format (a b64 edit response can be JPEG while the
13
+ * caller assumes PNG — see Phase 2 live evidence T14).
14
+ */
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ /** Magic-bytes detection. Returns null when the format is unknown. */
18
+ export function detectArtifactType(buf) {
19
+ if (!buf || buf.length < 4)
20
+ return null;
21
+ // JPEG
22
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
23
+ return { format: 'jpeg', ext: 'jpg', mime: 'image/jpeg' };
24
+ }
25
+ // PNG
26
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
27
+ return { format: 'png', ext: 'png', mime: 'image/png' };
28
+ }
29
+ // GIF
30
+ if (buf.toString('ascii', 0, 3) === 'GIF') {
31
+ return { format: 'gif', ext: 'gif', mime: 'image/gif' };
32
+ }
33
+ // WebP
34
+ if (buf.length > 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
35
+ return { format: 'webp', ext: 'webp', mime: 'image/webp' };
36
+ }
37
+ // glTF binary (GLB) — magic 'glTF' (0x676C5446)
38
+ if (buf.toString('ascii', 0, 4) === 'glTF') {
39
+ return { format: 'glb', ext: 'glb', mime: 'model/gltf-binary' };
40
+ }
41
+ // MP4 (ftyp box at offset 4)
42
+ if (buf.length > 12 && buf.toString('ascii', 4, 8) === 'ftyp') {
43
+ return { format: 'mp4', ext: 'mp4', mime: 'video/mp4' };
44
+ }
45
+ // WebM (EBML magic)
46
+ if (buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3) {
47
+ return { format: 'webm', ext: 'webm', mime: 'video/webm' };
48
+ }
49
+ // MP3 (ID3 tag or MPEG frame sync)
50
+ if (buf.length > 3 && (buf.toString('ascii', 0, 3) === 'ID3' || (buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0))) {
51
+ return { format: 'mp3', ext: 'mp3', mime: 'audio/mpeg' };
52
+ }
53
+ // WAV
54
+ if (buf.length > 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WAVE') {
55
+ return { format: 'wav', ext: 'wav', mime: 'audio/wav' };
56
+ }
57
+ // OGG
58
+ if (buf.toString('ascii', 0, 4) === 'OggS') {
59
+ return { format: 'ogg', ext: 'ogg', mime: 'audio/ogg' };
60
+ }
61
+ // JSON (fallback for text artifacts)
62
+ if (buf.length > 0 && (buf[0] === 0x7b || buf[0] === 0x5b)) {
63
+ return { format: 'json', ext: 'json', mime: 'application/json' };
64
+ }
65
+ return null;
66
+ }
67
+ const EXT_MIME = {
68
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp',
69
+ gif: 'image/gif', glb: 'model/gltf-binary', mp4: 'video/mp4', webm: 'video/webm',
70
+ mov: 'video/quicktime', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg',
71
+ m4a: 'audio/mp4', flac: 'audio/flac', txt: 'text/plain', json: 'application/json',
72
+ };
73
+ export function mimeForExt(ext) {
74
+ return EXT_MIME[ext.toLowerCase()] || 'application/octet-stream';
75
+ }
76
+ export function mimeFor(ext, kind) {
77
+ const e = ext.toLowerCase();
78
+ const known = EXT_MIME[e];
79
+ if (known)
80
+ return known;
81
+ if (kind === 'image')
82
+ return 'image/jpeg';
83
+ if (kind === 'audio')
84
+ return 'audio/mpeg';
85
+ if (kind === 'video')
86
+ return 'video/mp4';
87
+ return 'application/octet-stream';
88
+ }
89
+ /**
90
+ * Resolve an asset input (local path | http(s) URL | data: URI) into a
91
+ * Buffer + mime + filename. URL downloads are bounded (60s default).
92
+ */
93
+ export async function resolveArtifactInput(input, kind = 'image', timeoutMs = 60000) {
94
+ // data: URI
95
+ const dataMatch = input.match(/^data:([^;]+);base64,(.+)$/);
96
+ if (dataMatch) {
97
+ const mime = dataMatch[1];
98
+ const ext = (mime.split('/')[1] || 'bin').toLowerCase();
99
+ return { buf: Buffer.from(dataMatch[2], 'base64'), mime, ext, filename: `${kind}.${ext}` };
100
+ }
101
+ // http(s) URL — bounded fetch
102
+ if (/^https?:\/\//i.test(input)) {
103
+ const res = await fetch(input, { signal: AbortSignal.timeout(timeoutMs) });
104
+ if (!res.ok)
105
+ throw new Error(`Asset download failed: HTTP ${res.status}`);
106
+ const buf = Buffer.from(await res.arrayBuffer());
107
+ const urlExt = (input.split('?')[0].split('.').pop() || 'bin').toLowerCase();
108
+ const detected = detectArtifactType(buf);
109
+ const ext = detected ? detected.ext : urlExt;
110
+ const mime = detected ? detected.mime : mimeFor(urlExt, kind);
111
+ return { buf, mime, ext, filename: `${kind}.${ext}` };
112
+ }
113
+ // local file
114
+ if (fs.existsSync(input)) {
115
+ const buf = fs.readFileSync(input);
116
+ const detected = detectArtifactType(buf);
117
+ const nameExt = path.extname(input).toLowerCase().replace('.', '') || 'bin';
118
+ const ext = detected ? detected.ext : nameExt;
119
+ const mime = detected ? detected.mime : mimeFor(nameExt, kind);
120
+ return { buf, mime, ext, filename: path.basename(input) };
121
+ }
122
+ throw new Error(`Asset not found: ${input}`);
123
+ }
124
+ export function sanitizeFilename(name) {
125
+ return name.replace(/[^\w.\-]+/g, '_').slice(0, 120);
126
+ }
127
+ /**
128
+ * Persist artifact bytes. The written extension follows the DETECTED magic
129
+ * bytes (detectExt !== false) — never a blind caller assumption.
130
+ */
131
+ export function persistArtifact(buf, opts) {
132
+ const detected = opts.detectExt === false ? null : detectArtifactType(buf);
133
+ const ext = detected?.ext ?? opts.preferredExt ?? 'bin';
134
+ if (!fs.existsSync(opts.outputDir)) {
135
+ fs.mkdirSync(opts.outputDir, { recursive: true });
136
+ }
137
+ let filename = opts.filename ? sanitizeFilename(opts.filename) : undefined;
138
+ if (!filename) {
139
+ filename = `artifact_${Date.now()}.${ext}`;
140
+ }
141
+ else if (opts.detectExt !== false && detected) {
142
+ // STRICT: the written extension follows the real bytes — a caller
143
+ // filename like my_image.png becomes my_image.jpg for JPEG bytes.
144
+ // Filesystem path and returned ext always agree.
145
+ const dotIdx = filename.lastIndexOf('.');
146
+ if (dotIdx > 0) {
147
+ filename = filename.slice(0, dotIdx) + '.' + ext;
148
+ }
149
+ else {
150
+ filename = `${filename}.${ext}`;
151
+ }
152
+ }
153
+ else if (!filename.includes('.')) {
154
+ filename = `${filename}.${ext}`;
155
+ }
156
+ const filePath = path.join(opts.outputDir, filename);
157
+ fs.writeFileSync(filePath, buf);
158
+ return { filePath, size: buf.length, detected, ext };
159
+ }