opencode-pollinations-plugin 6.1.0-beta.9 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.de.md +130 -0
  2. package/README.es.md +130 -0
  3. package/README.fr.md +130 -0
  4. package/README.it.md +130 -0
  5. package/README.md +87 -73
  6. package/dist/index.js +52 -161
  7. package/dist/locales/de.json +374 -0
  8. package/dist/locales/en.json +373 -0
  9. package/dist/locales/es.json +374 -0
  10. package/dist/locales/fr.json +373 -0
  11. package/dist/locales/index.d.ts +1 -0
  12. package/dist/locales/index.js +37 -0
  13. package/dist/locales/it.json +374 -0
  14. package/dist/server/commands.d.ts +6 -0
  15. package/dist/server/commands.js +394 -125
  16. package/dist/server/config.d.ts +34 -23
  17. package/dist/server/config.js +200 -108
  18. package/dist/server/connect-response.d.ts +2 -0
  19. package/dist/server/connect-response.js +59 -0
  20. package/dist/server/generate-config.d.ts +3 -30
  21. package/dist/server/generate-config.js +164 -106
  22. package/dist/server/index.d.ts +2 -1
  23. package/dist/server/index.js +124 -149
  24. package/dist/server/logger.d.ts +8 -0
  25. package/dist/server/logger.js +38 -0
  26. package/dist/server/models/cache.d.ts +35 -0
  27. package/dist/server/models/cache.js +160 -0
  28. package/dist/server/models/fetcher.d.ts +18 -0
  29. package/dist/server/models/fetcher.js +194 -0
  30. package/dist/server/models/index.d.ts +6 -0
  31. package/dist/server/models/index.js +5 -0
  32. package/dist/server/models/manual.d.ts +15 -0
  33. package/dist/server/models/manual.js +92 -0
  34. package/dist/server/models/types.d.ts +55 -0
  35. package/dist/server/models/types.js +7 -0
  36. package/dist/server/models/worker.d.ts +22 -0
  37. package/dist/server/models/worker.js +174 -0
  38. package/dist/server/pollinations-api.d.ts +11 -0
  39. package/dist/server/pollinations-api.js +21 -8
  40. package/dist/server/proxy.js +222 -307
  41. package/dist/server/quota.d.ts +2 -0
  42. package/dist/server/quota.js +89 -86
  43. package/dist/server/scripts/pollinations_pricing.d.ts +8 -0
  44. package/dist/server/scripts/pollinations_pricing.js +246 -0
  45. package/dist/server/scripts/test_cost_endpoints.d.ts +1 -0
  46. package/dist/server/scripts/test_cost_endpoints.js +61 -0
  47. package/dist/server/scripts/test_dynamic_pricing.d.ts +1 -0
  48. package/dist/server/scripts/test_dynamic_pricing.js +39 -0
  49. package/dist/server/scripts/test_freetier_audit.d.ts +11 -0
  50. package/dist/server/scripts/test_freetier_audit.js +215 -0
  51. package/dist/server/scripts/test_parallel_cost.d.ts +1 -0
  52. package/dist/server/scripts/test_parallel_cost.js +104 -0
  53. package/dist/server/toast.d.ts +7 -1
  54. package/dist/server/toast.js +43 -10
  55. package/dist/tools/design/gen_diagram.d.ts +2 -0
  56. package/dist/tools/design/gen_diagram.js +94 -0
  57. package/dist/tools/design/gen_palette.d.ts +2 -0
  58. package/dist/tools/design/gen_palette.js +182 -0
  59. package/dist/tools/design/gen_qrcode.d.ts +2 -0
  60. package/dist/tools/design/gen_qrcode.js +50 -0
  61. package/dist/tools/ffmpeg.d.ts +24 -0
  62. package/dist/tools/ffmpeg.js +54 -0
  63. package/dist/tools/index.d.ts +25 -0
  64. package/dist/tools/index.js +86 -0
  65. package/dist/tools/pollinations/beta_discovery.d.ts +9 -0
  66. package/dist/tools/pollinations/beta_discovery.js +201 -0
  67. package/dist/tools/pollinations/cost-guard.d.ts +38 -0
  68. package/dist/tools/pollinations/cost-guard.js +136 -0
  69. package/dist/tools/pollinations/deepsearch.d.ts +7 -0
  70. package/dist/tools/pollinations/deepsearch.js +80 -0
  71. package/dist/tools/pollinations/gen_audio.d.ts +18 -0
  72. package/dist/tools/pollinations/gen_audio.js +220 -0
  73. package/dist/tools/pollinations/gen_image.d.ts +11 -0
  74. package/dist/tools/pollinations/gen_image.js +211 -0
  75. package/dist/tools/pollinations/gen_music.d.ts +14 -0
  76. package/dist/tools/pollinations/gen_music.js +157 -0
  77. package/dist/tools/pollinations/gen_video.d.ts +16 -0
  78. package/dist/tools/pollinations/gen_video.js +249 -0
  79. package/dist/tools/pollinations/polli_config.d.ts +2 -0
  80. package/dist/tools/pollinations/polli_config.js +95 -0
  81. package/dist/tools/pollinations/polli_gen_confirm.d.ts +2 -0
  82. package/dist/tools/pollinations/polli_gen_confirm.js +48 -0
  83. package/dist/tools/pollinations/polli_status.d.ts +2 -0
  84. package/dist/tools/pollinations/polli_status.js +31 -0
  85. package/dist/tools/pollinations/polli_web_search.d.ts +15 -0
  86. package/dist/tools/pollinations/polli_web_search.js +126 -0
  87. package/dist/tools/pollinations/search_crawl_scrape.d.ts +7 -0
  88. package/dist/tools/pollinations/search_crawl_scrape.js +85 -0
  89. package/dist/tools/pollinations/shared.d.ts +181 -0
  90. package/dist/tools/pollinations/shared.js +758 -0
  91. package/dist/tools/pollinations/test_estimators.d.ts +1 -0
  92. package/dist/tools/pollinations/test_estimators.js +22 -0
  93. package/dist/tools/pollinations/transcribe_audio.d.ts +13 -0
  94. package/dist/tools/pollinations/transcribe_audio.js +171 -0
  95. package/dist/tools/power/extract_audio.d.ts +2 -0
  96. package/dist/tools/power/extract_audio.js +179 -0
  97. package/dist/tools/power/extract_frames.d.ts +2 -0
  98. package/dist/tools/power/extract_frames.js +237 -0
  99. package/dist/tools/power/file_to_url.d.ts +2 -0
  100. package/dist/tools/power/file_to_url.js +217 -0
  101. package/dist/tools/power/remove_background.d.ts +2 -0
  102. package/dist/tools/power/remove_background.js +404 -0
  103. package/dist/tools/power/rmbg_keys.d.ts +2 -0
  104. package/dist/tools/power/rmbg_keys.js +79 -0
  105. package/dist/tools/shared.d.ts +30 -0
  106. package/dist/tools/shared.js +80 -0
  107. package/package.json +10 -4
  108. package/dist/server/models-seed.d.ts +0 -18
  109. package/dist/server/models-seed.js +0 -55
@@ -3,26 +3,22 @@ import * as path from 'path';
3
3
  import { loadConfig, saveConfig } from './config.js';
4
4
  import { handleCommand } from './commands.js';
5
5
  import { emitStatusToast, emitLogToast } from './toast.js';
6
+ import { buildConnectResponse } from './connect-response.js';
7
+ import { log } from './logger.js';
8
+ import { getConfigDir } from './config.js';
9
+ import { t } from '../locales/index.js';
6
10
  // --- PERSISTENCE: SIGNATURE MAP (Multi-Round Support) ---
7
- const SIG_FILE = path.join(process.env.HOME || '/tmp', '.config/opencode/pollinations-signature.json');
11
+ const SIG_FILE = path.join(getConfigDir(), 'pollinations-signature.json');
8
12
  let signatureMap = {};
9
13
  let lastSignature = null; // V1 Fallback Global
10
- function log(msg) {
11
- try {
12
- const ts = new Date().toISOString();
13
- if (!fs.existsSync('/tmp/opencode_pollinations_debug.log')) {
14
- fs.writeFileSync('/tmp/opencode_pollinations_debug.log', '');
15
- }
16
- fs.appendFileSync('/tmp/opencode_pollinations_debug.log', `[Proxy] ${ts} ${msg}\n`);
17
- }
18
- catch (e) { }
19
- }
20
14
  try {
21
15
  if (fs.existsSync(SIG_FILE)) {
22
16
  signatureMap = JSON.parse(fs.readFileSync(SIG_FILE, 'utf-8'));
23
17
  }
24
18
  }
25
- catch (e) { }
19
+ catch (e) {
20
+ log(`[Proxy Signature] Error loading: ${e}`);
21
+ }
26
22
  function saveSignatureMap() {
27
23
  try {
28
24
  if (!fs.existsSync(path.dirname(SIG_FILE)))
@@ -78,6 +74,23 @@ function dereferenceSchema(schema, rootDefs) {
78
74
  schema.description = (schema.description || "") + " [Ref Failed]";
79
75
  }
80
76
  }
77
+ // VERTEX FIX: 'const' not supported -> convert to 'enum'
78
+ if (schema.const !== undefined) {
79
+ schema.enum = [schema.const];
80
+ delete schema.const;
81
+ }
82
+ // VERTEX FIX: 'anyOf' must be exclusive (no other siblings)
83
+ if (schema.anyOf || schema.oneOf) {
84
+ // Vertex demands strict exclusivity.
85
+ // We keep 'definitions'/'$defs' if present at root (though unlikely here)
86
+ // But for a property node, we must strip EVERYTHING else.
87
+ const keys = Object.keys(schema);
88
+ keys.forEach(k => {
89
+ if (k !== 'anyOf' && k !== 'oneOf' && k !== 'definitions' && k !== '$defs') {
90
+ delete schema[k];
91
+ }
92
+ });
93
+ }
81
94
  if (schema.properties) {
82
95
  for (const key in schema.properties) {
83
96
  schema.properties[key] = dereferenceSchema(schema.properties[key], rootDefs);
@@ -86,6 +99,15 @@ function dereferenceSchema(schema, rootDefs) {
86
99
  if (schema.items) {
87
100
  schema.items = dereferenceSchema(schema.items, rootDefs);
88
101
  }
102
+ if (schema.anyOf) {
103
+ schema.anyOf = schema.anyOf.map((s) => dereferenceSchema(s, rootDefs));
104
+ }
105
+ if (schema.oneOf) {
106
+ schema.oneOf = schema.oneOf.map((s) => dereferenceSchema(s, rootDefs));
107
+ }
108
+ if (schema.allOf) {
109
+ schema.allOf = schema.allOf.map((s) => dereferenceSchema(s, rootDefs));
110
+ }
89
111
  if (schema.optional !== undefined)
90
112
  delete schema.optional;
91
113
  if (schema.title)
@@ -107,141 +129,55 @@ function sanitizeToolsForVertex(tools) {
107
129
  return tool;
108
130
  });
109
131
  }
110
- function truncateTools(tools, limit = 120) {
111
- if (!tools || tools.length <= limit)
112
- return tools;
113
- return tools.slice(0, limit);
114
- }
115
- // --- BEDROCK COMPATIBILITY SHIM ---
116
- // Bedrock requires:
117
- // 1. toolUseId matching [a-zA-Z0-9_-]+ (no dots, spaces, special chars)
118
- // 2. toolConfig present when toolUse/toolResult in messages
119
- function sanitizeToolUseId(id) {
120
- // Replace any char not in [a-zA-Z0-9_-] with underscore
121
- return id.replace(/[^a-zA-Z0-9_-]/g, '_');
122
- }
123
- function sanitizeForBedrock(body) {
124
- if (!body)
125
- return body;
126
- const sanitized = JSON.parse(JSON.stringify(body)); // Deep clone
127
- // 1. Sanitize toolUseId in all messages AND detect tool history
128
- let hasToolHistory = false;
129
- const toolNamesFromHistory = [];
130
- if (sanitized.messages && Array.isArray(sanitized.messages)) {
131
- for (const msg of sanitized.messages) {
132
- if (msg.content && Array.isArray(msg.content)) {
133
- for (const block of msg.content) {
134
- // Handle toolUse blocks
135
- if (block.toolUse && block.toolUse.toolUseId) {
136
- block.toolUse.toolUseId = sanitizeToolUseId(block.toolUse.toolUseId);
137
- hasToolHistory = true;
138
- if (block.toolUse.name)
139
- toolNamesFromHistory.push(block.toolUse.name);
140
- }
141
- // Handle toolResult blocks
142
- if (block.toolResult && block.toolResult.toolUseId) {
143
- block.toolResult.toolUseId = sanitizeToolUseId(block.toolResult.toolUseId);
144
- hasToolHistory = true;
145
- }
146
- // Handle OpenAI-style tool_use in content array
147
- if (block.type === 'tool_use' && block.id) {
148
- block.id = sanitizeToolUseId(block.id);
149
- hasToolHistory = true;
150
- if (block.name)
151
- toolNamesFromHistory.push(block.name);
152
- }
153
- }
154
- }
155
- // Handle assistant tool_calls array (OpenAI format)
156
- if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
157
- hasToolHistory = true;
158
- for (const tc of msg.tool_calls) {
159
- if (tc.id)
160
- tc.id = sanitizeToolUseId(tc.id);
161
- if (tc.function?.name)
162
- toolNamesFromHistory.push(tc.function.name);
163
- }
164
- }
165
- // Handle tool role message (OpenAI format)
166
- if (msg.role === 'tool') {
167
- hasToolHistory = true;
168
- if (msg.tool_call_id) {
169
- msg.tool_call_id = sanitizeToolUseId(msg.tool_call_id);
170
- }
132
+ function sanitizeToolsForBedrock(tools) {
133
+ return tools.map(tool => {
134
+ if (tool.function) {
135
+ if (!tool.function.description || tool.function.description.length === 0) {
136
+ tool.function.description = " "; // Force non-empty string
171
137
  }
172
138
  }
139
+ return tool;
140
+ });
141
+ }
142
+ function sanitizeSchemaForKimi(schema) {
143
+ if (!schema || typeof schema !== 'object')
144
+ return schema;
145
+ // Kimi Fixes
146
+ if (schema.title)
147
+ delete schema.title;
148
+ // Fix empty objects "{}" which Kimi hates.
149
+ // If it's an empty object without type, assume string or object?
150
+ // Often happens with "additionalProperties: {}"
151
+ if (Object.keys(schema).length === 0) {
152
+ schema.type = "string"; // Fallback to safe type
153
+ schema.description = "Any value";
173
154
  }
174
- // 2. Add toolConfig if tools present
175
- if (sanitized.tools && Array.isArray(sanitized.tools) && sanitized.tools.length > 0) {
176
- if (!sanitized.tool_config && !sanitized.toolConfig) {
177
- // Build toolConfig from tools array (Bedrock Converse format)
178
- sanitized.tool_config = {
179
- tools: sanitized.tools.map((t) => {
180
- if (t.function) {
181
- // OpenAI format -> Bedrock format
182
- return {
183
- toolSpec: {
184
- name: t.function.name,
185
- description: t.function.description || '',
186
- inputSchema: {
187
- json: t.function.parameters || { type: 'object', properties: {} }
188
- }
189
- }
190
- };
191
- }
192
- return t; // Already in Bedrock format
193
- })
194
- };
155
+ if (schema.properties) {
156
+ for (const key in schema.properties) {
157
+ schema.properties[key] = sanitizeSchemaForKimi(schema.properties[key]);
195
158
  }
196
159
  }
197
- // 3. CRITICAL: If no tools BUT history has tool calls, inject shim tools array
198
- else if (hasToolHistory) {
199
- // Bedrock requires toolConfig when history contains toolUse/toolResult
200
- // Pollinations builds toolConfig from the 'tools' array, not from our 'tool_config'
201
- // So we MUST inject 'tools' in OpenAI format, not just 'tool_config'
202
- const uniqueToolNames = [...new Set(toolNamesFromHistory)];
203
- // Build OpenAI-format tools array (this is what Pollinations actually uses!)
204
- const shimTools = uniqueToolNames.length > 0
205
- ? uniqueToolNames.map(name => ({
206
- type: 'function',
207
- function: {
208
- name: name,
209
- description: 'Tool inferred from conversation history (Bedrock compatibility shim)',
210
- parameters: { type: 'object', properties: {} }
211
- }
212
- }))
213
- : [{
214
- type: 'function',
215
- function: {
216
- name: '_bedrock_shim_tool',
217
- description: 'Internal shim to satisfy Bedrock toolConfig requirement',
218
- parameters: { type: 'object', properties: {} }
219
- }
220
- }];
221
- // Inject the tools array (Pollinations format)
222
- sanitized.tools = shimTools;
223
- // Also set tool_config for direct Bedrock passthrough (belt & suspenders)
224
- sanitized.tool_config = {
225
- tools: shimTools.map(t => ({
226
- toolSpec: {
227
- name: t.function.name,
228
- description: t.function.description,
229
- inputSchema: { json: t.function.parameters }
230
- }
231
- }))
232
- };
233
- log(`[Bedrock Shim] Injected ${shimTools.length} shim tool(s) for history compatibility: [${uniqueToolNames.join(', ') || '_bedrock_shim_tool'}]`);
234
- }
235
- return sanitized;
160
+ if (schema.items)
161
+ sanitizeSchemaForKimi(schema.items);
162
+ return schema;
163
+ }
164
+ function truncateTools(tools, limit = 120) {
165
+ if (!tools || tools.length <= limit)
166
+ return tools;
167
+ return tools.slice(0, limit);
236
168
  }
237
169
  const MAX_RETRIES = 3;
238
170
  const RETRY_DELAY_MS = 1000;
171
+ const FETCH_TIMEOUT_MS = 600000; // 10 Minutes global timeout
239
172
  function sleep(ms) {
240
173
  return new Promise(resolve => setTimeout(resolve, ms));
241
174
  }
242
175
  async function fetchWithRetry(url, options, retries = MAX_RETRIES) {
243
176
  try {
244
- const response = await fetch(url, options);
177
+ const controller = new AbortController();
178
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
179
+ const response = await fetch(url, { ...options, signal: controller.signal });
180
+ clearTimeout(timeoutId);
245
181
  if (response.ok)
246
182
  return response;
247
183
  if (response.status === 404 || response.status === 401 || response.status === 400) {
@@ -275,11 +211,6 @@ export async function handleChatCompletion(req, res, bodyRaw) {
275
211
  const config = loadConfig();
276
212
  // DEBUG: Trace Config State for Hot Reload verification
277
213
  log(`[Proxy Request] Config Loaded. Mode: ${config.mode}, HasKey: ${!!config.apiKey}, KeyLength: ${config.apiKey ? config.apiKey.length : 0}`);
278
- // SPY LOGGING
279
- try {
280
- fs.appendFileSync('/tmp/opencode_spy.log', `\n\n=== REQUEST ${new Date().toISOString()} ===\nMODEL: ${body.model}\nBODY:\n${JSON.stringify(body, null, 2)}\n==========================\n`);
281
- }
282
- catch (e) { }
283
214
  // 0. COMMAND HANDLING
284
215
  if (body.messages && body.messages.length > 0) {
285
216
  const lastMsg = body.messages[body.messages.length - 1];
@@ -330,6 +261,31 @@ export async function handleChatCompletion(req, res, bodyRaw) {
330
261
  }
331
262
  }
332
263
  log(`Incoming Model (OpenCode ID): ${body.model}`);
264
+ // 0. SPECIAL: pollinations/connect (Guide & Status)
265
+ const CONNECT_MODEL_IDS = ['pollinations/connect', 'free/pollinations/connect', 'enter/pollinations/connect', 'connect-pollinations'];
266
+ if (CONNECT_MODEL_IDS.includes(body.model)) {
267
+ const guideContent = await buildConnectResponse(config);
268
+ res.writeHead(200, {
269
+ 'Content-Type': 'text/event-stream',
270
+ 'Cache-Control': 'no-cache',
271
+ 'Connection': 'keep-alive'
272
+ });
273
+ const chunk = JSON.stringify({
274
+ id: 'connect-' + Date.now(),
275
+ object: 'chat.completion.chunk',
276
+ created: Math.floor(Date.now() / 1000),
277
+ model: 'pollinations/connect',
278
+ choices: [{
279
+ index: 0,
280
+ delta: { role: 'assistant', content: guideContent },
281
+ finish_reason: 'stop' // Instant finish
282
+ }]
283
+ });
284
+ res.write(`data: ${chunk}\n\n`);
285
+ res.write(`data: [DONE]\n\n`);
286
+ res.end();
287
+ return;
288
+ }
333
289
  // 1. STRICT ROUTING & SAFETY NET LOGIC (V5)
334
290
  let actualModel = body.model || "openai";
335
291
  let isEnterprise = false;
@@ -338,16 +294,15 @@ export async function handleChatCompletion(req, res, bodyRaw) {
338
294
  // LOAD QUOTA FOR SAFETY CHECKS
339
295
  const { getQuotaStatus, formatQuotaForToast } = await import('./quota.js');
340
296
  const quota = await getQuotaStatus(false);
341
- // A. Resolve Base Target - v6.0: All models go to gen.pollinations.ai
342
- // Strip any legacy prefixes for backwards compatibility
343
- if (actualModel.startsWith('enter/') || actualModel.startsWith('enter-')) {
344
- actualModel = actualModel.replace(/^enter[-/]/, '');
297
+ // A. Resolve Base Target
298
+ if (actualModel.startsWith('enter/')) {
299
+ isEnterprise = true;
300
+ actualModel = actualModel.replace('enter/', '');
345
301
  }
346
- else if (actualModel.startsWith('free/') || actualModel.startsWith('free-')) {
347
- actualModel = actualModel.replace(/^free[-/]/, '');
302
+ else if (actualModel.startsWith('free/')) {
303
+ isEnterprise = false;
304
+ actualModel = actualModel.replace('free/', '');
348
305
  }
349
- // v6.0: Everything is enterprise now (requires API key)
350
- isEnterprise = true;
351
306
  // A.1 PAID MODEL ENFORCEMENT (V5.5 Strategy)
352
307
  // Check dynamic list saved by generate-config.ts
353
308
  if (isEnterprise) {
@@ -367,8 +322,13 @@ export async function handleChatCompletion(req, res, bodyRaw) {
367
322
  if (quota.walletBalance <= 0.001) { // Floating point safety
368
323
  log(`[SafetyNet] Paid Only Model (${actualModel}) requested but Wallet is Empty ($${quota.walletBalance}). BLOCKING.`);
369
324
  // Immediate Block or Fallback?
370
- // Fallback based on current mode
371
- actualModel = config.fallbacks.economy || 'nova-fast';
325
+ // Text says: "💎 Paid Only models require purchased pollen only"
326
+ // Blocking is safer/clearer than falling back to a free model which might not be what the user expects for a "Pro" feature?
327
+ // Actually, Fallback to Free is usually better for UX if configured, BUT for specific "Paid Only" requests, the user explicitly chose a powerful model.
328
+ // Falling back to Mistral might be confusing if they asked for Gemini-Large.
329
+ // BUT we are failing gracefully.
330
+ // Let's Fallback to Free Default and Warn.
331
+ actualModel = config.fallbacks.free.main.replace('free/', '');
372
332
  isEnterprise = false;
373
333
  isFallbackActive = true;
374
334
  fallbackReason = "Paid Only Model requires purchased credits";
@@ -394,127 +354,121 @@ export async function handleChatCompletion(req, res, bodyRaw) {
394
354
  // WE DO NOT RETURN 403. WE ALLOW THE REQUEST.
395
355
  // Since config.mode is now 'manual', the next checks (alwaysfree/pro) will be skipped.
396
356
  }
397
- // === MODE ECONOMY ===
398
- // - Paid models BLOCKED (not fallback, BLOCK with message)
399
- // - tier < threshold → fallback economy
400
- // - tier = 0 → STOP (no wallet access)
401
- if (config.mode === 'economy') {
357
+ if (config.mode === 'alwaysfree') {
402
358
  if (isEnterprise) {
403
- // 1. BLOCK Paid Models
359
+ // Paid Only Check: BLOCK (not fallback) in AlwaysFree mode
404
360
  try {
405
361
  const homedir = process.env.HOME || '/tmp';
406
362
  const standardPaidPath = path.join(homedir, '.pollinations', 'pollinations-paid-models.json');
407
363
  if (fs.existsSync(standardPaidPath)) {
408
364
  const paidModels = JSON.parse(fs.readFileSync(standardPaidPath, 'utf-8'));
409
365
  if (paidModels.includes(actualModel)) {
410
- log(`[Economy] BLOCKED: Paid model ${actualModel} not allowed in Economy mode`);
411
- emitStatusToast('error', `💎 Modèle payant interdit en mode Economy: ${actualModel}`, 'Mode Economy');
412
- res.writeHead(403, { 'Content-Type': 'application/json' });
413
- res.end(JSON.stringify({
414
- error: {
415
- message: `💎 Paid model "${actualModel}" is not allowed in Economy mode. Switch to Pro mode or use a free model.`,
416
- code: 'ECONOMY_PAID_BLOCKED'
417
- }
418
- }));
366
+ log(`[AlwaysFree] BLOCKED: Paid Only Model (${actualModel}).`);
367
+ emitStatusToast('warning', t('proxy.warnings.paid_blocked_alwaysfree_title', { model: 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: t('proxy.warnings.paid_blocked_alwaysfree_msg', { model: actualModel })
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));
419
385
  return;
420
386
  }
421
387
  }
422
388
  }
423
389
  catch (e) {
424
- log(`[Economy] Error checking paid models: ${e}`);
390
+ log(`[Proxy AlwaysFree] Error checking paid models: ${e}`);
425
391
  }
426
- // 2. Check Tier
427
- if (quota.tier === 'error') {
428
- log(`[Economy] Quota unreachable, switching to fallback`);
429
- actualModel = config.fallbacks.economy || 'nova-fast';
392
+ if (!isFallbackActive && quota.tier === 'error') {
393
+ // Network error or unknown error (but NOT auth_limited, handled above)
394
+ log(`[SafetyNet] AlwaysFree Mode: Quota Check Failed. Switching to Free Fallback.`);
395
+ emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), 'AlwaysFree Mode');
396
+ actualModel = config.fallbacks.free.main.replace('free/', '');
397
+ isEnterprise = false;
430
398
  isFallbackActive = true;
431
- fallbackReason = "Quota Unreachable (Safety)";
399
+ fallbackReason = t('proxy.warnings.quota_unreachable_msg');
432
400
  }
433
401
  else {
434
402
  const tierRatio = quota.tierLimit > 0 ? (quota.tierRemaining / quota.tierLimit) : 0;
435
- // 3. STOP if tier = 0
436
- if (quota.tierRemaining <= 0.01) {
437
- log(`[Economy] STOP: Tier exhausted, no wallet access in Economy mode`);
438
- emitStatusToast('error', `🛑 Quota épuisé! Attendez le reset ou passez en mode Pro.`, 'Mode Economy');
439
- res.writeHead(429, { 'Content-Type': 'application/json' });
440
- res.end(JSON.stringify({
441
- error: {
442
- message: `🛑 Daily quota exhausted. Wait for reset or switch to Pro mode to use wallet credits.`,
443
- code: 'ECONOMY_TIER_EXHAUSTED'
444
- }
445
- }));
446
- return;
447
- }
448
- // 4. Fallback if tier < threshold
449
403
  if (tierRatio <= (config.thresholds.tier / 100)) {
450
- log(`[Economy] Tier ${(tierRatio * 100).toFixed(1)}% <= ${config.thresholds.tier}%, switching to fallback`);
451
- actualModel = config.fallbacks.economy || 'nova-fast';
404
+ log(`[SafetyNet] AlwaysFree Mode: Tier (${(tierRatio * 100).toFixed(1)}%) <= Threshold (${config.thresholds.tier}%). Switching.`);
405
+ emitStatusToast('warning', t('proxy.warnings.tier_limit_title', { threshold: config.thresholds.tier }), 'AlwaysFree Mode');
406
+ actualModel = config.fallbacks.free.main.replace('free/', '');
407
+ isEnterprise = false;
452
408
  isFallbackActive = true;
453
- fallbackReason = `Tier < ${config.thresholds.tier}% (Economie active)`;
409
+ fallbackReason = t('proxy.warnings.tier_limit_msg', { threshold: config.thresholds.tier });
454
410
  }
455
411
  }
456
412
  }
457
413
  }
458
- // === MODE PRO ===
459
- // - Paid models ALLOWED
460
- // - wallet < wallet_stop $ → STOP
461
- // - wallet < wallet_warn % → fallback pro
462
- // - tier exhausted → info toast (continue on wallet)
463
414
  else if (config.mode === 'pro') {
464
415
  if (isEnterprise) {
465
- // Init session wallet tracking (first request)
466
- if (!config.session?.wallet_initial && quota.walletBalance > 0) {
467
- const { initSessionWallet } = await import('./config.js');
468
- initSessionWallet(quota.walletBalance);
469
- }
470
416
  if (quota.tier === 'error') {
471
- log(`[Pro] Quota unreachable, switching to fallback`);
472
- actualModel = config.fallbacks.pro || 'qwen-coder';
417
+ // Network error or unknown
418
+ log(`[SafetyNet] Pro Mode: Quota Unreachable. Switching to Free Fallback.`);
419
+ emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), 'Pro Mode');
420
+ actualModel = config.fallbacks.free.main.replace('free/', '');
421
+ isEnterprise = false;
473
422
  isFallbackActive = true;
474
- fallbackReason = "Quota Unreachable (Safety)";
423
+ fallbackReason = t('proxy.warnings.quota_unreachable_msg');
475
424
  }
476
425
  else {
477
- const walletStop = config.thresholds.wallet_stop || 0.50;
478
- const walletWarnPercent = config.thresholds.wallet_warn || 20;
479
- const walletInitial = config.session?.wallet_initial || quota.walletBalance;
480
- const walletPercent = walletInitial > 0 ? (quota.walletBalance / walletInitial) * 100 : 100;
481
- // 1. STOP if wallet < wallet_stop $
482
- if (quota.walletBalance < walletStop) {
483
- log(`[Pro] STOP: Wallet $${quota.walletBalance} < limit $${walletStop}`);
484
- emitStatusToast('error', `🛑 Wallet $${quota.walletBalance.toFixed(2)} sous limite $${walletStop}`, 'Mode Pro');
485
- res.writeHead(429, { 'Content-Type': 'application/json' });
486
- res.end(JSON.stringify({
487
- error: {
488
- message: `🛑 Wallet ($${quota.walletBalance.toFixed(2)}) below hard limit ($${walletStop}). Add credits or adjust wallet_stop threshold.`,
489
- code: 'PRO_WALLET_LIMIT'
490
- }
491
- }));
492
- return;
426
+ const tierRatio = quota.tierLimit > 0 ? (quota.tierRemaining / quota.tierLimit) : 0;
427
+ if (quota.walletBalance < config.thresholds.wallet && tierRatio <= (config.thresholds.tier / 100)) {
428
+ log(`[SafetyNet] Pro Mode: Wallet < $${config.thresholds.wallet} AND Tier < ${config.thresholds.tier}%. Switching.`);
429
+ emitStatusToast('warning', t('proxy.warnings.wallet_tier_critical_title', { wallet: config.thresholds.wallet, tier: config.thresholds.tier }), 'Pro Mode');
430
+ actualModel = config.fallbacks.free.main.replace('free/', '');
431
+ isEnterprise = false;
432
+ isFallbackActive = true;
433
+ fallbackReason = t('proxy.warnings.wallet_tier_critical_msg', { wallet: config.thresholds.wallet, tier: config.thresholds.tier });
493
434
  }
494
- // 2. Fallback if wallet% < wallet_warn%
495
- if (walletPercent <= walletWarnPercent) {
496
- log(`[Pro] Wallet ${walletPercent.toFixed(1)}% <= ${walletWarnPercent}%, switching to fallback`);
497
- actualModel = config.fallbacks.pro || 'qwen-coder';
435
+ else if (quota.walletBalance < config.thresholds.wallet) {
436
+ log(`[SafetyNet] Pro Mode: Wallet < $${config.thresholds.wallet}. Switching.`);
437
+ emitStatusToast('warning', t('proxy.warnings.wallet_limit_title', { wallet: config.thresholds.wallet }), 'Pro Mode');
438
+ actualModel = config.fallbacks.free.main.replace('free/', '');
439
+ isEnterprise = false;
498
440
  isFallbackActive = true;
499
- fallbackReason = `Wallet < ${walletWarnPercent}% (${quota.walletBalance.toFixed(2)}$)`;
441
+ fallbackReason = t('proxy.warnings.wallet_limit_msg', { threshold: config.thresholds.wallet });
500
442
  }
501
- // 3. Info if tier exhausted (continue on wallet)
502
- if (quota.tierRemaining <= 0.01 && !isFallbackActive) {
503
- emitStatusToast('info', `ℹ️ Tier épuisé, utilisation du wallet ($${quota.walletBalance.toFixed(2)})`, 'Mode Pro');
443
+ else if (tierRatio <= (config.thresholds.tier / 100)) {
444
+ log(`[SafetyNet] Pro Mode: Tier < ${config.thresholds.tier}%. Switching.`);
445
+ emitStatusToast('warning', t('proxy.warnings.tier_limit_title', { threshold: config.thresholds.tier }), 'Pro Mode');
446
+ actualModel = config.fallbacks.free.main.replace('free/', '');
447
+ isEnterprise = false;
448
+ isFallbackActive = true;
449
+ fallbackReason = t('proxy.warnings.tier_limit_msg', { threshold: config.thresholds.tier });
504
450
  }
505
451
  }
506
452
  }
507
453
  }
508
- // C. Construct URL & Headers - v6.0: Always gen.pollinations.ai
509
- if (!config.apiKey) {
510
- emitLogToast('error', "Missing API Key - Use /pollinations connect <key>", 'Proxy Error');
511
- res.writeHead(401, { 'Content-Type': 'application/json' });
512
- res.end(JSON.stringify({ error: { message: "API Key required. Use /pollinations connect <your_key> to connect." } }));
513
- return;
454
+ // C. Construct URL & Headers
455
+ if (isEnterprise) {
456
+ if (!config.apiKey) {
457
+ emitLogToast('error', "Missing API Key for Enterprise Model", 'Proxy Error');
458
+ res.writeHead(401, { 'Content-Type': 'application/json' });
459
+ res.end(JSON.stringify({ error: { message: "API Key required for Enterprise models." } }));
460
+ return;
461
+ }
462
+ targetUrl = 'https://gen.pollinations.ai/v1/chat/completions';
463
+ authHeader = `Bearer ${config.apiKey}`;
464
+ log(`Routing to ENTERPRISE: ${actualModel}`);
465
+ }
466
+ else {
467
+ targetUrl = 'https://text.pollinations.ai/openai/chat/completions';
468
+ authHeader = undefined;
469
+ log(`Routing to FREE: ${actualModel} ${isFallbackActive ? '(FALLBACK)' : ''}`);
470
+ // emitLogToast('info', `Routing to: FREE UNIVERSE (${actualModel})`, 'Pollinations Routing'); // Too noisy
514
471
  }
515
- targetUrl = 'https://gen.pollinations.ai/v1/chat/completions';
516
- authHeader = `Bearer ${config.apiKey}`;
517
- log(`Routing to gen.pollinations.ai: ${actualModel}`);
518
472
  // NOTIFY SWITCH
519
473
  if (isFallbackActive) {
520
474
  emitStatusToast('warning', `⚠️ Safety Net: ${actualModel} (${fallbackReason})`, 'Pollinations Safety');
@@ -546,17 +500,24 @@ export async function handleChatCompletion(req, res, bodyRaw) {
546
500
  // LOGIC BLOCK: MODEL SPECIFIC ADAPTATIONS
547
501
  // =========================================================
548
502
  if (proxyBody.tools && Array.isArray(proxyBody.tools) && proxyBody.tools.length > 0) {
549
- // B0. KIMI / MOONSHOT SURGICAL FIX (Restored for Debug)
550
- // Tools are ENABLED. We rely on penalties and strict stops to fight loops.
503
+ // B0. KIMI / MOONSHOT SURGICAL FIX
551
504
  if (actualModel.includes("kimi") || actualModel.includes("moonshot")) {
552
- log(`[Proxy] Kimi: Tools ENABLED. Applying penalties/stops.`);
505
+ log(`[Proxy] Kimi: Tools ENABLED. Applying penalties/stops/sanitization.`);
553
506
  proxyBody.frequency_penalty = 1.1;
554
507
  proxyBody.presence_penalty = 0.4;
555
508
  proxyBody.stop = ["<|endoftext|>", "User:", "\nUser", "User :"];
509
+ // KIMI FIX: Remove 'title' from schema
510
+ proxyBody.tools = proxyBody.tools.map((t) => {
511
+ if (t.function && t.function.parameters) {
512
+ t.function.parameters = sanitizeSchemaForKimi(t.function.parameters);
513
+ }
514
+ return t;
515
+ });
556
516
  }
557
- // A. AZURE/OPENAI FIXES
558
- if (actualModel.includes("gpt") || actualModel.includes("openai") || actualModel.includes("azure")) {
559
- proxyBody.tools = truncateTools(proxyBody.tools, 120);
517
+ // A. AZURE/OPENAI FIXES + MIDJOURNEY + GROK
518
+ if (actualModel.includes("gpt") || actualModel.includes("openai") || actualModel.includes("azure") || actualModel.includes("midijourney") || actualModel.includes("grok")) {
519
+ const limit = (actualModel.includes("midijourney") || actualModel.includes("grok")) ? 128 : 120;
520
+ proxyBody.tools = truncateTools(proxyBody.tools, limit);
560
521
  if (proxyBody.messages) {
561
522
  proxyBody.messages.forEach((m) => {
562
523
  if (m.tool_calls) {
@@ -571,6 +532,11 @@ export async function handleChatCompletion(req, res, bodyRaw) {
571
532
  });
572
533
  }
573
534
  }
535
+ // BEDROCK FIX (Claude / Nova / ChickyTutor)
536
+ if (actualModel.includes("claude") || actualModel.includes("nova") || actualModel.includes("bedrock") || actualModel.includes("chickytutor")) {
537
+ log(`[Proxy] Bedrock: Sanitizing tools description.`);
538
+ proxyBody.tools = sanitizeToolsForBedrock(proxyBody.tools);
539
+ }
574
540
  // B1. NOMNOM SPECIAL (Disable Grounding, KEEP Search Tool)
575
541
  if (actualModel === "nomnom") {
576
542
  proxyBody.tools_config = { google_search_retrieval: { disable: true } };
@@ -578,36 +544,12 @@ export async function handleChatCompletion(req, res, bodyRaw) {
578
544
  proxyBody.tools = sanitizeToolsForVertex(proxyBody.tools || []);
579
545
  log(`[Proxy] Nomnom Fix: Grounding Disabled, Search Tool KEPT.`);
580
546
  }
581
- // B2. GEMINI FREE / FAST (CRASH FIX: STRICT SANITIZATION)
582
- // Restore Tools but REMOVE conflicting ones (Search)
583
547
  // B. GEMINI UNIFIED FIX (Free, Fast, Pro, Enterprise, Legacy)
584
- // Handles: "tools" vs "grounding" conflicts, and "infinite loops" via Stop Sequences.
585
- // GLOBAL BEDROCK FIX (All Models)
586
- // Check if history has tools but current request misses tools definition.
587
- // This happens when OpenCode sends the Tool Result (optimisation),
588
- // but Bedrock requires toolConfig to validate the history.
589
- const hasToolHistory = proxyBody.messages?.some((m) => m.role === 'tool' || m.tool_calls);
590
- if (hasToolHistory && (!proxyBody.tools || proxyBody.tools.length === 0)) {
591
- // Inject Shim Tool to satisfy Bedrock
592
- proxyBody.tools = [{
593
- type: 'function',
594
- function: {
595
- name: '_bedrock_compatibility_shim',
596
- description: 'Internal system tool to satisfy Bedrock strict toolConfig requirement. Do not use.',
597
- parameters: { type: 'object', properties: {} }
598
- }
599
- }];
600
- log(`[Proxy] Bedrock Fix: Injected shim tool for ${actualModel} (History has tools, Request missing tools)`);
601
- }
602
- // B. GEMINI UNIFIED FIX (Free, Fast, Pro, Enterprise, Legacy)
603
- // Fixes "Multiple tools" error (Vertex) and "JSON body validation failed" (v5.3.5 regression)
604
- // Added ChickyTutor (Claude/Gemini based) to fix "toolConfig must be defined" error.
605
- else if (actualModel.includes("gemini") || actualModel.includes("chickytutor")) {
548
+ else if (actualModel.includes("gemini")) {
606
549
  let hasFunctions = false;
607
550
  if (proxyBody.tools && Array.isArray(proxyBody.tools)) {
608
551
  hasFunctions = proxyBody.tools.some((t) => t.type === 'function' || t.function);
609
552
  }
610
- // Old Shim logic removed (moved up)
611
553
  if (hasFunctions) {
612
554
  // 1. Strict cleanup of 'google_search' tool
613
555
  proxyBody.tools = proxyBody.tools.filter((t) => {
@@ -638,14 +580,6 @@ export async function handleChatCompletion(req, res, bodyRaw) {
638
580
  log(`[Proxy] Gemini Logic: Tools=${proxyBody.tools ? proxyBody.tools.length : 'REMOVED'}, Stops NOT Injected.`);
639
581
  }
640
582
  }
641
- // B5. BEDROCK TOKEN LIMIT FIX
642
- if (actualModel.includes("chicky") || actualModel.includes("mistral")) {
643
- // Force max_tokens if not present or too high (Bedrock outputs usually max 4k, context 8k+ but strict check)
644
- if (!proxyBody.max_tokens || proxyBody.max_tokens > 4096) {
645
- proxyBody.max_tokens = 4096;
646
- log(`[Proxy] Enforcing max_tokens=4096 for ${actualModel} (Bedrock Limit)`);
647
- }
648
- }
649
583
  // C. GEMINI ID BACKTRACKING & SIGNATURE
650
584
  if ((actualModel.includes("gemini") || actualModel === "nomnom") && proxyBody.messages) {
651
585
  const lastMsg = proxyBody.messages[proxyBody.messages.length - 1];
@@ -709,18 +643,10 @@ export async function handleChatCompletion(req, res, bodyRaw) {
709
643
  if (authHeader)
710
644
  headers['Authorization'] = authHeader;
711
645
  // 5. Forward (Global Fetch with Retry)
712
- // BEDROCK COMPATIBILITY: Apply transformation BEFORE first request
713
- const isBedrockModel = actualModel.includes('nova') ||
714
- actualModel.includes('amazon') ||
715
- actualModel.includes('chickytutor');
716
- const finalProxyBody = isBedrockModel ? sanitizeForBedrock(proxyBody) : proxyBody;
717
- if (isBedrockModel) {
718
- log(`[Proxy] Applied Bedrock shim for ${actualModel} (Initial Request)`);
719
- }
720
646
  const fetchRes = await fetchWithRetry(targetUrl, {
721
647
  method: 'POST',
722
648
  headers: headers,
723
- body: JSON.stringify(finalProxyBody)
649
+ body: JSON.stringify(proxyBody)
724
650
  });
725
651
  res.statusCode = fetchRes.status;
726
652
  fetchRes.headers.forEach((val, key) => {
@@ -739,14 +665,20 @@ export async function handleChatCompletion(req, res, bodyRaw) {
739
665
  if ((isEnterpriseFallback || isGeminiToolsFallback) && config.mode !== 'manual') {
740
666
  log(`[SafetyNet] Upstream Rejection (${fetchRes.status}). Triggering Transparent Fallback.`);
741
667
  if (isEnterpriseFallback) {
742
- // 1a. Enterprise -> Fallback based on mode
743
- actualModel = config.mode === 'pro'
744
- ? (config.fallbacks.pro || 'qwen-coder')
745
- : (config.fallbacks.economy || 'nova-fast');
668
+ // 1a. Enterprise -> Free Fallback
669
+ actualModel = config.fallbacks.free.main.replace('free/', '');
746
670
  isEnterprise = false;
747
671
  isFallbackActive = true;
748
- if (fetchRes.status === 402)
672
+ if (fetchRes.status === 402) {
749
673
  fallbackReason = "Insufficient Funds (Upstream 402)";
674
+ // Force refresh quota cache so next pre-flight check is accurate
675
+ try {
676
+ await getQuotaStatus(true);
677
+ }
678
+ catch (e) {
679
+ log(`[Proxy Quota] Silent refresh error: ${e}`);
680
+ }
681
+ }
750
682
  else if (fetchRes.status === 429)
751
683
  fallbackReason = "Rate Limit (Upstream 429)";
752
684
  else if (fetchRes.status === 401)
@@ -764,33 +696,16 @@ export async function handleChatCompletion(req, res, bodyRaw) {
764
696
  // 2. Notify
765
697
  emitStatusToast('warning', `⚠️ Safety Net: ${actualModel} (${fallbackReason})`, 'Pollinations Safety');
766
698
  emitLogToast('warning', `Recovering from ${fetchRes.status} -> Switching to ${actualModel}`, 'Safety Net');
767
- // 3. Re-Prepare Request - v6.0: Stay on gen.pollinations.ai
768
- targetUrl = 'https://gen.pollinations.ai/v1/chat/completions';
699
+ // 3. Re-Prepare Request
700
+ targetUrl = 'https://text.pollinations.ai/openai/chat/completions';
769
701
  const retryHeaders = { ...headers };
770
- // Keep Authorization for gen.pollinations.ai
702
+ delete retryHeaders['Authorization']; // Free = No Auth
771
703
  const retryBody = { ...proxyBody, model: actualModel };
772
- // LIMIT MAX_TOKENS for lightweight fallback models (e.g. nova-fast limits)
773
- if (actualModel.includes('nova') || actualModel.includes('fast')) {
774
- // Force max_tokens down if missing or too high
775
- if (!retryBody.max_tokens || retryBody.max_tokens > 4000) {
776
- retryBody.max_tokens = 4000;
777
- log(`[SafetyNet] Adjusted max_tokens to 4000 for ${actualModel}`);
778
- }
779
- }
780
- // BEDROCK COMPATIBILITY SHIM (BUG 1 - Proper Transform)
781
- const isBedrockModel = actualModel.includes('nova') ||
782
- actualModel.includes('amazon') ||
783
- actualModel.includes('chickytutor');
784
- let finalRetryBody = retryBody;
785
- if (isBedrockModel) {
786
- finalRetryBody = sanitizeForBedrock(retryBody);
787
- log(`[SafetyNet] Applied Bedrock shim for ${actualModel}`);
788
- }
789
704
  // 4. Retry Fetch
790
705
  const retryRes = await fetchWithRetry(targetUrl, {
791
706
  method: 'POST',
792
707
  headers: retryHeaders,
793
- body: JSON.stringify(finalRetryBody)
708
+ body: JSON.stringify(retryBody)
794
709
  });
795
710
  if (retryRes.ok) {
796
711
  res.statusCode = retryRes.status;