opencode-pollinations-plugin 6.0.0 → 6.1.0-beta.10

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 (56) hide show
  1. package/README.md +140 -87
  2. package/dist/index.js +33 -154
  3. package/dist/server/commands.d.ts +2 -0
  4. package/dist/server/commands.js +84 -25
  5. package/dist/server/config.d.ts +6 -0
  6. package/dist/server/config.js +4 -1
  7. package/dist/server/generate-config.d.ts +3 -30
  8. package/dist/server/generate-config.js +172 -100
  9. package/dist/server/index.d.ts +2 -1
  10. package/dist/server/index.js +124 -149
  11. package/dist/server/pollinations-api.d.ts +11 -0
  12. package/dist/server/pollinations-api.js +20 -0
  13. package/dist/server/proxy.js +158 -72
  14. package/dist/server/quota.d.ts +8 -0
  15. package/dist/server/quota.js +106 -61
  16. package/dist/server/toast.d.ts +3 -0
  17. package/dist/server/toast.js +16 -0
  18. package/dist/tools/design/gen_diagram.d.ts +2 -0
  19. package/dist/tools/design/gen_diagram.js +94 -0
  20. package/dist/tools/design/gen_palette.d.ts +2 -0
  21. package/dist/tools/design/gen_palette.js +182 -0
  22. package/dist/tools/design/gen_qrcode.d.ts +2 -0
  23. package/dist/tools/design/gen_qrcode.js +50 -0
  24. package/dist/tools/index.d.ts +22 -0
  25. package/dist/tools/index.js +81 -0
  26. package/dist/tools/pollinations/deepsearch.d.ts +7 -0
  27. package/dist/tools/pollinations/deepsearch.js +80 -0
  28. package/dist/tools/pollinations/gen_audio.d.ts +18 -0
  29. package/dist/tools/pollinations/gen_audio.js +204 -0
  30. package/dist/tools/pollinations/gen_image.d.ts +13 -0
  31. package/dist/tools/pollinations/gen_image.js +239 -0
  32. package/dist/tools/pollinations/gen_music.d.ts +14 -0
  33. package/dist/tools/pollinations/gen_music.js +139 -0
  34. package/dist/tools/pollinations/gen_video.d.ts +16 -0
  35. package/dist/tools/pollinations/gen_video.js +222 -0
  36. package/dist/tools/pollinations/search_crawl_scrape.d.ts +7 -0
  37. package/dist/tools/pollinations/search_crawl_scrape.js +85 -0
  38. package/dist/tools/pollinations/shared.d.ts +170 -0
  39. package/dist/tools/pollinations/shared.js +454 -0
  40. package/dist/tools/pollinations/transcribe_audio.d.ts +17 -0
  41. package/dist/tools/pollinations/transcribe_audio.js +235 -0
  42. package/dist/tools/power/extract_audio.d.ts +2 -0
  43. package/dist/tools/power/extract_audio.js +180 -0
  44. package/dist/tools/power/extract_frames.d.ts +2 -0
  45. package/dist/tools/power/extract_frames.js +240 -0
  46. package/dist/tools/power/file_to_url.d.ts +2 -0
  47. package/dist/tools/power/file_to_url.js +217 -0
  48. package/dist/tools/power/remove_background.d.ts +2 -0
  49. package/dist/tools/power/remove_background.js +365 -0
  50. package/dist/tools/power/rmbg_keys.d.ts +2 -0
  51. package/dist/tools/power/rmbg_keys.js +78 -0
  52. package/dist/tools/shared.d.ts +30 -0
  53. package/dist/tools/shared.js +74 -0
  54. package/package.json +9 -3
  55. package/dist/server/models-seed.d.ts +0 -18
  56. package/dist/server/models-seed.js +0 -55
@@ -78,6 +78,23 @@ function dereferenceSchema(schema, rootDefs) {
78
78
  schema.description = (schema.description || "") + " [Ref Failed]";
79
79
  }
80
80
  }
81
+ // VERTEX FIX: 'const' not supported -> convert to 'enum'
82
+ if (schema.const !== undefined) {
83
+ schema.enum = [schema.const];
84
+ delete schema.const;
85
+ }
86
+ // VERTEX FIX: 'anyOf' must be exclusive (no other siblings)
87
+ if (schema.anyOf || schema.oneOf) {
88
+ // Vertex demands strict exclusivity.
89
+ // We keep 'definitions'/'$defs' if present at root (though unlikely here)
90
+ // But for a property node, we must strip EVERYTHING else.
91
+ const keys = Object.keys(schema);
92
+ keys.forEach(k => {
93
+ if (k !== 'anyOf' && k !== 'oneOf' && k !== 'definitions' && k !== '$defs') {
94
+ delete schema[k];
95
+ }
96
+ });
97
+ }
81
98
  if (schema.properties) {
82
99
  for (const key in schema.properties) {
83
100
  schema.properties[key] = dereferenceSchema(schema.properties[key], rootDefs);
@@ -86,6 +103,15 @@ function dereferenceSchema(schema, rootDefs) {
86
103
  if (schema.items) {
87
104
  schema.items = dereferenceSchema(schema.items, rootDefs);
88
105
  }
106
+ if (schema.anyOf) {
107
+ schema.anyOf = schema.anyOf.map((s) => dereferenceSchema(s, rootDefs));
108
+ }
109
+ if (schema.oneOf) {
110
+ schema.oneOf = schema.oneOf.map((s) => dereferenceSchema(s, rootDefs));
111
+ }
112
+ if (schema.allOf) {
113
+ schema.allOf = schema.allOf.map((s) => dereferenceSchema(s, rootDefs));
114
+ }
89
115
  if (schema.optional !== undefined)
90
116
  delete schema.optional;
91
117
  if (schema.title)
@@ -107,6 +133,38 @@ function sanitizeToolsForVertex(tools) {
107
133
  return tool;
108
134
  });
109
135
  }
136
+ function sanitizeToolsForBedrock(tools) {
137
+ return tools.map(tool => {
138
+ if (tool.function) {
139
+ if (!tool.function.description || tool.function.description.length === 0) {
140
+ tool.function.description = " "; // Force non-empty string
141
+ }
142
+ }
143
+ return tool;
144
+ });
145
+ }
146
+ function sanitizeSchemaForKimi(schema) {
147
+ if (!schema || typeof schema !== 'object')
148
+ return schema;
149
+ // Kimi Fixes
150
+ if (schema.title)
151
+ delete schema.title;
152
+ // Fix empty objects "{}" which Kimi hates.
153
+ // If it's an empty object without type, assume string or object?
154
+ // Often happens with "additionalProperties: {}"
155
+ if (Object.keys(schema).length === 0) {
156
+ schema.type = "string"; // Fallback to safe type
157
+ schema.description = "Any value";
158
+ }
159
+ if (schema.properties) {
160
+ for (const key in schema.properties) {
161
+ schema.properties[key] = sanitizeSchemaForKimi(schema.properties[key]);
162
+ }
163
+ }
164
+ if (schema.items)
165
+ sanitizeSchemaForKimi(schema.items);
166
+ return schema;
167
+ }
110
168
  function truncateTools(tools, limit = 120) {
111
169
  if (!tools || tools.length <= limit)
112
170
  return tools;
@@ -114,12 +172,16 @@ function truncateTools(tools, limit = 120) {
114
172
  }
115
173
  const MAX_RETRIES = 3;
116
174
  const RETRY_DELAY_MS = 1000;
175
+ const FETCH_TIMEOUT_MS = 600000; // 10 Minutes global timeout
117
176
  function sleep(ms) {
118
177
  return new Promise(resolve => setTimeout(resolve, ms));
119
178
  }
120
179
  async function fetchWithRetry(url, options, retries = MAX_RETRIES) {
121
180
  try {
122
- const response = await fetch(url, options);
181
+ const controller = new AbortController();
182
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
183
+ const response = await fetch(url, { ...options, signal: controller.signal });
184
+ clearTimeout(timeoutId);
123
185
  if (response.ok)
124
186
  return response;
125
187
  if (response.status === 404 || response.status === 401 || response.status === 400) {
@@ -153,11 +215,6 @@ export async function handleChatCompletion(req, res, bodyRaw) {
153
215
  const config = loadConfig();
154
216
  // DEBUG: Trace Config State for Hot Reload verification
155
217
  log(`[Proxy Request] Config Loaded. Mode: ${config.mode}, HasKey: ${!!config.apiKey}, KeyLength: ${config.apiKey ? config.apiKey.length : 0}`);
156
- // SPY LOGGING
157
- try {
158
- fs.appendFileSync('/tmp/opencode_spy.log', `\n\n=== REQUEST ${new Date().toISOString()} ===\nMODEL: ${body.model}\nBODY:\n${JSON.stringify(body, null, 2)}\n==========================\n`);
159
- }
160
- catch (e) { }
161
218
  // 0. COMMAND HANDLING
162
219
  if (body.messages && body.messages.length > 0) {
163
220
  const lastMsg = body.messages[body.messages.length - 1];
@@ -208,6 +265,27 @@ export async function handleChatCompletion(req, res, bodyRaw) {
208
265
  }
209
266
  }
210
267
  log(`Incoming Model (OpenCode ID): ${body.model}`);
268
+ // 0. SPECIAL: connect-pollinations fallback model
269
+ if (body.model === 'connect-pollinations') {
270
+ const connectMsg = {
271
+ id: `chatcmpl-connect-${Date.now()}`,
272
+ object: 'chat.completion',
273
+ created: Math.floor(Date.now() / 1000),
274
+ model: 'connect-pollinations',
275
+ choices: [{
276
+ index: 0,
277
+ message: {
278
+ role: 'assistant',
279
+ content: `🔗 **Se connecter à Pollinations**\n\nAccédez à 30+ modèles IA de pointe !\n\n📍 **Étapes :**\n1. Visitez https://enter.pollinations.ai\n2. Créez un compte gratuit\n3. Copiez votre API Key\n4. Exécutez: \`/pollinations config apiKey YOUR_KEY\`\n5. Redémarrez OpenCode\n\n✅ **Bénéfices :**\n• 30+ modèles avancés (GPT-5, Claude, Gemini...)\n• Crédits gratuits selon votre tier\n• Stabilité garantie`
280
+ },
281
+ finish_reason: 'stop'
282
+ }],
283
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
284
+ };
285
+ res.writeHead(200, { 'Content-Type': 'application/json' });
286
+ res.end(JSON.stringify(connectMsg));
287
+ return;
288
+ }
211
289
  // 1. STRICT ROUTING & SAFETY NET LOGIC (V5)
212
290
  let actualModel = body.model || "openai";
213
291
  let isEnterprise = false;
@@ -216,16 +294,15 @@ export async function handleChatCompletion(req, res, bodyRaw) {
216
294
  // LOAD QUOTA FOR SAFETY CHECKS
217
295
  const { getQuotaStatus, formatQuotaForToast } = await import('./quota.js');
218
296
  const quota = await getQuotaStatus(false);
219
- // A. Resolve Base Target - v6.0: All models go to gen.pollinations.ai
220
- // Strip any legacy prefixes for backwards compatibility
221
- if (actualModel.startsWith('enter/') || actualModel.startsWith('enter-')) {
222
- actualModel = actualModel.replace(/^enter[-/]/, '');
297
+ // A. Resolve Base Target
298
+ if (actualModel.startsWith('enter/')) {
299
+ isEnterprise = true;
300
+ actualModel = actualModel.replace('enter/', '');
223
301
  }
224
- else if (actualModel.startsWith('free/') || actualModel.startsWith('free-')) {
225
- actualModel = actualModel.replace(/^free[-/]/, '');
302
+ else if (actualModel.startsWith('free/')) {
303
+ isEnterprise = false;
304
+ actualModel = actualModel.replace('free/', '');
226
305
  }
227
- // v6.0: Everything is enterprise now (requires API key)
228
- isEnterprise = true;
229
306
  // A.1 PAID MODEL ENFORCEMENT (V5.5 Strategy)
230
307
  // Check dynamic list saved by generate-config.ts
231
308
  if (isEnterprise) {
@@ -279,18 +356,33 @@ export async function handleChatCompletion(req, res, bodyRaw) {
279
356
  }
280
357
  if (config.mode === 'alwaysfree') {
281
358
  if (isEnterprise) {
282
- // NEW: Paid Only Check for Always Free
359
+ // Paid Only Check: BLOCK (not fallback) in AlwaysFree mode
283
360
  try {
284
361
  const homedir = process.env.HOME || '/tmp';
285
362
  const standardPaidPath = path.join(homedir, '.pollinations', 'pollinations-paid-models.json');
286
363
  if (fs.existsSync(standardPaidPath)) {
287
364
  const paidModels = JSON.parse(fs.readFileSync(standardPaidPath, 'utf-8'));
288
365
  if (paidModels.includes(actualModel)) {
289
- log(`[SafetyNet] alwaysfree Mode: Request for Paid Only Model (${actualModel}). FALLBACK.`);
290
- actualModel = config.fallbacks.free.main.replace('free/', '');
291
- isEnterprise = false;
292
- isFallbackActive = true;
293
- fallbackReason = "Mode AlwaysFree actif: Ce modèle payant consomme du wallet. Passez en mode PRO.";
366
+ log(`[AlwaysFree] BLOCKED: Paid Only Model (${actualModel}).`);
367
+ emitStatusToast('warning', `🚫 Modèle payant bloqué: ${actualModel}`, 'AlwaysFree Mode');
368
+ const blockMsg = {
369
+ id: `chatcmpl-block-${Date.now()}`,
370
+ object: 'chat.completion',
371
+ created: Math.floor(Date.now() / 1000),
372
+ model: actualModel,
373
+ choices: [{
374
+ index: 0,
375
+ message: {
376
+ role: 'assistant',
377
+ content: `🚫 **Modèle payant non disponible en mode AlwaysFree**\n\nLe modèle \`${actualModel}\` consomme directement votre wallet (💎 Paid Only).\n\n**Solutions :**\n• \`/pollinations config mode pro\` — Autorise les modèles payants avec protection wallet\n• \`/pollinations config mode manual\` — Aucune restriction, contrôle total`
378
+ },
379
+ finish_reason: 'stop'
380
+ }],
381
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
382
+ };
383
+ res.writeHead(200, { 'Content-Type': 'application/json' });
384
+ res.end(JSON.stringify(blockMsg));
385
+ return;
294
386
  }
295
387
  }
296
388
  }
@@ -337,16 +429,24 @@ export async function handleChatCompletion(req, res, bodyRaw) {
337
429
  }
338
430
  }
339
431
  }
340
- // C. Construct URL & Headers - v6.0: Always gen.pollinations.ai
341
- if (!config.apiKey) {
342
- emitLogToast('error', "Missing API Key - Use /pollinations connect <key>", 'Proxy Error');
343
- res.writeHead(401, { 'Content-Type': 'application/json' });
344
- res.end(JSON.stringify({ error: { message: "API Key required. Use /pollinations connect <your_key> to connect." } }));
345
- return;
432
+ // C. Construct URL & Headers
433
+ if (isEnterprise) {
434
+ if (!config.apiKey) {
435
+ emitLogToast('error', "Missing API Key for Enterprise Model", 'Proxy Error');
436
+ res.writeHead(401, { 'Content-Type': 'application/json' });
437
+ res.end(JSON.stringify({ error: { message: "API Key required for Enterprise models." } }));
438
+ return;
439
+ }
440
+ targetUrl = 'https://gen.pollinations.ai/v1/chat/completions';
441
+ authHeader = `Bearer ${config.apiKey}`;
442
+ log(`Routing to ENTERPRISE: ${actualModel}`);
443
+ }
444
+ else {
445
+ targetUrl = 'https://text.pollinations.ai/openai/chat/completions';
446
+ authHeader = undefined;
447
+ log(`Routing to FREE: ${actualModel} ${isFallbackActive ? '(FALLBACK)' : ''}`);
448
+ // emitLogToast('info', `Routing to: FREE UNIVERSE (${actualModel})`, 'Pollinations Routing'); // Too noisy
346
449
  }
347
- targetUrl = 'https://gen.pollinations.ai/v1/chat/completions';
348
- authHeader = `Bearer ${config.apiKey}`;
349
- log(`Routing to gen.pollinations.ai: ${actualModel}`);
350
450
  // NOTIFY SWITCH
351
451
  if (isFallbackActive) {
352
452
  emitStatusToast('warning', `⚠️ Safety Net: ${actualModel} (${fallbackReason})`, 'Pollinations Safety');
@@ -378,17 +478,24 @@ export async function handleChatCompletion(req, res, bodyRaw) {
378
478
  // LOGIC BLOCK: MODEL SPECIFIC ADAPTATIONS
379
479
  // =========================================================
380
480
  if (proxyBody.tools && Array.isArray(proxyBody.tools) && proxyBody.tools.length > 0) {
381
- // B0. KIMI / MOONSHOT SURGICAL FIX (Restored for Debug)
382
- // Tools are ENABLED. We rely on penalties and strict stops to fight loops.
481
+ // B0. KIMI / MOONSHOT SURGICAL FIX
383
482
  if (actualModel.includes("kimi") || actualModel.includes("moonshot")) {
384
- log(`[Proxy] Kimi: Tools ENABLED. Applying penalties/stops.`);
483
+ log(`[Proxy] Kimi: Tools ENABLED. Applying penalties/stops/sanitization.`);
385
484
  proxyBody.frequency_penalty = 1.1;
386
485
  proxyBody.presence_penalty = 0.4;
387
486
  proxyBody.stop = ["<|endoftext|>", "User:", "\nUser", "User :"];
487
+ // KIMI FIX: Remove 'title' from schema
488
+ proxyBody.tools = proxyBody.tools.map((t) => {
489
+ if (t.function && t.function.parameters) {
490
+ t.function.parameters = sanitizeSchemaForKimi(t.function.parameters);
491
+ }
492
+ return t;
493
+ });
388
494
  }
389
- // A. AZURE/OPENAI FIXES
390
- if (actualModel.includes("gpt") || actualModel.includes("openai") || actualModel.includes("azure")) {
391
- proxyBody.tools = truncateTools(proxyBody.tools, 120);
495
+ // A. AZURE/OPENAI FIXES + MIDJOURNEY + GROK
496
+ if (actualModel.includes("gpt") || actualModel.includes("openai") || actualModel.includes("azure") || actualModel.includes("midijourney") || actualModel.includes("grok")) {
497
+ const limit = (actualModel.includes("midijourney") || actualModel.includes("grok")) ? 128 : 120;
498
+ proxyBody.tools = truncateTools(proxyBody.tools, limit);
392
499
  if (proxyBody.messages) {
393
500
  proxyBody.messages.forEach((m) => {
394
501
  if (m.tool_calls) {
@@ -403,6 +510,11 @@ export async function handleChatCompletion(req, res, bodyRaw) {
403
510
  });
404
511
  }
405
512
  }
513
+ // BEDROCK FIX (Claude / Nova / ChickyTutor)
514
+ if (actualModel.includes("claude") || actualModel.includes("nova") || actualModel.includes("bedrock") || actualModel.includes("chickytutor")) {
515
+ log(`[Proxy] Bedrock: Sanitizing tools description.`);
516
+ proxyBody.tools = sanitizeToolsForBedrock(proxyBody.tools);
517
+ }
406
518
  // B1. NOMNOM SPECIAL (Disable Grounding, KEEP Search Tool)
407
519
  if (actualModel === "nomnom") {
408
520
  proxyBody.tools_config = { google_search_retrieval: { disable: true } };
@@ -410,36 +522,12 @@ export async function handleChatCompletion(req, res, bodyRaw) {
410
522
  proxyBody.tools = sanitizeToolsForVertex(proxyBody.tools || []);
411
523
  log(`[Proxy] Nomnom Fix: Grounding Disabled, Search Tool KEPT.`);
412
524
  }
413
- // B2. GEMINI FREE / FAST (CRASH FIX: STRICT SANITIZATION)
414
- // Restore Tools but REMOVE conflicting ones (Search)
415
525
  // B. GEMINI UNIFIED FIX (Free, Fast, Pro, Enterprise, Legacy)
416
- // Handles: "tools" vs "grounding" conflicts, and "infinite loops" via Stop Sequences.
417
- // GLOBAL BEDROCK FIX (All Models)
418
- // Check if history has tools but current request misses tools definition.
419
- // This happens when OpenCode sends the Tool Result (optimisation),
420
- // but Bedrock requires toolConfig to validate the history.
421
- const hasToolHistory = proxyBody.messages?.some((m) => m.role === 'tool' || m.tool_calls);
422
- if (hasToolHistory && (!proxyBody.tools || proxyBody.tools.length === 0)) {
423
- // Inject Shim Tool to satisfy Bedrock
424
- proxyBody.tools = [{
425
- type: 'function',
426
- function: {
427
- name: '_bedrock_compatibility_shim',
428
- description: 'Internal system tool to satisfy Bedrock strict toolConfig requirement. Do not use.',
429
- parameters: { type: 'object', properties: {} }
430
- }
431
- }];
432
- log(`[Proxy] Bedrock Fix: Injected shim tool for ${actualModel} (History has tools, Request missing tools)`);
433
- }
434
- // B. GEMINI UNIFIED FIX (Free, Fast, Pro, Enterprise, Legacy)
435
- // Fixes "Multiple tools" error (Vertex) and "JSON body validation failed" (v5.3.5 regression)
436
- // Added ChickyTutor (Claude/Gemini based) to fix "toolConfig must be defined" error.
437
- else if (actualModel.includes("gemini") || actualModel.includes("chickytutor")) {
526
+ else if (actualModel.includes("gemini")) {
438
527
  let hasFunctions = false;
439
528
  if (proxyBody.tools && Array.isArray(proxyBody.tools)) {
440
529
  hasFunctions = proxyBody.tools.some((t) => t.type === 'function' || t.function);
441
530
  }
442
- // Old Shim logic removed (moved up)
443
531
  if (hasFunctions) {
444
532
  // 1. Strict cleanup of 'google_search' tool
445
533
  proxyBody.tools = proxyBody.tools.filter((t) => {
@@ -470,14 +558,6 @@ export async function handleChatCompletion(req, res, bodyRaw) {
470
558
  log(`[Proxy] Gemini Logic: Tools=${proxyBody.tools ? proxyBody.tools.length : 'REMOVED'}, Stops NOT Injected.`);
471
559
  }
472
560
  }
473
- // B5. BEDROCK TOKEN LIMIT FIX
474
- if (actualModel.includes("chicky") || actualModel.includes("mistral")) {
475
- // Force max_tokens if not present or too high (Bedrock outputs usually max 4k, context 8k+ but strict check)
476
- if (!proxyBody.max_tokens || proxyBody.max_tokens > 4096) {
477
- proxyBody.max_tokens = 4096;
478
- log(`[Proxy] Enforcing max_tokens=4096 for ${actualModel} (Bedrock Limit)`);
479
- }
480
- }
481
561
  // C. GEMINI ID BACKTRACKING & SIGNATURE
482
562
  if ((actualModel.includes("gemini") || actualModel === "nomnom") && proxyBody.messages) {
483
563
  const lastMsg = proxyBody.messages[proxyBody.messages.length - 1];
@@ -567,8 +647,14 @@ export async function handleChatCompletion(req, res, bodyRaw) {
567
647
  actualModel = config.fallbacks.free.main.replace('free/', '');
568
648
  isEnterprise = false;
569
649
  isFallbackActive = true;
570
- if (fetchRes.status === 402)
650
+ if (fetchRes.status === 402) {
571
651
  fallbackReason = "Insufficient Funds (Upstream 402)";
652
+ // Force refresh quota cache so next pre-flight check is accurate
653
+ try {
654
+ await getQuotaStatus(true);
655
+ }
656
+ catch (e) { }
657
+ }
572
658
  else if (fetchRes.status === 429)
573
659
  fallbackReason = "Rate Limit (Upstream 429)";
574
660
  else if (fetchRes.status === 401)
@@ -586,10 +672,10 @@ export async function handleChatCompletion(req, res, bodyRaw) {
586
672
  // 2. Notify
587
673
  emitStatusToast('warning', `⚠️ Safety Net: ${actualModel} (${fallbackReason})`, 'Pollinations Safety');
588
674
  emitLogToast('warning', `Recovering from ${fetchRes.status} -> Switching to ${actualModel}`, 'Safety Net');
589
- // 3. Re-Prepare Request - v6.0: Stay on gen.pollinations.ai
590
- targetUrl = 'https://gen.pollinations.ai/v1/chat/completions';
675
+ // 3. Re-Prepare Request
676
+ targetUrl = 'https://text.pollinations.ai/openai/chat/completions';
591
677
  const retryHeaders = { ...headers };
592
- // Keep Authorization for gen.pollinations.ai
678
+ delete retryHeaders['Authorization']; // Free = No Auth
593
679
  const retryBody = { ...proxyBody, model: actualModel };
594
680
  // 4. Retry Fetch
595
681
  const retryRes = await fetchWithRetry(targetUrl, {
@@ -1,3 +1,11 @@
1
+ export interface DetailedUsageEntry {
2
+ timestamp: string;
3
+ type: string;
4
+ model: string;
5
+ meter_source: 'tier' | 'pack';
6
+ cost_usd: number;
7
+ requests?: number;
8
+ }
1
9
  export interface QuotaStatus {
2
10
  tierRemaining: number;
3
11
  tierUsed: number;
@@ -1,12 +1,17 @@
1
1
  import * as fs from 'fs';
2
+ import * as path from 'path';
2
3
  import * as https from 'https'; // Use Native HTTPS
4
+ import * as crypto from 'crypto';
3
5
  import { loadConfig } from './config.js';
4
- // === CACHE ===
6
+ // === CACHE & CONSTANTS ===
5
7
  const CACHE_TTL = 30000; // 30 secondes
6
8
  let cachedQuota = null;
7
9
  let lastQuotaFetch = 0;
10
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
11
+ const HISTORY_RETENTION_MS = 48 * 60 * 60 * 1000; // 48h history
8
12
  // === TIER LIMITS ===
9
13
  const TIER_LIMITS = {
14
+ microbe: { pollen: 0.1, emoji: '🦠' },
10
15
  spore: { pollen: 1, emoji: '🦠' },
11
16
  seed: { pollen: 3, emoji: '🌱' },
12
17
  flower: { pollen: 10, emoji: '🌸' },
@@ -19,24 +24,71 @@ function logQuota(msg) {
19
24
  }
20
25
  catch (e) { }
21
26
  }
22
- // === FONCTIONS PRINCIPALES ===
27
+ // === HISTORY MANAGER (JSON) ===
28
+ function getHistoryFilePath() {
29
+ const homedir = process.env.HOME || '/tmp';
30
+ const historyDir = path.join(homedir, '.pollinations');
31
+ if (!fs.existsSync(historyDir)) {
32
+ try {
33
+ fs.mkdirSync(historyDir, { recursive: true });
34
+ }
35
+ catch (e) { }
36
+ }
37
+ return path.join(historyDir, 'usage_history.json');
38
+ }
39
+ function computeEntrySignature(entry) {
40
+ // Unique signature per transaction: timestamp + model + cost + source
41
+ return crypto.createHash('md5').update(`${entry.timestamp}|${entry.model}|${entry.cost_usd}|${entry.meter_source}`).digest('hex');
42
+ }
43
+ function updateLocalHistory(newEntries) {
44
+ const filePath = getHistoryFilePath();
45
+ let history = [];
46
+ // 1. Load existing
47
+ try {
48
+ if (fs.existsSync(filePath)) {
49
+ const raw = fs.readFileSync(filePath, 'utf-8');
50
+ history = JSON.parse(raw);
51
+ }
52
+ }
53
+ catch (e) {
54
+ logQuota(`Failed to load history: ${e}`);
55
+ history = [];
56
+ }
57
+ // 2. Merge (Deduplication via Signature)
58
+ const existingSignatures = new Set(history.map(computeEntrySignature));
59
+ let addedCount = 0;
60
+ for (const entry of newEntries) {
61
+ const sig = computeEntrySignature(entry);
62
+ if (!existingSignatures.has(sig)) {
63
+ history.push(entry);
64
+ existingSignatures.add(sig);
65
+ addedCount++;
66
+ }
67
+ }
68
+ // 3. Prune (> 48h)
69
+ const now = Date.now();
70
+ const beforePrune = history.length;
71
+ history = history.filter(e => {
72
+ const entryTime = new Date(e.timestamp.replace(' ', 'T') + 'Z').getTime();
73
+ return (now - entryTime) < HISTORY_RETENTION_MS;
74
+ });
75
+ // 4. Sort (Newest first)
76
+ history.sort((a, b) => new Date(b.timestamp.replace(' ', 'T') + 'Z').getTime() - new Date(a.timestamp.replace(' ', 'T') + 'Z').getTime());
77
+ // 5. Save
78
+ try {
79
+ fs.writeFileSync(filePath, JSON.stringify(history, null, 2));
80
+ logQuota(`History Update: Added ${addedCount}, Pruned ${beforePrune - history.length}, Total ${history.length} entries.`);
81
+ }
82
+ catch (e) {
83
+ logQuota(`Failed to save history: ${e}`);
84
+ }
85
+ return history;
86
+ }
87
+ // === MAIN QUOTA FUNCTION ===
23
88
  export async function getQuotaStatus(forceRefresh = false) {
24
89
  const config = loadConfig();
25
90
  if (!config.apiKey) {
26
- // Pas de clé = Mode manual par défaut, pas de quota
27
- return {
28
- tierRemaining: 0,
29
- tierUsed: 0,
30
- tierLimit: 0,
31
- walletBalance: 0,
32
- nextResetAt: new Date(),
33
- timeUntilReset: 0,
34
- canUseEnterprise: false,
35
- isUsingWallet: false,
36
- needsAlert: false,
37
- tier: 'none',
38
- tierEmoji: '❌'
39
- };
91
+ return createDefaultQuota('none', 0);
40
92
  }
41
93
  const now = Date.now();
42
94
  if (!forceRefresh && cachedQuota && (now - lastQuotaFetch) < CACHE_TTL) {
@@ -44,27 +96,33 @@ export async function getQuotaStatus(forceRefresh = false) {
44
96
  }
45
97
  try {
46
98
  logQuota("Fetching Quota Data...");
99
+ // 1. Fetch API
47
100
  // SEQUENTIAL FETCH (Avoid Rate Limits)
48
- // We fetch one by one. If one fails, we catch and return fallback.
49
101
  const profileRes = await fetchAPI('/account/profile', config.apiKey);
50
102
  const balanceRes = await fetchAPI('/account/balance', config.apiKey);
51
103
  const usageRes = await fetchAPI('/account/usage', config.apiKey);
52
104
  logQuota(`Fetch Success. Tier: ${profileRes.tier}, Balance: ${balanceRes.balance}`);
53
105
  const profile = profileRes;
54
106
  const balance = balanceRes.balance;
55
- const usage = usageRes.usage || [];
56
- const tierInfo = TIER_LIMITS[profile.tier] || { pollen: 1, emoji: '❓' }; // Default 1 (Spore)
107
+ // 2. Update Local History (The Source of Truth)
108
+ const fullHistory = updateLocalHistory(usageRes.usage || []);
109
+ const tierInfo = TIER_LIMITS[profile.tier] || { pollen: 1, emoji: '❓' };
57
110
  const tierLimit = tierInfo.pollen;
58
- // Calculer le reset
111
+ // 3. Calculate Reset & Usage from History
59
112
  const resetInfo = calculateResetInfo(profile.nextResetAt);
60
- // Calculer l'usage de la période actuelle
61
- const { tierUsed } = calculateCurrentPeriodUsage(usage, resetInfo);
113
+ const { tierUsed } = calculateCurrentPeriodUsage(fullHistory, resetInfo);
114
+ // 4. Calculate Balances
62
115
  const tierRemaining = Math.max(0, tierLimit - tierUsed);
63
116
  // Fix rounding errors
64
117
  const cleanTierRemaining = Math.max(0, parseFloat(tierRemaining.toFixed(4)));
65
118
  // Le wallet c'est le reste (balance totale - ce qu'il reste du tier gratuit non consommé)
119
+ // Formula: Pollinations Balance = Wallet + TierRemaining.
66
120
  const walletBalance = Math.max(0, balance - cleanTierRemaining);
67
121
  const cleanWalletBalance = Math.max(0, parseFloat(walletBalance.toFixed(4)));
122
+ // needsAlert: check BOTH tier threshold AND wallet threshold
123
+ const tierAlertPercent = tierLimit > 0 ? (cleanTierRemaining / tierLimit * 100) : 0;
124
+ const tierNeedsAlert = tierLimit > 0 && tierAlertPercent <= config.thresholds.tier;
125
+ const walletNeedsAlert = cleanWalletBalance > 0 && cleanWalletBalance < (config.thresholds.wallet || 0.5);
68
126
  cachedQuota = {
69
127
  tierRemaining: cleanTierRemaining,
70
128
  tierUsed,
@@ -74,7 +132,7 @@ export async function getQuotaStatus(forceRefresh = false) {
74
132
  timeUntilReset: resetInfo.timeUntilReset,
75
133
  canUseEnterprise: cleanTierRemaining > 0.05 || cleanWalletBalance > 0.05,
76
134
  isUsingWallet: cleanTierRemaining <= 0.05 && cleanWalletBalance > 0.05,
77
- needsAlert: tierLimit > 0 ? (cleanTierRemaining / tierLimit * 100) <= config.thresholds.tier : false,
135
+ needsAlert: tierNeedsAlert || walletNeedsAlert,
78
136
  tier: profile.tier,
79
137
  tierEmoji: tierInfo.emoji
80
138
  };
@@ -84,30 +142,29 @@ export async function getQuotaStatus(forceRefresh = false) {
84
142
  catch (e) {
85
143
  logQuota(`ERROR fetching quota: ${e.message}`);
86
144
  let errorType = 'unknown';
87
- if (e.message && e.message.includes('403')) {
145
+ if (e.message && e.message.includes('403'))
88
146
  errorType = 'auth_limited';
89
- }
90
- else if (e.message && e.message.includes('Network Error')) {
147
+ else if (e.message && e.message.includes('Network Error'))
91
148
  errorType = 'network';
92
- }
93
- // Retourner le cache ou un état par défaut safe
94
- return cachedQuota || {
95
- tierRemaining: 0,
96
- tierUsed: 0,
97
- tierLimit: 1,
98
- walletBalance: 0,
99
- nextResetAt: new Date(),
100
- timeUntilReset: 0,
101
- canUseEnterprise: false,
102
- isUsingWallet: false,
103
- needsAlert: true,
104
- tier: 'error',
105
- tierEmoji: '⚠️',
106
- errorType
107
- };
149
+ return cachedQuota || { ...createDefaultQuota('error', 1), errorType };
108
150
  }
109
151
  }
110
- // === HELPERS (Native HTTPS) ===
152
+ function createDefaultQuota(tierName, limit) {
153
+ return {
154
+ tierRemaining: 0,
155
+ tierUsed: 0,
156
+ tierLimit: limit,
157
+ walletBalance: 0,
158
+ nextResetAt: new Date(),
159
+ timeUntilReset: 0,
160
+ canUseEnterprise: false,
161
+ isUsingWallet: false,
162
+ needsAlert: false,
163
+ tier: tierName,
164
+ tierEmoji: TIER_LIMITS[tierName]?.emoji || '❌'
165
+ };
166
+ }
167
+ // === HELPERS ===
111
168
  function fetchAPI(endpoint, apiKey) {
112
169
  return new Promise((resolve, reject) => {
113
170
  const options = {
@@ -146,28 +203,23 @@ function fetchAPI(endpoint, apiKey) {
146
203
  function calculateResetInfo(nextResetAt) {
147
204
  const nextResetFromAPI = new Date(nextResetAt);
148
205
  const now = new Date();
149
- // Extraire l'heure de reset depuis l'API (varie par utilisateur!)
150
206
  const resetHour = nextResetFromAPI.getUTCHours();
151
207
  const resetMinute = nextResetFromAPI.getUTCMinutes();
152
208
  const resetSecond = nextResetFromAPI.getUTCSeconds();
153
- // Calculer le reset d'aujourd'hui à cette heure
154
209
  const todayResetUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), resetHour, resetMinute, resetSecond));
155
210
  let lastReset;
156
211
  let nextReset;
157
212
  if (now >= todayResetUTC) {
158
- // Le reset d'aujourd'hui est passé
159
213
  lastReset = todayResetUTC;
160
- nextReset = new Date(todayResetUTC.getTime() + 24 * 60 * 60 * 1000);
214
+ nextReset = new Date(todayResetUTC.getTime() + ONE_DAY_MS);
161
215
  }
162
216
  else {
163
- // Le reset d'aujourd'hui n'est pas encore passé
164
- lastReset = new Date(todayResetUTC.getTime() - 24 * 60 * 60 * 1000);
217
+ lastReset = new Date(todayResetUTC.getTime() - ONE_DAY_MS);
165
218
  nextReset = todayResetUTC;
166
219
  }
167
220
  const timeUntilReset = nextReset.getTime() - now.getTime();
168
221
  const timeSinceReset = now.getTime() - lastReset.getTime();
169
- const cycleDuration = 24 * 60 * 60 * 1000;
170
- const progressPercent = (timeSinceReset / cycleDuration) * 100;
222
+ const progressPercent = (timeSinceReset / ONE_DAY_MS) * 100;
171
223
  return {
172
224
  nextReset,
173
225
  lastReset,
@@ -182,15 +234,10 @@ function calculateResetInfo(nextResetAt) {
182
234
  function calculateCurrentPeriodUsage(usage, resetInfo) {
183
235
  let tierUsed = 0;
184
236
  let packUsed = 0;
185
- // Parser le timestamp de l'API avec Z pour UTC
186
- function parseUsageTimestamp(timestamp) {
187
- // Format: "2026-01-23 01:11:21"
188
- const isoString = timestamp.replace(' ', 'T') + 'Z';
189
- return new Date(isoString);
190
- }
191
- // FILTRER: Ne garder que les entrées APRÈS le dernier reset
192
237
  const entriesAfterReset = usage.filter(entry => {
193
- const entryTime = parseUsageTimestamp(entry.timestamp);
238
+ // Safe Parse
239
+ const timestamp = entry.timestamp.replace(' ', 'T') + 'Z';
240
+ const entryTime = new Date(timestamp);
194
241
  return entryTime >= resetInfo.lastReset;
195
242
  });
196
243
  for (const entry of entriesAfterReset) {
@@ -203,7 +250,6 @@ function calculateCurrentPeriodUsage(usage, resetInfo) {
203
250
  }
204
251
  return { tierUsed, packUsed };
205
252
  }
206
- // === EXPORT POUR LES ALERTES ===
207
253
  export function formatQuotaForToast(quota) {
208
254
  if (quota.errorType === 'auth_limited') {
209
255
  return `🔑 CLE LIMITÉE (Génération Seule) | 💎 Wallet: N/A | ⏰ Reset: N/A`;
@@ -211,7 +257,6 @@ export function formatQuotaForToast(quota) {
211
257
  const tierPercent = quota.tierLimit > 0
212
258
  ? Math.round((quota.tierRemaining / quota.tierLimit) * 100)
213
259
  : 0;
214
- // Format compact: 1h23m
215
260
  const ms = quota.timeUntilReset;
216
261
  const hours = Math.floor(ms / (1000 * 60 * 60));
217
262
  const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
@@ -4,3 +4,6 @@ export declare function emitStatusToast(type: 'info' | 'warning' | 'error' | 'su
4
4
  export declare function createToastHooks(client: any): {
5
5
  'session.idle': ({ event }: any) => Promise<void>;
6
6
  };
7
+ export declare function createToolHooks(client: any): {
8
+ 'tool.execute.after': (input: any, output: any) => Promise<void>;
9
+ };