opencode-pollinations-plugin 5.1.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.
@@ -0,0 +1,588 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { loadConfig } from './config.js';
4
+ import { handleCommand } from './commands.js';
5
+ import { emitStatusToast, emitLogToast } from './toast.js';
6
+ // --- PERSISTENCE: SIGNATURE MAP (Multi-Round Support) ---
7
+ const SIG_FILE = path.join(process.env.HOME || '/tmp', '.config/opencode/pollinations-signature.json');
8
+ let signatureMap = {};
9
+ 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
+ try {
21
+ if (fs.existsSync(SIG_FILE)) {
22
+ signatureMap = JSON.parse(fs.readFileSync(SIG_FILE, 'utf-8'));
23
+ }
24
+ }
25
+ catch (e) { }
26
+ function saveSignatureMap() {
27
+ try {
28
+ if (!fs.existsSync(path.dirname(SIG_FILE)))
29
+ fs.mkdirSync(path.dirname(SIG_FILE), { recursive: true });
30
+ fs.writeFileSync(SIG_FILE, JSON.stringify(signatureMap, null, 2));
31
+ }
32
+ catch (e) {
33
+ log(`ERROR: Error mapping signature: ${String(e)}`);
34
+ }
35
+ }
36
+ // RECURSIVE NORMALIZER for Stable Hashing
37
+ function normalizeContent(c) {
38
+ if (!c)
39
+ return "";
40
+ if (typeof c === 'string')
41
+ return c.replace(/\s+/g, ''); // Standard String
42
+ if (Array.isArray(c))
43
+ return c.map(normalizeContent).join(''); // Recurse Array
44
+ if (typeof c === 'object') {
45
+ const keys = Object.keys(c).sort();
46
+ return keys.map(k => k + normalizeContent(c[k])).join('');
47
+ }
48
+ return String(c);
49
+ }
50
+ function hashMessage(content) {
51
+ const normalized = normalizeContent(content);
52
+ let hash = 0;
53
+ for (let i = 0; i < normalized.length; i++) {
54
+ const char = normalized.charCodeAt(i);
55
+ hash = ((hash << 5) - hash) + char;
56
+ hash = hash & hash;
57
+ }
58
+ return Math.abs(hash).toString(16);
59
+ }
60
+ // --- SANITIZATION HELPERS ---
61
+ function dereferenceSchema(schema, rootDefs) {
62
+ if (!schema || typeof schema !== 'object')
63
+ return schema;
64
+ if (schema.$ref || schema.ref) {
65
+ const refKey = (schema.$ref || schema.ref).split('/').pop();
66
+ if (rootDefs && rootDefs[refKey]) {
67
+ const def = dereferenceSchema(JSON.parse(JSON.stringify(rootDefs[refKey])), rootDefs);
68
+ delete schema.$ref;
69
+ delete schema.ref;
70
+ Object.assign(schema, def);
71
+ }
72
+ else {
73
+ for (const key in schema) {
74
+ if (key !== 'description' && key !== 'default')
75
+ delete schema[key];
76
+ }
77
+ schema.type = "string";
78
+ schema.description = (schema.description || "") + " [Ref Failed]";
79
+ }
80
+ }
81
+ if (schema.properties) {
82
+ for (const key in schema.properties) {
83
+ schema.properties[key] = dereferenceSchema(schema.properties[key], rootDefs);
84
+ }
85
+ }
86
+ if (schema.items) {
87
+ schema.items = dereferenceSchema(schema.items, rootDefs);
88
+ }
89
+ if (schema.optional !== undefined)
90
+ delete schema.optional;
91
+ if (schema.title)
92
+ delete schema.title;
93
+ return schema;
94
+ }
95
+ function sanitizeToolsForVertex(tools) {
96
+ return tools.map(tool => {
97
+ if (!tool.function || !tool.function.parameters)
98
+ return tool;
99
+ let params = tool.function.parameters;
100
+ const defs = params.definitions || params.$defs;
101
+ params = dereferenceSchema(params, defs);
102
+ if (params.definitions)
103
+ delete params.definitions;
104
+ if (params.$defs)
105
+ delete params.$defs;
106
+ tool.function.parameters = params;
107
+ return tool;
108
+ });
109
+ }
110
+ function truncateTools(tools, limit = 120) {
111
+ if (!tools || tools.length <= limit)
112
+ return tools;
113
+ return tools.slice(0, limit);
114
+ }
115
+ const MAX_RETRIES = 3;
116
+ const RETRY_DELAY_MS = 1000;
117
+ function sleep(ms) {
118
+ return new Promise(resolve => setTimeout(resolve, ms));
119
+ }
120
+ async function fetchWithRetry(url, options, retries = MAX_RETRIES) {
121
+ try {
122
+ const response = await fetch(url, options);
123
+ if (response.ok)
124
+ return response;
125
+ if (response.status === 404 || response.status === 401 || response.status === 400) {
126
+ // Don't retry client errors (except rate limit)
127
+ return response;
128
+ }
129
+ if (retries > 0 && (response.status === 429 || response.status >= 500)) {
130
+ log(`[Retry] Upstream Error ${response.status}. Retrying in ${RETRY_DELAY_MS}ms... (${retries} left)`);
131
+ await sleep(RETRY_DELAY_MS);
132
+ return fetchWithRetry(url, options, retries - 1);
133
+ }
134
+ return response;
135
+ }
136
+ catch (error) {
137
+ if (retries > 0) {
138
+ log(`[Retry] Network Error: ${error}. Retrying... (${retries} left)`);
139
+ await sleep(RETRY_DELAY_MS);
140
+ return fetchWithRetry(url, options, retries - 1);
141
+ }
142
+ throw error;
143
+ }
144
+ }
145
+ // --- MAIN HANDLER ---
146
+ export async function handleChatCompletion(req, res, bodyRaw) {
147
+ let targetUrl = '';
148
+ let authHeader = undefined;
149
+ try {
150
+ const body = JSON.parse(bodyRaw);
151
+ const config = loadConfig();
152
+ // 0. COMMAND HANDLING
153
+ if (body.messages && body.messages.length > 0) {
154
+ const lastMsg = body.messages[body.messages.length - 1];
155
+ if (lastMsg.role === 'user') {
156
+ let text = "";
157
+ if (typeof lastMsg.content === 'string') {
158
+ text = lastMsg.content;
159
+ }
160
+ else if (Array.isArray(lastMsg.content)) {
161
+ // Handle Multimodal [{type:'text', text:'...'}]
162
+ text = lastMsg.content
163
+ .map((c) => c.text || c.content || "")
164
+ .join("");
165
+ }
166
+ text = text.trim();
167
+ log(`[Command Check] Extracted: "${text.substring(0, 50)}..." from type: ${typeof lastMsg.content}`);
168
+ if (text.startsWith('/pollinations') || text.startsWith('/poll')) {
169
+ log(`[Command] Intercepting: ${text}`);
170
+ const cmdResult = await handleCommand(text);
171
+ if (cmdResult.handled) {
172
+ if (true) { // ALWAYS MOCK STREAM for Compatibility
173
+ res.writeHead(200, {
174
+ 'Content-Type': 'text/event-stream',
175
+ 'Cache-Control': 'no-cache',
176
+ 'Connection': 'keep-alive'
177
+ });
178
+ const content = cmdResult.response || cmdResult.error || "Commande exécutée.";
179
+ const id = "pollinations-cmd-" + Date.now();
180
+ const created = Math.floor(Date.now() / 1000);
181
+ // Mock Chunk 1: Content
182
+ const chunk1 = {
183
+ id, object: "chat.completion.chunk", created, model: body.model,
184
+ choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }]
185
+ };
186
+ res.write(`data: ${JSON.stringify(chunk1)}\n\n`);
187
+ // Mock Chunk 2: Stop
188
+ const chunk2 = {
189
+ id, object: "chat.completion.chunk", created, model: body.model,
190
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }]
191
+ };
192
+ res.write(`data: ${JSON.stringify(chunk2)}\n\n`);
193
+ res.write("data: [DONE]\n\n");
194
+ res.end();
195
+ return; // SHORT CIRCUIT
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
201
+ log(`Incoming Model (OpenCode ID): ${body.model}`);
202
+ // 1. STRICT ROUTING & SAFETY NET LOGIC (V5)
203
+ let actualModel = body.model || "openai";
204
+ let isEnterprise = false;
205
+ let isFallbackActive = false;
206
+ let fallbackReason = "";
207
+ // LOAD QUOTA FOR SAFETY CHECKS
208
+ const { getQuotaStatus, formatQuotaForToast } = await import('./quota.js');
209
+ const quota = await getQuotaStatus(false);
210
+ // A. Resolve Base Target
211
+ if (actualModel.startsWith('enter/')) {
212
+ isEnterprise = true;
213
+ actualModel = actualModel.replace('enter/', '');
214
+ }
215
+ else if (actualModel.startsWith('free/')) {
216
+ isEnterprise = false;
217
+ actualModel = actualModel.replace('free/', '');
218
+ }
219
+ // B. SAFETY NETS (The Core V5 Logic)
220
+ if (config.mode === 'alwaysfree') {
221
+ if (isEnterprise) {
222
+ if (quota.tier === 'error') {
223
+ log(`[SafetyNet] AlwaysFree Mode: Quota Check Failed. Switching to Free Fallback.`);
224
+ actualModel = config.fallbacks.free.main.replace('free/', '');
225
+ isEnterprise = false;
226
+ isFallbackActive = true;
227
+ fallbackReason = "Quota Unreachable (Safety)";
228
+ }
229
+ else if (quota.tierRemaining <= 0.1) {
230
+ log(`[SafetyNet] AlwaysFree Mode: Daily Tier Empty. Switching to Free Fallback.`);
231
+ actualModel = config.fallbacks.free.main.replace('free/', '');
232
+ isEnterprise = false;
233
+ isFallbackActive = true;
234
+ fallbackReason = "Daily Tier Empty (Wallet Protected)";
235
+ }
236
+ }
237
+ }
238
+ else if (config.mode === 'pro') {
239
+ if (isEnterprise) {
240
+ if (quota.tier === 'error') {
241
+ log(`[SafetyNet] Pro Mode: Quota Unreachable. Switching to Free Fallback.`);
242
+ actualModel = config.fallbacks.free.main.replace('free/', '');
243
+ isEnterprise = false;
244
+ isFallbackActive = true;
245
+ fallbackReason = "Quota Unreachable (Safety)";
246
+ }
247
+ else if (quota.walletBalance < 0.10 && quota.tierRemaining <= 0.1) {
248
+ log(`[SafetyNet] Pro Mode: Wallet Critical (<$0.10). Switching to Free Fallback.`);
249
+ actualModel = config.fallbacks.free.main.replace('free/', '');
250
+ isEnterprise = false;
251
+ isFallbackActive = true;
252
+ fallbackReason = "Wallet Critical";
253
+ }
254
+ }
255
+ }
256
+ // C. Construct URL & Headers
257
+ if (isEnterprise) {
258
+ if (!config.apiKey) {
259
+ emitLogToast('error', "Missing API Key for Enterprise Model", 'Proxy Error');
260
+ res.writeHead(401, { 'Content-Type': 'application/json' });
261
+ res.end(JSON.stringify({ error: { message: "API Key required for Enterprise models." } }));
262
+ return;
263
+ }
264
+ targetUrl = 'https://gen.pollinations.ai/v1/chat/completions';
265
+ authHeader = `Bearer ${config.apiKey}`;
266
+ log(`Routing to ENTERPRISE: ${actualModel}`);
267
+ }
268
+ else {
269
+ targetUrl = 'https://text.pollinations.ai/openai/chat/completions';
270
+ authHeader = undefined;
271
+ log(`Routing to FREE: ${actualModel} ${isFallbackActive ? '(FALLBACK)' : ''}`);
272
+ emitLogToast('info', `Routing to: FREE UNIVERSE (${actualModel})`, 'Pollinations Routing');
273
+ }
274
+ // NOTIFY SWITCH
275
+ if (isFallbackActive) {
276
+ emitStatusToast('warning', `⚠️ Safety Net: ${actualModel} (${fallbackReason})`, 'Pollinations Safety');
277
+ }
278
+ // 2. Prepare Proxy Body
279
+ const proxyBody = {
280
+ ...body,
281
+ model: actualModel
282
+ };
283
+ // 3. Global Hygiene
284
+ if (!isEnterprise && !proxyBody.seed) {
285
+ proxyBody.seed = Math.floor(Math.random() * 1000000);
286
+ }
287
+ if (isEnterprise)
288
+ proxyBody.private = true;
289
+ if (proxyBody.stream_options)
290
+ delete proxyBody.stream_options;
291
+ // 3.6 STOP SEQUENCES (Prevent Looping - CRITICAL FIX)
292
+ // Inject explicit stop sequences to prevent "User:" hallucinations
293
+ if (!proxyBody.stop) {
294
+ proxyBody.stop = ["\nUser:", "\nModel:", "User:", "Model:"];
295
+ }
296
+ // 3.5 PREPARE SIGNATURE HASHING
297
+ let currentRequestHash = null;
298
+ if (proxyBody.messages && proxyBody.messages.length > 0) {
299
+ const lastMsg = proxyBody.messages[proxyBody.messages.length - 1];
300
+ currentRequestHash = hashMessage(lastMsg);
301
+ }
302
+ // =========================================================
303
+ // LOGIC BLOCK: MODEL SPECIFIC ADAPTATIONS
304
+ // =========================================================
305
+ if (proxyBody.tools && Array.isArray(proxyBody.tools)) {
306
+ // B0. KIMI / MOONSHOT SURGICAL FIX (Restored for Debug)
307
+ // Tools are ENABLED. We rely on penalties and strict stops to fight loops.
308
+ if (actualModel.includes("kimi") || actualModel.includes("moonshot")) {
309
+ log(`[Proxy] Kimi: Tools ENABLED. Applying penalties/stops.`);
310
+ proxyBody.frequency_penalty = 1.1;
311
+ proxyBody.presence_penalty = 0.4;
312
+ proxyBody.stop = ["<|endoftext|>", "User:", "\nUser", "User :"];
313
+ }
314
+ // A. AZURE/OPENAI FIXES
315
+ if (actualModel.includes("gpt") || actualModel.includes("openai") || actualModel.includes("azure")) {
316
+ proxyBody.tools = truncateTools(proxyBody.tools, 120);
317
+ if (proxyBody.messages) {
318
+ proxyBody.messages.forEach((m) => {
319
+ if (m.tool_calls) {
320
+ m.tool_calls.forEach((tc) => {
321
+ if (tc.id && tc.id.length > 40)
322
+ tc.id = tc.id.substring(0, 40);
323
+ });
324
+ }
325
+ if (m.tool_call_id && m.tool_call_id.length > 40) {
326
+ m.tool_call_id = m.tool_call_id.substring(0, 40);
327
+ }
328
+ });
329
+ }
330
+ }
331
+ // B1. NOMNOM SPECIAL (Disable Grounding, KEEP Search Tool)
332
+ if (actualModel === "nomnom") {
333
+ proxyBody.tools_config = { google_search_retrieval: { disable: true } };
334
+ // Keep Tools, Just Sanitize
335
+ proxyBody.tools = sanitizeToolsForVertex(proxyBody.tools || []);
336
+ log(`[Proxy] Nomnom Fix: Grounding Disabled, Search Tool KEPT.`);
337
+ }
338
+ // B2. GEMINI FREE / FAST (CRASH FIX: STRICT SANITIZATION)
339
+ // Restore Tools but REMOVE conflicting ones (Search)
340
+ else if ((actualModel.includes("gemini") && !isEnterprise) ||
341
+ (actualModel.includes("gemini") && actualModel.includes("fast"))) {
342
+ const hasFunctions = proxyBody.tools.some((t) => t.type === 'function' || t.function);
343
+ if (hasFunctions) {
344
+ // 1. Disable Magic Grounding (Source of loops/crashes)
345
+ proxyBody.tools_config = { google_search_retrieval: { disable: true } };
346
+ // 2. Remove 'google_search' explicitly (Replica of V3.5.5 logic)
347
+ proxyBody.tools = proxyBody.tools.filter((t) => {
348
+ const isFunc = t.type === 'function' || t.function;
349
+ const name = t.function?.name || t.name;
350
+ return isFunc && name !== 'google_search';
351
+ });
352
+ // 3. Ensure tools are Vertex-Compatible
353
+ proxyBody.tools = sanitizeToolsForVertex(proxyBody.tools);
354
+ log(`[Proxy] Gemini Free: Tools RESTORED but Sanitized (No Search/Grounding).`);
355
+ }
356
+ }
357
+ // B3. GEMINI ENTERPRISE 3.0+
358
+ else if (actualModel.includes("gemini")) {
359
+ const hasFunctions = proxyBody.tools.some((t) => t.type === 'function' || t.function);
360
+ if (hasFunctions) {
361
+ proxyBody.tools_config = { google_search_retrieval: { disable: true } };
362
+ // Keep Search Tool in List
363
+ proxyBody.tools = proxyBody.tools.filter((t) => t.type === 'function' || t.function);
364
+ proxyBody.tools = sanitizeToolsForVertex(proxyBody.tools);
365
+ }
366
+ }
367
+ }
368
+ // C. GEMINI ID BACKTRACKING & SIGNATURE
369
+ if ((actualModel.includes("gemini") || actualModel === "nomnom") && proxyBody.messages) {
370
+ const lastMsg = proxyBody.messages[proxyBody.messages.length - 1];
371
+ proxyBody.messages.forEach((m, index) => {
372
+ if (m.role === 'assistant') {
373
+ let sig = null;
374
+ if (index > 0) {
375
+ const prevMsg = proxyBody.messages[index - 1];
376
+ const prevHash = hashMessage(prevMsg);
377
+ sig = signatureMap[prevHash];
378
+ }
379
+ if (!sig)
380
+ sig = lastSignature;
381
+ if (sig) {
382
+ if (!m.thought_signature)
383
+ m.thought_signature = sig;
384
+ if (m.tool_calls) {
385
+ m.tool_calls.forEach((tc) => {
386
+ if (!tc.thought_signature)
387
+ tc.thought_signature = sig;
388
+ if (tc.function && !tc.function.thought_signature)
389
+ tc.function.thought_signature = sig;
390
+ });
391
+ }
392
+ }
393
+ }
394
+ else if (m.role === 'tool') {
395
+ let sig = null;
396
+ if (index > 0)
397
+ sig = lastSignature; // Fallback
398
+ if (sig && !m.thought_signature) {
399
+ m.thought_signature = sig;
400
+ }
401
+ }
402
+ });
403
+ // Fix Tool Response ID
404
+ if (lastMsg.role === 'tool') {
405
+ let targetAssistantMsg = null;
406
+ for (let i = proxyBody.messages.length - 2; i >= 0; i--) {
407
+ const m = proxyBody.messages[i];
408
+ if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0) {
409
+ targetAssistantMsg = m;
410
+ break;
411
+ }
412
+ }
413
+ if (targetAssistantMsg) {
414
+ const originalId = targetAssistantMsg.tool_calls[0].id;
415
+ const currentId = lastMsg.tool_call_id;
416
+ if (currentId !== originalId) {
417
+ lastMsg.tool_call_id = originalId;
418
+ }
419
+ }
420
+ }
421
+ }
422
+ // 4. Headers
423
+ const headers = {
424
+ 'Content-Type': 'application/json',
425
+ 'Accept': 'application/json, text/event-stream',
426
+ 'User-Agent': 'curl/8.5.0'
427
+ };
428
+ if (authHeader)
429
+ headers['Authorization'] = authHeader;
430
+ // 5. Forward (Global Fetch with Retry)
431
+ const fetchRes = await fetchWithRetry(targetUrl, {
432
+ method: 'POST',
433
+ headers: headers,
434
+ body: JSON.stringify(proxyBody)
435
+ });
436
+ res.statusCode = fetchRes.status;
437
+ fetchRes.headers.forEach((val, key) => {
438
+ if (key !== 'content-encoding' && key !== 'content-length') {
439
+ res.setHeader(key, val);
440
+ }
441
+ });
442
+ if (!fetchRes.ok) {
443
+ log(`Upstream Error: ${fetchRes.status} ${fetchRes.statusText}`);
444
+ // TRANSPARENT FALLBACK ON 402 (Payment) or 429 (Rate Limit) IF Enterprise
445
+ if ((fetchRes.status === 402 || fetchRes.status === 429) && isEnterprise) {
446
+ log(`[SafetyNet] Upstream Rejection (${fetchRes.status}). Triggering Transparent Fallback.`);
447
+ // 1. Switch Config
448
+ actualModel = config.fallbacks.free.main.replace('free/', '');
449
+ isEnterprise = false;
450
+ isFallbackActive = true;
451
+ fallbackReason = fetchRes.status === 402 ? "Insufficient Funds (Upstream 402)" : "Rate Limit (Upstream 429)";
452
+ // 2. Notify
453
+ emitStatusToast('warning', `⚠️ Safety Net: ${actualModel} (${fallbackReason})`, 'Pollinations Safety');
454
+ emitLogToast('warning', `Recovering from ${fetchRes.status} -> Switching to ${actualModel}`, 'Safety Net');
455
+ // 3. Re-Prepare Request
456
+ targetUrl = 'https://text.pollinations.ai/openai/chat/completions';
457
+ const retryHeaders = { ...headers };
458
+ delete retryHeaders['Authorization']; // Free = No Auth
459
+ const retryBody = { ...proxyBody, model: actualModel };
460
+ // 4. Retry Fetch
461
+ const retryRes = await fetchWithRetry(targetUrl, {
462
+ method: 'POST',
463
+ headers: retryHeaders,
464
+ body: JSON.stringify(retryBody)
465
+ });
466
+ if (retryRes.ok) {
467
+ res.statusCode = retryRes.status;
468
+ // Overwrite response with retry
469
+ // We need to handle the stream of retryRes now.
470
+ // The easiest way is to assign fetchRes = retryRes, BUT fetchRes is const.
471
+ // Refactor needed? No, I can just stream retryRes here and return.
472
+ retryRes.headers.forEach((val, key) => {
473
+ if (key !== 'content-encoding' && key !== 'content-length') {
474
+ res.setHeader(key, val);
475
+ }
476
+ });
477
+ if (retryRes.body) {
478
+ let accumulated = "";
479
+ let currentSignature = null;
480
+ // @ts-ignore
481
+ for await (const chunk of retryRes.body) {
482
+ const buffer = Buffer.from(chunk);
483
+ const chunkStr = buffer.toString();
484
+ // ... (Copy basic stream logic or genericize? Copying safe for hotfix)
485
+ accumulated += chunkStr;
486
+ res.write(chunkStr);
487
+ }
488
+ // INJECT NOTIFICATION AT END
489
+ const warningMsg = `\n\n> ⚠️ **Safety Net**: ${fallbackReason}. Switched to \`${actualModel}\`.`;
490
+ const safeId = "fallback-" + Date.now();
491
+ const warningChunk = {
492
+ id: safeId,
493
+ object: "chat.completion.chunk",
494
+ created: Math.floor(Date.now() / 1000),
495
+ model: actualModel,
496
+ choices: [{ index: 0, delta: { role: "assistant", content: warningMsg }, finish_reason: null }]
497
+ };
498
+ res.write(`data: ${JSON.stringify(warningChunk)}\n\n`);
499
+ // DASHBOARD UPDATE
500
+ const dashboardMsg = formatQuotaForToast(quota); // Quota is stale/empty but that's fine
501
+ const fullMsg = `${dashboardMsg} | ⚙️ PRO (FALLBACK)`;
502
+ emitStatusToast('info', fullMsg, 'Pollinations Status');
503
+ res.end();
504
+ return; // EXIT FUNCTION, HANDLED.
505
+ }
506
+ }
507
+ }
508
+ }
509
+ // Stream Loop
510
+ if (fetchRes.body) {
511
+ let accumulated = "";
512
+ let currentSignature = null;
513
+ // @ts-ignore
514
+ for await (const chunk of fetchRes.body) {
515
+ const buffer = Buffer.from(chunk);
516
+ let chunkStr = buffer.toString();
517
+ // FIX: STOP REASON NORMALIZATION using Regex Safely
518
+ // 1. If Kimi/Model sends "tool_calls" reason but "tool_calls":null, FORCE STOP.
519
+ if (chunkStr.includes('"finish_reason": "tool_calls"') && chunkStr.includes('"tool_calls":null')) {
520
+ chunkStr = chunkStr.replace('"finish_reason": "tool_calls"', '"finish_reason": "stop"');
521
+ }
522
+ // 2. Original Logic: Ensure formatting but avoid false positives on null
523
+ // Only upgrade valid stops to tool_calls if we see actual tool array start
524
+ if (chunkStr.includes('"finish_reason"')) {
525
+ const stopRegex = /"finish_reason"\s*:\s*"(stop|STOP|did_not_finish|finished|end_turn|MAX_TOKENS)"/g;
526
+ if (stopRegex.test(chunkStr)) {
527
+ if (chunkStr.includes('"tool_calls":[') || chunkStr.includes('"tool_calls": [')) {
528
+ chunkStr = chunkStr.replace(stopRegex, '"finish_reason": "tool_calls"');
529
+ }
530
+ else {
531
+ chunkStr = chunkStr.replace(stopRegex, '"finish_reason": "stop"');
532
+ }
533
+ }
534
+ }
535
+ // SIGNATURE CAPTURE
536
+ if (!currentSignature) {
537
+ const match = chunkStr.match(/"thought_signature"\s*:\s*"([^"]+)"/);
538
+ if (match && match[1])
539
+ currentSignature = match[1];
540
+ }
541
+ // SAFETY STOP: SERVER-SIDE LOOP DETECTION (GUILLOTINE)
542
+ if (chunkStr.includes("User:") || chunkStr.includes("\nUser") || chunkStr.includes("user:")) {
543
+ if (chunkStr.match(/(\n|^)\s*(User|user)\s*:/)) {
544
+ res.end();
545
+ return; // HARD STOP
546
+ }
547
+ }
548
+ accumulated += chunkStr;
549
+ res.write(chunkStr);
550
+ }
551
+ // INJECT NOTIFICATION AT END
552
+ if (isFallbackActive) {
553
+ const warningMsg = `\n\n> ⚠️ **Safety Net**: ${fallbackReason}. Switched to \`${actualModel}\`.`;
554
+ const safeId = "fallback-" + Date.now();
555
+ const warningChunk = {
556
+ id: safeId,
557
+ object: "chat.completion.chunk",
558
+ created: Math.floor(Date.now() / 1000),
559
+ model: actualModel,
560
+ choices: [{ index: 0, delta: { role: "assistant", content: warningMsg }, finish_reason: null }]
561
+ };
562
+ res.write(`data: ${JSON.stringify(warningChunk)}\n\n`);
563
+ }
564
+ // END STREAM: SAVE MAP & EMIT TOAST
565
+ if (currentSignature && currentRequestHash) {
566
+ signatureMap[currentRequestHash] = currentSignature;
567
+ saveSignatureMap();
568
+ lastSignature = currentSignature;
569
+ }
570
+ // V5 DASHBOARD TOAST
571
+ const dashboardMsg = formatQuotaForToast(quota);
572
+ let modeLabel = config.mode.toUpperCase();
573
+ if (isFallbackActive)
574
+ modeLabel += " (FALLBACK)";
575
+ const fullMsg = `${dashboardMsg} | ⚙️ ${modeLabel}`;
576
+ // Only emit if not silenced (handled inside emitStatusToast)
577
+ emitStatusToast('info', fullMsg, 'Pollinations Status');
578
+ }
579
+ res.end();
580
+ }
581
+ catch (e) {
582
+ log(`ERROR: Proxy Handler Error: ${String(e)}`);
583
+ if (!res.headersSent) {
584
+ res.writeHead(500);
585
+ res.end(JSON.stringify({ error: "Internal Proxy Error", details: String(e) }));
586
+ }
587
+ }
588
+ }
@@ -0,0 +1,15 @@
1
+ export interface QuotaStatus {
2
+ tierRemaining: number;
3
+ tierUsed: number;
4
+ tierLimit: number;
5
+ walletBalance: number;
6
+ nextResetAt: Date;
7
+ timeUntilReset: number;
8
+ canUseEnterprise: boolean;
9
+ isUsingWallet: boolean;
10
+ needsAlert: boolean;
11
+ tier: string;
12
+ tierEmoji: string;
13
+ }
14
+ export declare function getQuotaStatus(forceRefresh?: boolean): Promise<QuotaStatus>;
15
+ export declare function formatQuotaForToast(quota: QuotaStatus): string;