opencode-pollinations-plugin 6.3.0 → 6.4.2

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 CHANGED
@@ -1,6 +1,6 @@
1
- # 🌸 Pollinations AI Plugin für OpenCode (v6.3.0)
1
+ # 🌸 Pollinations AI Plugin für OpenCode (v6.4)
2
2
 
3
- ## ✨ Neu in v6.3.0
3
+ ## ✨ Neu in v6.4.0
4
4
 
5
5
  - 🎯 **Quests & Gamification** : `polli_quests` + `/poll quests` — sieh deine Quests und das **einlösbare kostenlose Pollen**. Die Nutzung des Plugins schließt Quests rückwirkend ab!
6
6
  - 🆓 **Kostenlose Bild- & Videogenerierung (ohne Schlüssel!)** : `gen_edit_image_free` (erstellen + bearbeiten, ~20/Tag) und `gen_video_free` (Text→Video + Bild/Audio, ~5/Tag), für jedes Modell.
@@ -35,7 +35,7 @@
35
35
 
36
36
  ---
37
37
 
38
- ## ✨ Was ist neu in V6.2.7?
38
+ ## ✨ Was ist neu in V6.4?
39
39
 
40
40
  - ⏱️ **Stündliche Quoten**: Verabschieden Sie sich von täglichen Limits! Developer Tiers werden jetzt **jede Stunde** pünktlich um `:00` zurückgesetzt. So haben Sie bei langen Code-Sitzungen immer frische Credits.
41
41
  - ⚡ **100% Dynamische Engine**: Keine hartcodierten Modelllisten, festen Standardkonfigurationen und Preise mehr! In V6.2 ruft der KI-Agent von OpenCode dynamisch die neuesten LLMs, Parameter, Tags (`[💎 Paid]`, `[🌿 Free]`) und Kostenabschätzungen über die Pollinations-APIs ab.
package/README.es.md CHANGED
@@ -1,6 +1,6 @@
1
- # 🌸 Pollinations AI Plugin para OpenCode (v6.3.0)
1
+ # 🌸 Pollinations AI Plugin para OpenCode (v6.4)
2
2
 
3
- ## ✨ Novedades v6.3.0
3
+ ## ✨ Novedades v6.4.0
4
4
 
5
5
  - 🎯 **Misiones y Gamificación** : `polli_quests` + `/poll quests` — mira tus misiones y el **Pollen gratis por reclamar**. ¡Usar el plugin completa misiones retroactivamente!
6
6
  - 🆓 **Imagen y Vídeo gratis (¡sin clave!)** : `gen_edit_image_free` (generar + editar, ~20/día) y `gen_video_free` (texto→vídeo + imagen/audio, ~5/día), para cualquier modelo.
@@ -35,7 +35,7 @@
35
35
 
36
36
  ---
37
37
 
38
- ## ✨ Novedades de la V6.2.7
38
+ ## ✨ Novedades de la V6.4
39
39
 
40
40
  - ⏱️ **Cuotas por Hora**: ¡Dile adiós a los límites diarios! Los Tiers de Desarrollador se recargan **cada hora** exacta (a la hora en punto `:00`), asegurando que siempre tengas créditos frescos para tus largas jornadas de código.
41
41
  - ⚡ **Motor 100% Dinámico**: ¡Se acabaron las listas de modelos quemadas en el código y los precios estáticos! En V6.2, el agente obtiene dinámicamente los últimos LLMs, parámetros, etiquetas (`[💎 Paid]`, `[🌿 Free]`) y costes aproximados conectándose continuamente a la API de Pollinations.
package/README.fr.md CHANGED
@@ -1,6 +1,6 @@
1
- # 🌸 Plugin Pollinations AI pour OpenCode (v6.3.0)
1
+ # 🌸 Plugin Pollinations AI pour OpenCode (v6.4)
2
2
 
3
- ## ✨ Nouveautés v6.3.0
3
+ ## ✨ Nouveautés v6.4.0
4
4
 
5
5
  - 🎯 **Quêtes & Gamification** : `polli_quests` + `/poll quests` — voyez vos quêtes et le **Pollen gratuit à réclamer**. Utiliser le plugin complète des quêtes rétroactivement !
6
6
  - 🆓 **Image & Vidéo gratuites (sans clé !)** : `gen_edit_image_free` (génération + édition, ~20/j) et `gen_video_free` (texte→vidéo + image/audio, ~5/j), pour n'importe quel modèle.
@@ -35,7 +35,7 @@
35
35
 
36
36
  ---
37
37
 
38
- ## ✨ Nouveautés de la V6.2.7
38
+ ## ✨ Nouveautés de la V6.4
39
39
 
40
40
  - ⏱️ **Quotas Horaires** : Dites adieu aux limites journalières ! Les Tiers Développeurs se réinitialisent désormais **toutes les heures** pile (à `:00`), vous assurant d'avoir toujours des crédits frais disponibles pendant vos longues sessions de code.
41
41
  - ⚡ **Moteur 100% Dynamique** : Fini les listes de modèles codées en dur, les configurations par défaut et les prix fixes ! L'agent IA récupère dynamiquement les derniers LLMs, paramètres, tags (`[💎 Paid]`, `[🌿 Free]`) et coûts approximatifs depuis les API Pollinations.
package/README.it.md CHANGED
@@ -1,6 +1,6 @@
1
- # 🌸 Pollinations AI Plugin per OpenCode (v6.3.0)
1
+ # 🌸 Pollinations AI Plugin per OpenCode (v6.4)
2
2
 
3
- ## ✨ Novità v6.3.0
3
+ ## ✨ Novità v6.4.0
4
4
 
5
5
  - 🎯 **Quest e Gamification** : `polli_quests` + `/poll quests` — guarda le tue quest e il **Pollen gratuito da riscattare**. Usare il plugin completa quest retroattivamente!
6
6
  - 🆓 **Immagini e Video gratis (senza chiave!)** : `gen_edit_image_free` (genera + modifica, ~20/giorno) e `gen_video_free` (testo→video + immagine/audio, ~5/giorno), per qualsiasi modello.
@@ -35,7 +35,7 @@
35
35
 
36
36
  ---
37
37
 
38
- ## ✨ Novità della V6.2.7
38
+ ## ✨ Novità della V6.4
39
39
 
40
40
  - ⏱️ **Quote Orarie**: Addio ai limiti giornalieri! I Tier Sviluppatore ora si ricaricano **ogni singola ora** in punto (`:00`), garantendoti sempre crediti freschi durante le tue sessioni di programmazione.
41
41
  - ⚡ **Motore 100% Dinamico**: Fine delle liste di modelli hard-coded, delle configurazioni fisse e dei prezzi statici! Nella V6.2, l'agente AI di OpenCode recupera dinamicamente gli ultimi LLM, parametri, tag (`[💎 Paid]`, `[🌿 Free]`) e stime dei costi dalle API di Pollinations.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # 🌸 Pollinations AI Plugin for OpenCode (v6.3.0)
1
+ # 🌸 Pollinations AI Plugin for OpenCode (v6.4)
2
2
 
3
3
  <div align="center">
4
4
  <img src="https://avatars.githubusercontent.com/u/88394740?s=400&v=4" alt="Pollinations.ai Logo" width="180">
@@ -27,7 +27,7 @@
27
27
 
28
28
  ---
29
29
 
30
- ## ✨ What's new in V6.3.0?
30
+ ## ✨ What's new in v6.4?
31
31
 
32
32
  - 🎯 **Quests & Gamification**: New `polli_quests` tool + `/poll quests` command. See your Pollinations quests by category and the **free Pollen waiting to be claimed**. Just using this plugin (text, image & audio models) completes several quests **retroactively** — you may already have free Pollen waiting!
33
33
  - 🆓 **Free Image & Video Tools (no key required!)**: Two brand-new bonus tools usable by **any** OpenCode model, even without a Pollinations account:
package/README.zh.md CHANGED
@@ -1,6 +1,6 @@
1
- # 🌸 Pollinations AI 插件 - OpenCode版 (v6.3.0)
1
+ # 🌸 Pollinations AI 插件 - OpenCode版 (v6.4)
2
2
 
3
- ## ✨ v6.3.0 新功能
3
+ ## ✨ v6.4.0 新功能
4
4
 
5
5
  - 🎯 **任务与游戏化** :`polli_quests` + `/poll quests` — 查看你的任务和**可领取的免费 Pollen**。使用本插件会追溯性地完成多个任务!
6
6
  - 🆓 **免费图像和视频 (无需密钥!)** :`gen_edit_image_free` (生成 + 编辑,约 20/天) 和 `gen_video_free` (文本→视频 + 图像/音频,约 5/天),适用于任何模型。
@@ -35,7 +35,7 @@
35
35
 
36
36
  ---
37
37
 
38
- ## ✨ V6.2.7 新篇章
38
+ ## ✨ V6.4 新篇章
39
39
 
40
40
  - ⏱️ **小时级额度刷新**: 告别漫长的每日限额等待!现有的开发者等级现在将在 **每个整点** (`:00`) 清零并重新下放属于你的花粉(Pollen)。这确保您的代码生成和创意测试不被中断!
41
41
  - ⚡ **100% 动态云端引擎**: 在 V6.2 中,代理助手如今能够自主向 Pollinations API 前台发送查询请求,自动读取并注入全网最新的 AI 模型表 (`[💎 Paid]`, `[🌿 Free]`) 、动态最高限额与实时期望消费数据,硬编码的时代宣告结束。
@@ -62,7 +62,9 @@
62
62
  "table_head2": "|--------|-------|--------|--------|\n",
63
63
  "no_history": "*Keine Aktivität in diesem Zeitraum.*\n",
64
64
  "full_requires_key": "⚠️ `/poll usage full` erfordert einen API-Schlüssel.\n",
65
- "hint_full": "💡 Verwenden Sie `/poll usage full` für Details."
65
+ "hint_full": "💡 Verwenden Sie `/poll usage full` für Details.",
66
+ "source_split": "- **Diese Periode verbraucht:** 🎁 Quest {tier} · 💎 Bezahlt {pack}\n",
67
+ "split": "- **Guthaben:** {total} 🌻 · 🎁 Quest ~{quest} · 💎 Bezahlt ~{paid}\n\n"
66
68
  },
67
69
  "fallback": {
68
70
  "current": "Current fallbacks:\n- {free}\n- {enter}",
@@ -62,7 +62,9 @@
62
62
  "table_head2": "|-------|------|------|--------|\n",
63
63
  "no_history": "*No activity in the current period.*\n",
64
64
  "full_requires_key": "⚠️ `/poll usage full` requires a connected API key.\n",
65
- "hint_full": "💡 Use `/poll usage full` for details (requires key)."
65
+ "hint_full": "💡 Use `/poll usage full` for details (requires key).",
66
+ "source_split": "- **Consumed this period:** 🎁 Quest {tier} · 💎 Paid {pack}\n",
67
+ "split": "- **Balance:** {total} 🌻 · 🎁 Quest ~{quest} · 💎 Paid ~{paid}\n\n"
66
68
  },
67
69
  "fallback": {
68
70
  "current": "Current fallbacks:\n- {free}\n- {enter}",
@@ -62,7 +62,9 @@
62
62
  "table_head2": "|--------|--------|-------|--------|\n",
63
63
  "no_history": "*Sin actividad en el período actual.*\n",
64
64
  "full_requires_key": "⚠️ `/poll usage full` requiere una clave API conectada.\n",
65
- "hint_full": "💡 Usa `/poll usage full` para ver detalles (requiere clave)."
65
+ "hint_full": "💡 Usa `/poll usage full` para ver detalles (requiere clave).",
66
+ "source_split": "- **Consumido este período:** 🎁 Misiones {tier} · 💎 Pagado {pack}\n",
67
+ "split": "- **Saldo:** {total} 🌻 · 🎁 Misiones ~{quest} · 💎 Pagado ~{paid}\n\n"
66
68
  },
67
69
  "fallback": {
68
70
  "current": "Current fallbacks:\n- {free}\n- {enter}",
@@ -62,7 +62,9 @@
62
62
  "table_head2": "|--------|------|------|--------|\n",
63
63
  "no_history": "*Aucune activité dans la période en cours.*\n",
64
64
  "full_requires_key": "⚠️ `/poll usage full` nécessite une clé API connectée.\n",
65
- "hint_full": "💡 Utilisez `/poll usage full` pour les détails (nécessite une clé)."
65
+ "hint_full": "💡 Utilisez `/poll usage full` pour les détails (nécessite une clé).",
66
+ "source_split": "- **Consommé cette période :** 🎁 Quêtes {tier} · 💎 Payé {pack}\n",
67
+ "split": "- **Solde :** {total} 🌻 · 🎁 Quêtes ~{quest} · 💎 Payé ~{paid}\n\n"
66
68
  },
67
69
  "fallback": {
68
70
  "current": "Fallbacks actuels:\n- {free}\n- {enter}",
@@ -62,7 +62,9 @@
62
62
  "table_head2": "|---------|-------|-------|-------|\n",
63
63
  "no_history": "*Nessuna attività in questo periodo.*\n",
64
64
  "full_requires_key": "⚠️ `/poll usage full` richiede una chiave API.\n",
65
- "hint_full": "💡 Usa `/poll usage full` per i dettagli."
65
+ "hint_full": "💡 Usa `/poll usage full` per i dettagli.",
66
+ "source_split": "- **Consumato questo periodo:** 🎁 Quest {tier} · 💎 Pagato {pack}\n",
67
+ "split": "- **Saldo:** {total} 🌻 · 🎁 Quest ~{quest} · 💎 Pagato ~{paid}\n\n"
66
68
  },
67
69
  "fallback": {
68
70
  "current": "Current fallbacks:\n- {free}\n- {enter}",
@@ -62,7 +62,9 @@
62
62
  "table_head2": "|-------|------|------|--------|\n",
63
63
  "no_history": "*当前周期内无活动。*\n",
64
64
  "full_requires_key": "⚠️ `/poll usage full` 需要已连接的 API 密钥。\n",
65
- "hint_full": "💡 使用 `/poll usage full` 查看详情 (需要密钥)。"
65
+ "hint_full": "💡 使用 `/poll usage full` 查看详情 (需要密钥)。",
66
+ "source_split": "- **本期已消耗:** 🎁 任务 {tier} · 💎 付费 {pack}\n",
67
+ "split": "- **余额:** {total} 🌻 · 🎁 任务 ~{quest} · 💎 付费 ~{paid}\n\n"
66
68
  },
67
69
  "fallback": {
68
70
  "current": "当前回退:\n- {free}\n- {enter}",
@@ -7,6 +7,27 @@ import { ModelRegistry } from './models/index.js';
7
7
  import { t } from '../locales/index.js';
8
8
  import { formatTierTable } from './tier-info.js';
9
9
  import { buildQuestsReport } from '../tools/pollinations/polli_quests.js';
10
+ // GET helper for /account/* JSON endpoints (used for quest-reward reconstruction).
11
+ function fetchAccountJson(path, apiKey) {
12
+ return new Promise((resolve, reject) => {
13
+ const req = https.request({
14
+ hostname: 'gen.pollinations.ai', path, method: 'GET',
15
+ headers: { 'Authorization': `Bearer ${apiKey}`, 'User-Agent': 'opencode-pollinations-plugin' },
16
+ }, (res) => {
17
+ let data = '';
18
+ res.on('data', c => data += c);
19
+ res.on('end', () => { try {
20
+ resolve(JSON.parse(data));
21
+ }
22
+ catch (e) {
23
+ reject(e);
24
+ } });
25
+ });
26
+ req.on('error', reject);
27
+ req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
28
+ req.end();
29
+ });
30
+ }
10
31
  function checkEndpoint(ep, key) {
11
32
  return new Promise((resolve) => {
12
33
  const req = https.request({
@@ -241,7 +262,48 @@ export async function handleUsageCommand(args) {
241
262
  response += t('commands.usage.tier', { emoji: quota.tierEmoji, tier: quota.tier.toUpperCase(), limit: quota.tierLimit });
242
263
  response += t('commands.usage.quota', { remaining: formatPollen(quota.tierLimit - quota.tierRemaining), limit: formatPollen(quota.tierLimit) });
243
264
  response += t('commands.usage.usage_bar', { bar: progressBar(quota.tierLimit - quota.tierRemaining, quota.tierLimit) });
244
- response += t('commands.usage.wallet', { balance: quota.walletBalance.toFixed(2) });
265
+ // Reconstructed Quest/Paid split by CROSS-REFERENCING exact data (no magic field needed):
266
+ // • claimed quest rewards (tier bucket) ← /account/quests (exact)
267
+ // • tier consumption since the first claim ← /account/usage meter_source=='tier' (exact)
268
+ // • current-hour floor remaining (≤ tierLimit) ← quota (the only fuzzy term, ignored)
269
+ // Quest remaining ≈ claimedQuestTier − tierConsumedSinceClaim (+ floor); Paid = Total − Quest.
270
+ const total = quota.tierRemaining + quota.walletBalance; // true balance
271
+ let claimedQuestTier = 0;
272
+ let firstClaimMs = Infinity;
273
+ let tierConsumedSinceClaim = 0;
274
+ if (config.apiKey && config.keyHasAccessToProfile !== false) {
275
+ try {
276
+ const qres = await fetchAccountJson('/account/quests', config.apiKey);
277
+ for (const q of (qres?.quests || [])) {
278
+ const r = q.reward;
279
+ if (r && r.claimedAt && r.balanceBucket === 'tier') {
280
+ claimedQuestTier += (r.pollenAmount || 0);
281
+ const cms = new Date(r.claimedAt).getTime();
282
+ if (!isNaN(cms) && cms < firstClaimMs)
283
+ firstClaimMs = cms;
284
+ }
285
+ }
286
+ // Sum tier-metered spend since the first claim (what actually ate the Quest stash).
287
+ if (claimedQuestTier > 0 && isFinite(firstClaimMs)) {
288
+ const ures = await fetchAccountJson('/account/usage?limit=100', config.apiKey);
289
+ for (const e of (ures?.usage || [])) {
290
+ const ts = new Date(String(e.timestamp).replace(' ', 'T') + (String(e.timestamp).includes('Z') ? '' : 'Z')).getTime();
291
+ if (e.meter_source === 'tier' && !isNaN(ts) && ts >= firstClaimMs) {
292
+ tierConsumedSinceClaim += (e.cost_usd || 0);
293
+ }
294
+ }
295
+ }
296
+ }
297
+ catch { /* fall back gracefully if unreachable */ }
298
+ }
299
+ // Quest stash = claimed − consumed, plus the current hourly floor still available.
300
+ const questPollen = Math.max(0, claimedQuestTier - tierConsumedSinceClaim) + quota.tierRemaining;
301
+ const paidPollen = Math.max(0, total - questPollen);
302
+ response += t('commands.usage.split', {
303
+ quest: questPollen.toFixed(2),
304
+ paid: paidPollen.toFixed(2),
305
+ total: total.toFixed(2),
306
+ });
245
307
  response += t('commands.usage.reset', { date: resetDate, duration: durationStr });
246
308
  if (isFull && config.apiKey) {
247
309
  if (config.keyHasAccessToProfile === false) {
@@ -254,6 +316,12 @@ export async function handleUsageCommand(args) {
254
316
  const stats = calculateCurrentPeriodStats(usageData, lastReset, quota.tierLimit);
255
317
  response += t('commands.usage.period_detail', { time: lastReset.toLocaleTimeString() });
256
318
  response += t('commands.usage.total_reqs', { reqs: stats.totalRequests, inTok: formatTokens(stats.inputTokens), outTok: formatTokens(stats.outputTokens) });
319
+ // Exact consumption split by meter_source (tier = Quest Pollen, pack = Paid).
320
+ // This is the ONLY reliable split the API exposes (remaining split is dashboard/cookie-only).
321
+ response += t('commands.usage.source_split', {
322
+ tier: formatPollen(stats.tierUsed),
323
+ pack: formatPollen(stats.packUsed),
324
+ });
257
325
  response += t('commands.usage.table_head1');
258
326
  response += t('commands.usage.table_head2');
259
327
  const sorted = Array.from(stats.models.entries()).sort((a, b) => b[1].cost - a[1].cost);
@@ -587,6 +655,7 @@ ${t('commands.config.table_divider')}
587
655
  | **costConfirmationRequired**| \`${config.costConfirmationRequired ?? true}\` | ${t('commands.config.costConfirmationRequired_role')} | \`/poll config costConfirmationRequired <true/false>\` |
588
656
  | **costThreshold**| \`${config.costThreshold ?? 0.15} 🌻\` | ${t('commands.config.costThreshold_role')} | \`/poll config costThreshold <X>\` |
589
657
  | **cost_estimator**| \`${config.costEstimator ?? true}\` | ${t('commands.config.cost_estimator_role')} | \`/poll config cost_estimator <true/false>\` |
658
+ | **refillOverride**| \`${config.refillOverride ?? 'auto (déduit)'}\` | Quest Pollen refill horaire | \`/poll config refillOverride <0.01/0.15/0.4/0.8/10>\` |
590
659
  | **fallbacks.free.main** | \`${config.fallbacks?.free?.main || 'free/mistral'}\` | ${t('commands.config.fallback_main_role')} | \`/poll fallback <main> <agent>\` |
591
660
  | **fallbacks.free.agent** | \`${config.fallbacks?.free?.agent || 'free/openai-fast'}\`| ${t('commands.config.fallback_agent_role')} | \`/poll fallback <main> <agent>\` |
592
661
  | **fallbacks.enter.agent** | \`${config.fallbacks?.enter?.agent || 'free/openai-fast'}\`| ${t('commands.config.fallback_enter_role')} | *${t('commands.config.managed_auto')}* |
@@ -683,9 +752,21 @@ ${t('commands.config.table_divider')}
683
752
  saveConfig({ costConfirmationRequired: enabled });
684
753
  return { handled: true, response: `✅ costConfirmationRequired = ${enabled}` };
685
754
  }
755
+ if (key === 'refillOverride' && value) {
756
+ if (value === 'auto') {
757
+ saveConfig({ refillOverride: undefined });
758
+ return { handled: true, response: '✅ refillOverride = auto (déduction automatique)' };
759
+ }
760
+ const override = parseFloat(value);
761
+ if (isNaN(override) || ![0.01, 0.15, 0.4, 0.8, 10].includes(override)) {
762
+ return { handled: true, error: 'Valeurs supportées: auto, 0.01, 0.15, 0.4, 0.8, 10' };
763
+ }
764
+ saveConfig({ refillOverride: override });
765
+ return { handled: true, response: `✅ refillOverride = ${override} 🌻/h` };
766
+ }
686
767
  return {
687
768
  handled: true,
688
- error: `Clé inconnue: ${key}. Clés: status_gui, logs_gui, threshold_tier, threshold_wallet, status_bar, cost_estimator, enablePaidTools, costThreshold, costConfirmationRequired, lang`
769
+ error: `Clé inconnue: ${key}. Clés: status_gui, logs_gui, threshold_tier, threshold_wallet, status_bar, cost_estimator, enablePaidTools, costThreshold, costConfirmationRequired, refillOverride, lang`
689
770
  };
690
771
  }
691
772
  function handleHelpCommand() {
@@ -28,6 +28,7 @@ export interface PollinationsConfigV5 {
28
28
  costConfirmationRequired: boolean;
29
29
  statusBar: boolean;
30
30
  costEstimator: boolean;
31
+ refillOverride?: number;
31
32
  lang?: string;
32
33
  }
33
34
  export declare function loadConfig(): PollinationsConfigV5;
@@ -58,6 +59,7 @@ export declare function saveConfig(updates: Partial<PollinationsConfigV5>): {
58
59
  costConfirmationRequired: boolean;
59
60
  statusBar: boolean;
60
61
  costEstimator: boolean;
62
+ refillOverride?: number;
61
63
  lang?: string;
62
64
  };
63
65
  export declare function saveKeyToAuthJson(key: string): boolean;
@@ -1,65 +1,147 @@
1
- import * as https from 'https'; // Use Native HTTPS
1
+ import * as https from 'https';
2
2
  import { loadConfig } from './config.js';
3
3
  // === CACHE & CONSTANTS ===
4
- const CACHE_TTL = 30000; // 30 secondes
4
+ const CACHE_TTL = 30000;
5
5
  let cachedQuota = null;
6
6
  let lastQuotaFetch = 0;
7
7
  const ONE_HOUR_MS = 60 * 60 * 1000;
8
- const HISTORY_RETENTION_MS = 48 * 60 * 60 * 1000; // 48h history
9
- // === TIER LIMITS (HOURLY) ===
10
- // https://pollinations.ai/pricing - Quotas refreshed every hour
11
- // Source of truth mirrored from official pollinations/shared/tier-config.ts (2026-06)
12
- // cadence 'none' => no hourly refill => API returns nextResetAt: null
13
- const TIER_LIMITS = {
14
- anonymous: { pollen: 0, emoji: '👤', cadence: 'none' },
15
- microbe: { pollen: 0, emoji: '🦠', cadence: 'none' },
16
- spore: { pollen: 0.01, emoji: '🍄', cadence: 'hourly' },
17
- seed: { pollen: 0.15, emoji: '🌱', cadence: 'hourly' },
18
- flower: { pollen: 0.4, emoji: '🌸', cadence: 'hourly' },
19
- nectar: { pollen: 0.8, emoji: '🍯', cadence: 'hourly' },
20
- router: { pollen: 10, emoji: '🐝', cadence: 'hourly' },
21
- };
8
+ const KNOWN_REFILLS = [
9
+ { pollen: 0, emoji: '👤', label: 'anonymous' },
10
+ { pollen: 0.01, emoji: '🍄', label: 'spore' },
11
+ { pollen: 0.15, emoji: '🌱', label: 'seed' },
12
+ { pollen: 0.4, emoji: '🌸', label: 'flower' },
13
+ { pollen: 0.8, emoji: '🍯', label: 'nectar' },
14
+ { pollen: 10, emoji: '🐝', label: 'router' },
15
+ ];
22
16
  // === LOGGING ===
23
17
  import { logApi } from './logger.js';
24
18
  function logQuota(msg) {
25
19
  logApi(`[QUOTA] ${msg}`);
26
20
  }
27
- // === SMART FETCH API ===
21
+ // === SMART FETCH API (cursor-based) ===
28
22
  export async function fetchUsageForPeriod(apiKey, lastReset) {
29
23
  let allUsage = [];
30
- let offset = 0;
31
- const limit = 100; // Bulk fetch
24
+ const limit = 100;
25
+ let cursorEventId = null;
32
26
  while (true) {
27
+ let queryPath = `/account/usage?limit=${limit}`;
28
+ if (cursorEventId) {
29
+ queryPath += `&before_event_id=${encodeURIComponent(cursorEventId)}`;
30
+ }
33
31
  let usageRes;
34
32
  try {
35
- usageRes = await fetchAPI(`/account/usage?limit=${limit}&offset=${offset}`, apiKey);
33
+ usageRes = await fetchAPI(queryPath, apiKey);
36
34
  }
37
35
  catch (e) {
38
- logQuota(`SmartFetch failed at offset ${offset}: ${e}`);
36
+ logQuota(`SmartFetch failed: ${e}`);
39
37
  break;
40
38
  }
41
39
  if (!usageRes.usage || usageRes.usage.length === 0) {
42
- break; // No more records
40
+ break;
43
41
  }
44
42
  let reachedCutoff = false;
45
43
  for (const entry of usageRes.usage) {
46
- const timestampStr = entry.timestamp.includes('Z') ? entry.timestamp : entry.timestamp.replace(' ', 'T') + 'Z';
44
+ const timestampStr = entry.timestamp.includes('Z')
45
+ ? entry.timestamp
46
+ : entry.timestamp.replace(' ', 'T') + 'Z';
47
47
  const entryTime = new Date(timestampStr);
48
48
  if (entryTime < lastReset) {
49
49
  reachedCutoff = true;
50
- break; // Entry is from previous period, stop.
50
+ break;
51
51
  }
52
52
  allUsage.push(entry);
53
53
  }
54
- // If we found an entry older than lastReset, or if the page was not full, we reached the end.
55
54
  if (reachedCutoff || usageRes.usage.length < limit) {
56
55
  break;
57
56
  }
58
- offset += limit;
57
+ const lastEntry = usageRes.usage[usageRes.usage.length - 1];
58
+ cursorEventId = lastEntry.cursor_event_id || null;
59
+ if (!cursorEventId)
60
+ break;
59
61
  }
60
62
  logQuota(`SmartFetch: Retrieved ${allUsage.length} transactions for current period.`);
61
63
  return allUsage;
62
64
  }
65
+ // === ALLOWANCE DEDUCTION ===
66
+ async function fetchUsageForAllowance(apiKey) {
67
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * ONE_HOUR_MS);
68
+ const allUsage = [];
69
+ let cursorEventId = null;
70
+ let pageCount = 0;
71
+ const maxPages = 20;
72
+ while (pageCount < maxPages) {
73
+ let path = `/account/usage?days=7&limit=500`;
74
+ if (cursorEventId) {
75
+ path += `&before_event_id=${encodeURIComponent(cursorEventId)}`;
76
+ }
77
+ const res = await fetchAPI(path, apiKey);
78
+ if (!res.usage || res.usage.length === 0)
79
+ break;
80
+ let reachedCutoff = false;
81
+ for (const entry of res.usage) {
82
+ const ts = (entry.timestamp.includes('Z') ? entry.timestamp : entry.timestamp.replace(' ', 'T') + 'Z');
83
+ if (new Date(ts) < sevenDaysAgo) {
84
+ reachedCutoff = true;
85
+ break;
86
+ }
87
+ allUsage.push(entry);
88
+ }
89
+ if (reachedCutoff || res.usage.length < 500)
90
+ break;
91
+ const last = res.usage[res.usage.length - 1];
92
+ cursorEventId = last?.cursor_event_id || null;
93
+ if (!cursorEventId)
94
+ break;
95
+ pageCount++;
96
+ }
97
+ logQuota(`deduceAllowance: fetched ${allUsage.length} records over ${pageCount + 1} pages`);
98
+ return allUsage;
99
+ }
100
+ async function deduceAllowanceFromApi(apiKey) {
101
+ try {
102
+ const allUsage = await fetchUsageForAllowance(apiKey);
103
+ if (allUsage.length === 0)
104
+ return 0;
105
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * ONE_HOUR_MS);
106
+ const hourlyBuckets = new Map();
107
+ for (const entry of allUsage) {
108
+ if (entry.meter_source !== 'tier')
109
+ continue;
110
+ const ts = entry.timestamp.includes('Z') ? entry.timestamp : entry.timestamp.replace(' ', 'T') + 'Z';
111
+ const entryTime = new Date(ts);
112
+ if (entryTime < sevenDaysAgo)
113
+ continue;
114
+ const hourKey = entryTime.getUTCFullYear() * 1000000
115
+ + entryTime.getUTCMonth() * 10000
116
+ + entryTime.getUTCDate() * 100
117
+ + entryTime.getUTCHours();
118
+ hourlyBuckets.set(hourKey, (hourlyBuckets.get(hourKey) || 0) + entry.cost_usd);
119
+ }
120
+ const maxHourlyTier = Math.max(0, ...hourlyBuckets.values());
121
+ const match = KNOWN_REFILLS.slice().reverse().find(r => r.pollen <= maxHourlyTier + 0.02);
122
+ logQuota(`deduceAllowance: ${allUsage.length} records, ${hourlyBuckets.size} hourly buckets, max=${maxHourlyTier.toFixed(4)}, deduced=${match?.pollen ?? 0} (${match?.label})`);
123
+ return match ? match.pollen : 0;
124
+ }
125
+ catch (e) {
126
+ logQuota(`deduceAllowance failed: ${e}`);
127
+ return 0;
128
+ }
129
+ }
130
+ function deduceAllowanceFromUsage(usage) {
131
+ const tierCosts = usage
132
+ .filter(u => u.meter_source === 'tier')
133
+ .map(u => u.cost_usd);
134
+ const maxHourlyTier = Math.max(0, ...tierCosts);
135
+ const match = KNOWN_REFILLS.slice().reverse().find(r => r.pollen <= maxHourlyTier + 0.02);
136
+ return match ? match.pollen : 0;
137
+ }
138
+ function tierMetaForAllowance(allowance) {
139
+ const match = KNOWN_REFILLS.find(r => r.pollen === allowance)
140
+ || KNOWN_REFILLS.findLast(r => r.pollen <= allowance);
141
+ return match
142
+ ? { label: match.label, emoji: match.emoji }
143
+ : { label: 'unknown', emoji: '❓' };
144
+ }
63
145
  // === MAIN QUOTA FUNCTION ===
64
146
  export async function getQuotaStatus(forceRefresh = false) {
65
147
  const config = loadConfig();
@@ -72,33 +154,29 @@ export async function getQuotaStatus(forceRefresh = false) {
72
154
  }
73
155
  try {
74
156
  logQuota("Fetching Quota Data...");
75
- // 1. Fetch API
76
- // SEQUENTIAL FETCH (Avoid Rate Limits)
77
- const profileRes = await fetchAPI('/account/profile', config.apiKey);
78
157
  const balanceRes = await fetchAPI('/account/balance', config.apiKey);
79
- const profile = profileRes;
80
- const balance = balanceRes.balance;
81
- // 2. Convertir Timezone : Obtenir instant exact du Reset
82
- const resetInfo = calculateResetInfo(profile.nextResetAt);
83
- logQuota(`Fetch Success. Tier: ${profile.tier}, Balance: ${balance}, Next Reset: ${profile.nextResetAt}`);
84
- // 3. Smart Fetch : Récupérer uniquement les dépenses du jour (depuis lastReset)
158
+ const resetInfo = calculateResetInfo();
85
159
  const periodUsage = await fetchUsageForPeriod(config.apiKey, resetInfo.lastReset);
86
- const tierInfo = TIER_LIMITS[profile.tier] || { pollen: 0, emoji: '❓', cadence: 'none' };
87
- const tierLimit = tierInfo.pollen;
88
- // 4. Calcul Strict FreeTier / Wallet
160
+ const allowance = balanceRes.allowance
161
+ ?? config.refillOverride
162
+ ?? await deduceAllowanceFromApi(config.apiKey);
163
+ const tierMeta = tierMetaForAllowance(allowance);
164
+ const tierLimit = allowance;
89
165
  const { tierUsed } = calculateCurrentPeriodUsage(periodUsage, resetInfo);
90
- // 4. Calculate Balances
91
166
  const tierRemaining = Math.max(0, tierLimit - tierUsed);
92
- // Fix rounding errors
93
167
  const cleanTierRemaining = Math.max(0, parseFloat(tierRemaining.toFixed(4)));
94
- // Le wallet c'est le reste (balance totale - ce qu'il reste du tier gratuit non consommé)
95
- // Formula: Pollinations Balance = Wallet + TierRemaining.
96
- const walletBalance = Math.max(0, balance - cleanTierRemaining);
97
- const cleanWalletBalance = Math.max(0, parseFloat(walletBalance.toFixed(4)));
98
- // needsAlert: check BOTH tier threshold AND wallet threshold
168
+ const totalBalance = balanceRes.total
169
+ ?? balanceRes.balance
170
+ ?? 0;
171
+ const paidPollenNative = balanceRes.pack;
172
+ const paidPollen = paidPollenNative !== undefined
173
+ ? paidPollenNative
174
+ : Math.max(0, totalBalance - cleanTierRemaining);
175
+ const cleanWalletBalance = Math.max(0, parseFloat(paidPollen.toFixed(4)));
99
176
  const tierAlertPercent = tierLimit > 0 ? (cleanTierRemaining / tierLimit * 100) : 0;
100
177
  const tierNeedsAlert = tierLimit > 0 && tierAlertPercent <= config.thresholds.tier;
101
178
  const walletNeedsAlert = cleanWalletBalance > 0 && cleanWalletBalance < (config.thresholds.wallet || 0.5);
179
+ logQuota(`Fetch Success. Allowance: ${allowance}, Paid: ${cleanWalletBalance}, Total: ${totalBalance}`);
102
180
  cachedQuota = {
103
181
  tierRemaining: cleanTierRemaining,
104
182
  tierUsed,
@@ -109,8 +187,8 @@ export async function getQuotaStatus(forceRefresh = false) {
109
187
  canUseEnterprise: cleanTierRemaining > 0.05 || cleanWalletBalance > 0.05,
110
188
  isUsingWallet: cleanTierRemaining <= 0.05 && cleanWalletBalance > 0.05,
111
189
  needsAlert: tierNeedsAlert || walletNeedsAlert,
112
- tier: profile.tier,
113
- tierEmoji: tierInfo.emoji
190
+ tier: tierMeta.label,
191
+ tierEmoji: tierMeta.emoji
114
192
  };
115
193
  lastQuotaFetch = now;
116
194
  return cachedQuota;
@@ -126,6 +204,7 @@ export async function getQuotaStatus(forceRefresh = false) {
126
204
  }
127
205
  }
128
206
  function createDefaultQuota(tierName, limit) {
207
+ const meta = tierMetaForAllowance(limit);
129
208
  return {
130
209
  tierRemaining: 0,
131
210
  tierUsed: 0,
@@ -136,8 +215,8 @@ function createDefaultQuota(tierName, limit) {
136
215
  canUseEnterprise: false,
137
216
  isUsingWallet: false,
138
217
  needsAlert: false,
139
- tier: tierName,
140
- tierEmoji: TIER_LIMITS[tierName]?.emoji || '❌'
218
+ tier: tierName !== 'none' ? meta.label : 'none',
219
+ tierEmoji: meta.emoji
141
220
  };
142
221
  }
143
222
  // === HELPERS ===
@@ -150,7 +229,7 @@ function fetchAPI(endpoint, apiKey) {
150
229
  method: 'GET',
151
230
  headers: {
152
231
  'Authorization': `Bearer ${apiKey}`,
153
- 'User-Agent': 'opencode-pollinations-plugin/5.1.0'
232
+ 'User-Agent': 'opencode-pollinations-plugin/6.4.1'
154
233
  }
155
234
  };
156
235
  const req = https.request(options, (res) => {
@@ -176,26 +255,9 @@ function fetchAPI(endpoint, apiKey) {
176
255
  req.end();
177
256
  });
178
257
  }
179
- function calculateResetInfo(nextResetAt) {
258
+ function calculateResetInfo() {
180
259
  const now = new Date();
181
- // Tiers with cadence 'none' (anonymous/microbe) return nextResetAt: null.
182
- // Guard also against malformed timestamps that would yield Invalid Date (NaN).
183
- let nextReset = nextResetAt ? new Date(nextResetAt) : null;
184
- if (!nextReset || isNaN(nextReset.getTime())) {
185
- // No refill: there is no upcoming reset. Use a stable current-hour window
186
- // so usage SmartFetch still has a sane lower bound, and surface 0 countdown.
187
- const lastReset = new Date(now.getTime() - ONE_HOUR_MS);
188
- return {
189
- nextReset: now,
190
- lastReset,
191
- timeUntilReset: 0,
192
- timeSinceReset: ONE_HOUR_MS,
193
- resetHour: now.getUTCHours(),
194
- resetMinute: now.getUTCMinutes(),
195
- resetSecond: now.getUTCSeconds(),
196
- progressPercent: 100
197
- };
198
- }
260
+ const nextReset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours() + 1, 0, 0, 0));
199
261
  const lastReset = new Date(nextReset.getTime() - ONE_HOUR_MS);
200
262
  const timeUntilReset = Math.max(0, nextReset.getTime() - now.getTime());
201
263
  const timeSinceReset = Math.max(0, now.getTime() - lastReset.getTime());
@@ -206,8 +268,8 @@ function calculateResetInfo(nextResetAt) {
206
268
  timeUntilReset,
207
269
  timeSinceReset,
208
270
  resetHour: nextReset.getUTCHours(),
209
- resetMinute: nextReset.getUTCMinutes(),
210
- resetSecond: nextReset.getUTCSeconds(),
271
+ resetMinute: 0,
272
+ resetSecond: 0,
211
273
  progressPercent
212
274
  };
213
275
  }
@@ -215,7 +277,6 @@ function calculateCurrentPeriodUsage(usage, resetInfo) {
215
277
  let tierUsed = 0;
216
278
  let packUsed = 0;
217
279
  const entriesAfterReset = usage.filter(entry => {
218
- // Safe Parse
219
280
  const timestamp = entry.timestamp.replace(' ', 'T') + 'Z';
220
281
  const entryTime = new Date(timestamp);
221
282
  return entryTime >= resetInfo.lastReset;
@@ -241,5 +302,5 @@ export function formatQuotaForToast(quota) {
241
302
  const hours = Math.floor(ms / (1000 * 60 * 60));
242
303
  const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
243
304
  const resetIn = `${hours}h${minutes}m`;
244
- return `${quota.tierEmoji} Tier: ${quota.tierRemaining.toFixed(2)}/${quota.tierLimit} (${tierPercent}%) | 💎 Wallet: $${quota.walletBalance.toFixed(2)} | ⏰ Reset: ${resetIn}`;
305
+ return `${quota.tierEmoji} Quest: ${quota.tierRemaining.toFixed(2)}/${quota.tierLimit} (${tierPercent}%) | 💎 Paid: $${quota.walletBalance.toFixed(2)} | ⏰ Reset: ${resetIn}`;
245
306
  }
@@ -16,16 +16,16 @@ export const TIER_INFO = {
16
16
  emoji: '🦠',
17
17
  hourlyPollen: 0,
18
18
  dailyEstimate: 0,
19
- condition: 'Just register!',
20
- conditionKey: 'tier.condition.signup',
19
+ condition: 'Account under review',
20
+ conditionKey: 'tier.condition.under_review',
21
21
  },
22
22
  spore: {
23
23
  name: 'Spore',
24
24
  emoji: '🍄',
25
25
  hourlyPollen: 0.01,
26
26
  dailyEstimate: 0.24,
27
- condition: 'Automatic verification',
28
- conditionKey: 'tier.condition.auto_verify',
27
+ condition: 'New account (default)',
28
+ conditionKey: 'tier.condition.default',
29
29
  },
30
30
  seed: {
31
31
  name: 'Seed',
@@ -40,8 +40,8 @@ export const TIER_INFO = {
40
40
  emoji: '🌸',
41
41
  hourlyPollen: 0.4,
42
42
  dailyEstimate: 9.6,
43
- condition: 'Publish an app',
44
- conditionKey: 'tier.condition.publish_app',
43
+ condition: 'Complete Quests & contribute',
44
+ conditionKey: 'tier.condition.quests',
45
45
  },
46
46
  nectar: {
47
47
  name: 'Nectar',
@@ -36,7 +36,8 @@ 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', 'zh']).optional().describe('Plugin language for commands and toasts (en, fr, es, de, it, zh).')
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
+ 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.')
40
41
  },
41
42
  async execute(args, context) {
42
43
  if (args.action === 'view') {
@@ -63,6 +64,9 @@ Use 'action=update' to change these. NEVER confuse Chat Mode with Tools Protecti
63
64
  updates.enablePaidTools = args.enablePaidTools;
64
65
  if (args.costThreshold !== undefined)
65
66
  updates.costThreshold = args.costThreshold;
67
+ if (args.refillOverride !== undefined) {
68
+ updates.refillOverride = args.refillOverride === 0 ? undefined : args.refillOverride;
69
+ }
66
70
  if (args.lang !== undefined)
67
71
  updates.lang = args.lang;
68
72
  if (args.thresholdsTier !== undefined || args.thresholdsWallet !== undefined) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-pollinations-plugin",
3
3
  "displayName": "Pollinations",
4
- "version": "6.3.0",
4
+ "version": "6.4.2",
5
5
  "description": "Native Pollinations.ai Provider Plugin for OpenCode",
6
6
  "publisher": "pollinations",
7
7
  "repository": {