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.
- package/LICENSE.md +21 -0
- package/README.md +140 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +128 -0
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +135 -0
- package/dist/provider_v1.d.ts +1 -0
- package/dist/provider_v1.js +135 -0
- package/dist/server/commands.d.ts +10 -0
- package/dist/server/commands.js +302 -0
- package/dist/server/config.d.ts +49 -0
- package/dist/server/config.js +159 -0
- package/dist/server/generate-config.d.ts +13 -0
- package/dist/server/generate-config.js +154 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +120 -0
- package/dist/server/pollinations-api.d.ts +48 -0
- package/dist/server/pollinations-api.js +147 -0
- package/dist/server/proxy.d.ts +2 -0
- package/dist/server/proxy.js +588 -0
- package/dist/server/quota.d.ts +15 -0
- package/dist/server/quota.js +210 -0
- package/dist/server/router.d.ts +8 -0
- package/dist/server/router.js +122 -0
- package/dist/server/status.d.ts +3 -0
- package/dist/server/status.js +31 -0
- package/dist/server/toast.d.ts +6 -0
- package/dist/server/toast.js +78 -0
- package/package.json +53 -0
|
@@ -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;
|