opencode-pollinations-plugin 6.4.0 → 6.4.3

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,4 +1,4 @@
1
- # 🌸 Pollinations AI Plugin für OpenCode (v6.4.0)
1
+ # 🌸 Pollinations AI Plugin für OpenCode (v6.4)
2
2
 
3
3
  ## ✨ Neu in v6.4.0
4
4
 
@@ -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,4 +1,4 @@
1
- # 🌸 Pollinations AI Plugin para OpenCode (v6.4.0)
1
+ # 🌸 Pollinations AI Plugin para OpenCode (v6.4)
2
2
 
3
3
  ## ✨ Novedades v6.4.0
4
4
 
@@ -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,4 +1,4 @@
1
- # 🌸 Plugin Pollinations AI pour OpenCode (v6.4.0)
1
+ # 🌸 Plugin Pollinations AI pour OpenCode (v6.4)
2
2
 
3
3
  ## ✨ Nouveautés v6.4.0
4
4
 
@@ -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,4 +1,4 @@
1
- # 🌸 Pollinations AI Plugin per OpenCode (v6.4.0)
1
+ # 🌸 Pollinations AI Plugin per OpenCode (v6.4)
2
2
 
3
3
  ## ✨ Novità v6.4.0
4
4
 
@@ -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.4.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,4 +1,4 @@
1
- # 🌸 Pollinations AI 插件 - OpenCode版 (v6.4.0)
1
+ # 🌸 Pollinations AI 插件 - OpenCode版 (v6.4)
2
2
 
3
3
  ## ✨ v6.4.0 新功能
4
4
 
@@ -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]`) 、动态最高限额与实时期望消费数据,硬编码的时代宣告结束。
@@ -655,6 +655,8 @@ ${t('commands.config.table_divider')}
655
655
  | **costConfirmationRequired**| \`${config.costConfirmationRequired ?? true}\` | ${t('commands.config.costConfirmationRequired_role')} | \`/poll config costConfirmationRequired <true/false>\` |
656
656
  | **costThreshold**| \`${config.costThreshold ?? 0.15} 🌻\` | ${t('commands.config.costThreshold_role')} | \`/poll config costThreshold <X>\` |
657
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>\` |
659
+ | **questStashInFreeMode**| \`${config.questStashInFreeMode ?? true}\` | Compte le stash dans alwaysfree | \`/poll config questStashInFreeMode <true/false>\` |
658
660
  | **fallbacks.free.main** | \`${config.fallbacks?.free?.main || 'free/mistral'}\` | ${t('commands.config.fallback_main_role')} | \`/poll fallback <main> <agent>\` |
659
661
  | **fallbacks.free.agent** | \`${config.fallbacks?.free?.agent || 'free/openai-fast'}\`| ${t('commands.config.fallback_agent_role')} | \`/poll fallback <main> <agent>\` |
660
662
  | **fallbacks.enter.agent** | \`${config.fallbacks?.enter?.agent || 'free/openai-fast'}\`| ${t('commands.config.fallback_enter_role')} | *${t('commands.config.managed_auto')}* |
@@ -751,9 +753,26 @@ ${t('commands.config.table_divider')}
751
753
  saveConfig({ costConfirmationRequired: enabled });
752
754
  return { handled: true, response: `✅ costConfirmationRequired = ${enabled}` };
753
755
  }
756
+ if (key === 'refillOverride' && value) {
757
+ if (value === 'auto') {
758
+ saveConfig({ refillOverride: undefined });
759
+ return { handled: true, response: '✅ refillOverride = auto (déduction automatique)' };
760
+ }
761
+ const override = parseFloat(value);
762
+ if (isNaN(override) || ![0.01, 0.15, 0.4, 0.8, 10].includes(override)) {
763
+ return { handled: true, error: 'Valeurs supportées: auto, 0.01, 0.15, 0.4, 0.8, 10' };
764
+ }
765
+ saveConfig({ refillOverride: override });
766
+ return { handled: true, response: `✅ refillOverride = ${override} 🌻/h` };
767
+ }
768
+ if (key === 'questStashInFreeMode' && value) {
769
+ const enabled = value === 'true';
770
+ saveConfig({ questStashInFreeMode: enabled });
771
+ return { handled: true, response: `✅ questStashInFreeMode = ${enabled}${enabled ? ' (le stash Quest compte comme free)' : ' (seul le refill horaire compte)'}` };
772
+ }
754
773
  return {
755
774
  handled: true,
756
- error: `Clé inconnue: ${key}. Clés: status_gui, logs_gui, threshold_tier, threshold_wallet, status_bar, cost_estimator, enablePaidTools, costThreshold, costConfirmationRequired, lang`
775
+ error: `Clé inconnue: ${key}. Clés: status_gui, logs_gui, threshold_tier, threshold_wallet, status_bar, cost_estimator, enablePaidTools, costThreshold, costConfirmationRequired, refillOverride, questStashInFreeMode, lang`
757
776
  };
758
777
  }
759
778
  function handleHelpCommand() {
@@ -28,6 +28,8 @@ export interface PollinationsConfigV5 {
28
28
  costConfirmationRequired: boolean;
29
29
  statusBar: boolean;
30
30
  costEstimator: boolean;
31
+ refillOverride?: number;
32
+ questStashInFreeMode?: boolean;
31
33
  lang?: string;
32
34
  }
33
35
  export declare function loadConfig(): PollinationsConfigV5;
@@ -58,6 +60,8 @@ export declare function saveConfig(updates: Partial<PollinationsConfigV5>): {
58
60
  costConfirmationRequired: boolean;
59
61
  statusBar: boolean;
60
62
  costEstimator: boolean;
63
+ refillOverride?: number;
64
+ questStashInFreeMode?: boolean;
61
65
  lang?: string;
62
66
  };
63
67
  export declare function saveKeyToAuthJson(key: string): boolean;
@@ -82,6 +82,7 @@ const DEFAULT_CONFIG_V5 = {
82
82
  keyHasAccessToProfile: true, // Default true for legacy keys
83
83
  statusBar: true,
84
84
  costEstimator: true, // Show cost estimates by default
85
+ questStashInFreeMode: true, // Count quest stash as free in alwaysfree
85
86
  lang: 'en', // Default language is English
86
87
  };
87
88
  import { log as logSystem } from './logger.js';
@@ -87,12 +87,12 @@ export async function generatePollinationsConfig(forceApiKey, forceStrict = fals
87
87
  const enterList = ModelRegistry.list('text');
88
88
  const paidModels = [];
89
89
  enterList.forEach((m) => {
90
- if (m.tools === false)
91
- return; // OpenCode UI chat nécessite explicitement les tools
90
+ if (m.tools !== true || m.community === true)
91
+ return; // Exclude non-tool and community models
92
92
  const mapped = mapModel(m, 'enter/', '');
93
93
  modelsOutput.push(mapped);
94
94
  if (m.paid_only) {
95
- paidModels.push(mapped.id.replace('enter/', '')); // Store bare ID "gemini-large"
95
+ paidModels.push(mapped.id.replace('enter/', ''));
96
96
  }
97
97
  });
98
98
  log(`Total models (Free+Pro): ${modelsOutput.length}`);
@@ -207,7 +207,7 @@ function mapModel(raw, prefix, namePrefix) {
207
207
  }
208
208
  }
209
209
  // 2. REASONING VARIANTS — format @ai-sdk/openai-compatible: { reasoningEffort: "level" }
210
- if (raw.reasoning === true || rawId.includes('thinking') || rawId.includes('reasoning')) {
210
+ if (raw.reasoning === true || raw.capabilities?.includes('reasoning') || rawId.includes('thinking') || rawId.includes('reasoning')) {
211
211
  modelObj.variants = {
212
212
  ...modelObj.variants,
213
213
  low: { reasoningEffort: 'low' },
@@ -58,12 +58,11 @@ export class ToolRegistryWorker {
58
58
  videoTable += `| Modèle | Source I/O | Audio | 1 pollen ≈ | Specs |\n`;
59
59
  videoTable += `|--------|------------|-------|------------|-------|\n`;
60
60
  for (const m of videoModels) {
61
- // Estimation pour une vidéo moyenne de 6 secondes
62
61
  const cost = estimateVideoCost(m.name, 6);
63
62
  const price = cost ? `${per1pollen(cost)} vidéos` : 'inconnu';
64
- // durationRange et aspectRatios arrivent du fetcher (ou fallback)
65
63
  const specs = `${m.durationRange ? m.durationRange.join('-') + 's' : '?s'} / ${m.aspectRatios ? m.aspectRatios.length : '?'} ratios`;
66
- const badge = m.paid_only ? '[💎 Paid]' : '[🌿 Free]';
64
+ const isCommunity = m.community === true || m.name.includes('/');
65
+ const badge = isCommunity ? '[👥]' : (m.paid_only ? '[💎 Paid]' : '[🌿 Free]');
67
66
  videoTable += `| \`${m.name}\` ${badge} | ${m.supportsI2X ? 'T2V/I2V' : 'T2V'} | ${m.output_modalities?.includes('audio') || m.name === 'grok-video' ? '✅' : '❌'} | ${price} | ${specs} |\n`;
68
67
  }
69
68
  if (!polliGenVideoTool.description.includes('**🎬 Modèles Vidéo Détectés')) {
@@ -84,7 +83,8 @@ export class ToolRegistryWorker {
84
83
  for (const m of imageModels.slice(0, 20)) {
85
84
  const cost = estimateImageCost(m.name);
86
85
  const price = cost ? `${per1pollen(cost)} images` : 'inconnu';
87
- const badge = m.paid_only ? '[💎 Paid]' : '[🌿 Free]';
86
+ const isCommunity = m.community === true || m.name.includes('/');
87
+ const badge = isCommunity ? '[👥]' : (m.paid_only ? '[💎 Paid]' : '[🌿 Free]');
88
88
  imageTable += `| \`${m.name}\` ${badge} | ${m.supportsI2X ? '✅' : '❌'} | Standard | ${price} |\n`;
89
89
  }
90
90
  if (imageModels.length > 20) {
@@ -105,9 +105,9 @@ export class ToolRegistryWorker {
105
105
  audioTable += `| Modèle | Durée max | Qualité |\n`;
106
106
  audioTable += `|--------|-----------|---------|\n`;
107
107
  for (const m of audioModels) {
108
- const badge = m.paid_only ? '[💎 Paid]' : '[🌿 Free]';
108
+ const isCommunity = m.community === true || m.name.includes('/');
109
+ const badge = isCommunity ? '[👥]' : (m.paid_only ? '[💎 Paid]' : '[🌿 Free]');
109
110
  const duration = m.durationRange ? `${m.durationRange.join('-')}s` : 'Standard';
110
- // STT Whispers or TTS or Music
111
111
  audioTable += `| \`${m.name}\` ${badge} | ${duration} | Standard |\n`;
112
112
  }
113
113
  if (!polliGenAudioTool.description.includes('**🎵 Modèles Audio/Music Détectés')) {
@@ -138,7 +138,8 @@ export class ToolRegistryWorker {
138
138
  searchTable += `| Modèle | Description / Specs |\n`;
139
139
  searchTable += `|--------|---------------------|\n`;
140
140
  for (const m of searchModels) {
141
- const badge = m.paid_only ? '[💎 Paid]' : '[🌿 Free]';
141
+ const isCommunity = m.community === true || m.name.includes('/');
142
+ const badge = isCommunity ? '[👥]' : (m.paid_only ? '[💎 Paid]' : '[🌿 Free]');
142
143
  // Clean markdown piping conflicts
143
144
  let cleanDesc = m.description.replace(/\|/g, '-');
144
145
  // Adding "Specialized" hint
@@ -446,7 +446,13 @@ export async function handleChatCompletion(req, res, bodyRaw) {
446
446
  fallbackReason = t('proxy.warnings.quota_unreachable_msg');
447
447
  }
448
448
  else {
449
- const tierRatio = quota.tierLimit > 0 ? (quota.tierRemaining / quota.tierLimit) : 0;
449
+ const effectiveFree = config.questStashInFreeMode !== false
450
+ ? quota.tierRemaining + (quota.questStash || 0)
451
+ : quota.tierRemaining;
452
+ const effectiveLimit = config.questStashInFreeMode !== false
453
+ ? quota.tierLimit + (quota.questStash || 0)
454
+ : quota.tierLimit;
455
+ const tierRatio = effectiveLimit > 0 ? (effectiveFree / effectiveLimit) : 0;
450
456
  if (tierRatio <= (config.thresholds.tier / 100)) {
451
457
  log(`[SafetyNet] AlwaysFree Mode: Tier (${(tierRatio * 100).toFixed(1)}%) <= Threshold (${config.thresholds.tier}%). Switching.`);
452
458
  emitStatusToast('warning', t('proxy.warnings.tier_limit_title', { threshold: config.thresholds.tier }), 'AlwaysFree Mode');
@@ -3,6 +3,7 @@ export interface QuotaStatus {
3
3
  tierRemaining: number;
4
4
  tierUsed: number;
5
5
  tierLimit: number;
6
+ questStash: number;
6
7
  walletBalance: number;
7
8
  nextResetAt: Date;
8
9
  timeUntilReset: number;
@@ -16,3 +17,8 @@ export interface QuotaStatus {
16
17
  export declare function fetchUsageForPeriod(apiKey: string, lastReset: Date): Promise<DetailedUsageEntry[]>;
17
18
  export declare function getQuotaStatus(forceRefresh?: boolean): Promise<QuotaStatus>;
18
19
  export declare function formatQuotaForToast(quota: QuotaStatus): string;
20
+ export declare function fetchQuestStash(apiKey: string): Promise<{
21
+ questStash: number;
22
+ claimedQuestTier: number;
23
+ tierConsumedSinceClaim: number;
24
+ }>;
@@ -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,45 +154,42 @@ 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,
105
183
  tierLimit,
184
+ questStash: 0, // will be computed by fetchQuestStash if needed
106
185
  walletBalance: cleanWalletBalance,
107
186
  nextResetAt: resetInfo.nextReset,
108
187
  timeUntilReset: resetInfo.timeUntilReset,
109
188
  canUseEnterprise: cleanTierRemaining > 0.05 || cleanWalletBalance > 0.05,
110
189
  isUsingWallet: cleanTierRemaining <= 0.05 && cleanWalletBalance > 0.05,
111
190
  needsAlert: tierNeedsAlert || walletNeedsAlert,
112
- tier: profile.tier,
113
- tierEmoji: tierInfo.emoji
191
+ tier: tierMeta.label,
192
+ tierEmoji: tierMeta.emoji
114
193
  };
115
194
  lastQuotaFetch = now;
116
195
  return cachedQuota;
@@ -126,18 +205,20 @@ export async function getQuotaStatus(forceRefresh = false) {
126
205
  }
127
206
  }
128
207
  function createDefaultQuota(tierName, limit) {
208
+ const meta = tierMetaForAllowance(limit);
129
209
  return {
130
210
  tierRemaining: 0,
131
211
  tierUsed: 0,
132
212
  tierLimit: limit,
213
+ questStash: 0,
133
214
  walletBalance: 0,
134
215
  nextResetAt: new Date(),
135
216
  timeUntilReset: 0,
136
217
  canUseEnterprise: false,
137
218
  isUsingWallet: false,
138
219
  needsAlert: false,
139
- tier: tierName,
140
- tierEmoji: TIER_LIMITS[tierName]?.emoji || '❌'
220
+ tier: tierName !== 'none' ? meta.label : 'none',
221
+ tierEmoji: meta.emoji
141
222
  };
142
223
  }
143
224
  // === HELPERS ===
@@ -150,7 +231,7 @@ function fetchAPI(endpoint, apiKey) {
150
231
  method: 'GET',
151
232
  headers: {
152
233
  'Authorization': `Bearer ${apiKey}`,
153
- 'User-Agent': 'opencode-pollinations-plugin/5.1.0'
234
+ 'User-Agent': 'opencode-pollinations-plugin/6.4.1'
154
235
  }
155
236
  };
156
237
  const req = https.request(options, (res) => {
@@ -176,26 +257,9 @@ function fetchAPI(endpoint, apiKey) {
176
257
  req.end();
177
258
  });
178
259
  }
179
- function calculateResetInfo(nextResetAt) {
260
+ function calculateResetInfo() {
180
261
  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
- }
262
+ const nextReset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours() + 1, 0, 0, 0));
199
263
  const lastReset = new Date(nextReset.getTime() - ONE_HOUR_MS);
200
264
  const timeUntilReset = Math.max(0, nextReset.getTime() - now.getTime());
201
265
  const timeSinceReset = Math.max(0, now.getTime() - lastReset.getTime());
@@ -206,8 +270,8 @@ function calculateResetInfo(nextResetAt) {
206
270
  timeUntilReset,
207
271
  timeSinceReset,
208
272
  resetHour: nextReset.getUTCHours(),
209
- resetMinute: nextReset.getUTCMinutes(),
210
- resetSecond: nextReset.getUTCSeconds(),
273
+ resetMinute: 0,
274
+ resetSecond: 0,
211
275
  progressPercent
212
276
  };
213
277
  }
@@ -215,7 +279,6 @@ function calculateCurrentPeriodUsage(usage, resetInfo) {
215
279
  let tierUsed = 0;
216
280
  let packUsed = 0;
217
281
  const entriesAfterReset = usage.filter(entry => {
218
- // Safe Parse
219
282
  const timestamp = entry.timestamp.replace(' ', 'T') + 'Z';
220
283
  const entryTime = new Date(timestamp);
221
284
  return entryTime >= resetInfo.lastReset;
@@ -241,5 +304,37 @@ export function formatQuotaForToast(quota) {
241
304
  const hours = Math.floor(ms / (1000 * 60 * 60));
242
305
  const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
243
306
  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}`;
307
+ const stashStr = quota.questStash > 0
308
+ ? ` | 🎁 ~${quota.questStash.toFixed(2)} (stash)`
309
+ : '';
310
+ return `${quota.tierEmoji} ${quota.tierRemaining.toFixed(2)}/${quota.tierLimit} (${tierPercent}%)${stashStr} | 💎 $${quota.walletBalance.toFixed(2)} | ⏰ ${resetIn}`;
311
+ }
312
+ export async function fetchQuestStash(apiKey) {
313
+ let claimedQuestTier = 0;
314
+ let firstClaimMs = Infinity;
315
+ let tierConsumedSinceClaim = 0;
316
+ try {
317
+ const qres = await fetchAPI('/account/quests', apiKey);
318
+ for (const q of (qres?.quests || [])) {
319
+ const r = q.reward;
320
+ if (r && r.claimedAt && r.balanceBucket === 'tier') {
321
+ claimedQuestTier += (r.pollenAmount || 0);
322
+ const cms = new Date(r.claimedAt).getTime();
323
+ if (!isNaN(cms) && cms < firstClaimMs)
324
+ firstClaimMs = cms;
325
+ }
326
+ }
327
+ if (claimedQuestTier > 0 && isFinite(firstClaimMs)) {
328
+ const ures = await fetchAPI('/account/usage?limit=500', apiKey);
329
+ for (const e of (ures?.usage || [])) {
330
+ const ts = new Date(String(e.timestamp).replace(' ', 'T') + (String(e.timestamp).includes('Z') ? '' : 'Z')).getTime();
331
+ if (e.meter_source === 'tier' && !isNaN(ts) && ts >= firstClaimMs) {
332
+ tierConsumedSinceClaim += (e.cost_usd || 0);
333
+ }
334
+ }
335
+ }
336
+ }
337
+ catch { /* fallback gracefully */ }
338
+ const questStash = Math.max(0, claimedQuestTier - tierConsumedSinceClaim);
339
+ return { questStash, claimedQuestTier, tierConsumedSinceClaim };
245
340
  }
@@ -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,9 @@ 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.'),
41
+ questStashInFreeMode: tool.schema.boolean().optional().describe('Count accumulated quest stash as free pollen in alwaysfree Safety Net (default: true).')
40
42
  },
41
43
  async execute(args, context) {
42
44
  if (args.action === 'view') {
@@ -63,6 +65,11 @@ Use 'action=update' to change these. NEVER confuse Chat Mode with Tools Protecti
63
65
  updates.enablePaidTools = args.enablePaidTools;
64
66
  if (args.costThreshold !== undefined)
65
67
  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;
66
73
  if (args.lang !== undefined)
67
74
  updates.lang = args.lang;
68
75
  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.4.0",
4
+ "version": "6.4.3",
5
5
  "description": "Native Pollinations.ai Provider Plugin for OpenCode",
6
6
  "publisher": "pollinations",
7
7
  "repository": {