opencode-pollinations-plugin 6.4.10 → 6.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +67 -55
- package/README.es.md +79 -67
- package/README.fr.md +70 -58
- package/README.it.md +78 -66
- package/README.md +33 -29
- package/README.zh.md +77 -65
- package/dist/locales/de.json +82 -50
- package/dist/locales/en.json +84 -52
- package/dist/locales/es.json +82 -50
- package/dist/locales/fr.json +81 -49
- package/dist/locales/it.json +82 -50
- package/dist/locales/zh.json +82 -50
- package/dist/server/commands.js +112 -110
- package/dist/server/config.d.ts +24 -6
- package/dist/server/config.js +45 -4
- package/dist/server/connect-response.js +7 -7
- package/dist/server/generate-config.d.ts +7 -0
- package/dist/server/generate-config.js +22 -16
- package/dist/server/models/cache.d.ts +23 -10
- package/dist/server/models/cache.js +46 -24
- package/dist/server/models/fetcher.js +9 -0
- package/dist/server/models/types.d.ts +8 -0
- package/dist/server/models/worker.js +32 -8
- package/dist/server/proxy.d.ts +6 -0
- package/dist/server/proxy.js +297 -210
- package/dist/server/quota.d.ts +23 -32
- package/dist/server/quota.js +45 -185
- package/dist/server/status.js +1 -2
- package/dist/server/toast.js +1 -1
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +8 -3
- package/dist/tools/pollinations/artifact-core.d.ts +53 -0
- package/dist/tools/pollinations/artifact-core.js +159 -0
- package/dist/tools/pollinations/beta_discovery.js +2 -1
- package/dist/tools/pollinations/cost-guard.d.ts +2 -2
- package/dist/tools/pollinations/error-parser.d.ts +38 -0
- package/dist/tools/pollinations/error-parser.js +112 -0
- package/dist/tools/pollinations/gen_3d.d.ts +17 -0
- package/dist/tools/pollinations/gen_3d.js +207 -0
- package/dist/tools/pollinations/gen_image.js +29 -10
- package/dist/tools/pollinations/gen_music.js +3 -2
- package/dist/tools/pollinations/gen_video.js +13 -2
- package/dist/tools/pollinations/polli_config.js +18 -21
- package/dist/tools/pollinations/polli_gen_confirm.js +2 -0
- package/dist/tools/pollinations/shared.d.ts +1 -1
- package/dist/tools/pollinations/shared.js +53 -142
- package/dist/tools/pollinations/timeout-policy.d.ts +80 -0
- package/dist/tools/pollinations/timeout-policy.js +124 -0
- package/dist/tools/pollinations/tool-capability-registry.d.ts +51 -0
- package/dist/tools/pollinations/tool-capability-registry.js +215 -0
- package/dist/tools/pollinations/transcribe_audio.js +5 -24
- package/package.json +6 -4
- package/dist/server/tier-info.d.ts +0 -36
- package/dist/server/tier-info.js +0 -107
package/dist/server/proxy.js
CHANGED
|
@@ -166,41 +166,214 @@ function truncateTools(tools, limit = 120) {
|
|
|
166
166
|
return tools;
|
|
167
167
|
return tools.slice(0, limit);
|
|
168
168
|
}
|
|
169
|
-
|
|
169
|
+
// v6.5: single source for the dynamic paid_only list (saved by generate-config.ts).
|
|
170
|
+
function isPaidOnlyModel(model) {
|
|
171
|
+
try {
|
|
172
|
+
const standardPaidPath = path.join(getConfigDir(), 'pollinations-paid-models.json');
|
|
173
|
+
if (fs.existsSync(standardPaidPath)) {
|
|
174
|
+
const paidModels = JSON.parse(fs.readFileSync(standardPaidPath, 'utf-8'));
|
|
175
|
+
return Array.isArray(paidModels) && paidModels.includes(model);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
log(`[Proxy] Error checking paid models: ${e}`);
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const MAX_RETRIES = 1; // v6.5: at most 1 initial request + 1 retry (429 only)
|
|
170
184
|
const RETRY_DELAY_MS = 1000;
|
|
171
185
|
const FETCH_TIMEOUT_MS = 600000; // 10 Minutes global timeout
|
|
172
186
|
function sleep(ms) {
|
|
173
187
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
174
188
|
}
|
|
175
|
-
|
|
189
|
+
export function classifyRetry(signal) {
|
|
190
|
+
if (signal === 'abort' || signal === 'network') {
|
|
191
|
+
// Timeout / connection reset after possible submission → NO REPLAY.
|
|
192
|
+
return 'NO_RETRY';
|
|
193
|
+
}
|
|
194
|
+
if (signal === 429) {
|
|
195
|
+
// Rate limit is the only class we retry, conservatively (single
|
|
196
|
+
// retry). Chat streaming is NOT idempotent upstream, so we keep this
|
|
197
|
+
// minimal and never retry ambiguous 5xx/520.
|
|
198
|
+
return 'RETRY';
|
|
199
|
+
}
|
|
200
|
+
// 5xx / 520 / 402 / 4xx: ambiguous or billing-relevant → NO blind replay.
|
|
201
|
+
return 'NO_RETRY';
|
|
202
|
+
}
|
|
203
|
+
export async function fetchWithRetry(url, options, retries = MAX_RETRIES) {
|
|
204
|
+
const controller = new AbortController();
|
|
205
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
206
|
+
let response;
|
|
176
207
|
try {
|
|
177
|
-
|
|
178
|
-
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
179
|
-
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
180
|
-
clearTimeout(timeoutId);
|
|
181
|
-
if (response.ok)
|
|
182
|
-
return response;
|
|
183
|
-
if (response.status === 404 || response.status === 401 || response.status === 400) {
|
|
184
|
-
// Don't retry client errors (except rate limit)
|
|
185
|
-
return response;
|
|
186
|
-
}
|
|
187
|
-
if (retries > 0 && (response.status === 429 || response.status >= 500 || response.status === 520)) {
|
|
188
|
-
// Check for specific "Queue" message in 520/429 body if possible (async read?)
|
|
189
|
-
// For now, just retry blindly on 520/5xx
|
|
190
|
-
log(`[Retry] Upstream Error ${response.status}. Retrying in ${RETRY_DELAY_MS}ms... (${retries} left)`);
|
|
191
|
-
await sleep(RETRY_DELAY_MS);
|
|
192
|
-
return fetchWithRetry(url, options, retries - 1);
|
|
193
|
-
}
|
|
194
|
-
return response;
|
|
208
|
+
response = await fetch(url, { ...options, signal: controller.signal });
|
|
195
209
|
}
|
|
196
210
|
catch (error) {
|
|
197
|
-
|
|
211
|
+
clearTimeout(timeoutId);
|
|
212
|
+
const isAbort = error?.name === 'AbortError' || controller.signal.aborted;
|
|
213
|
+
if (retries > 0 && classifyRetry(isAbort ? 'abort' : 'network') === 'RETRY') {
|
|
198
214
|
log(`[Retry] Network Error: ${error}. Retrying... (${retries} left)`);
|
|
199
215
|
await sleep(RETRY_DELAY_MS);
|
|
200
216
|
return fetchWithRetry(url, options, retries - 1);
|
|
201
217
|
}
|
|
202
218
|
throw error;
|
|
203
219
|
}
|
|
220
|
+
clearTimeout(timeoutId);
|
|
221
|
+
if (response.ok)
|
|
222
|
+
return response;
|
|
223
|
+
if (response.status === 404 || response.status === 401 || response.status === 400) {
|
|
224
|
+
// Don't retry client errors (except rate limit)
|
|
225
|
+
return response;
|
|
226
|
+
}
|
|
227
|
+
if (retries > 0 && classifyRetry(response.status) === 'RETRY') {
|
|
228
|
+
log(`[Retry] Upstream Error ${response.status}. Retrying in ${RETRY_DELAY_MS}ms... (${retries} left)`);
|
|
229
|
+
await sleep(RETRY_DELAY_MS);
|
|
230
|
+
return fetchWithRetry(url, options, retries - 1);
|
|
231
|
+
}
|
|
232
|
+
return response;
|
|
233
|
+
}
|
|
234
|
+
// ============================================================================
|
|
235
|
+
// NATIVE REASONING NORMALIZATION
|
|
236
|
+
// Preserve `reasoning_content`, `reasoning` and `reasoning_details` as
|
|
237
|
+
// structured sibling fields. OpenCode's OpenAI-compatible adapter consumes
|
|
238
|
+
// these natively (including interleaved replay). They must never be copied or
|
|
239
|
+
// concatenated into `content`. Only malformed tool-call parasites are removed.
|
|
240
|
+
// ============================================================================
|
|
241
|
+
function normalizeToolCallShape(obj) {
|
|
242
|
+
if (!obj || typeof obj !== 'object')
|
|
243
|
+
return obj;
|
|
244
|
+
if (Array.isArray(obj)) {
|
|
245
|
+
for (const item of obj)
|
|
246
|
+
normalizeToolCallShape(item);
|
|
247
|
+
return obj;
|
|
248
|
+
}
|
|
249
|
+
// Kimi: top-level name === null is a parasite; function.name is canonical.
|
|
250
|
+
if ('name' in obj && obj.name === null) {
|
|
251
|
+
delete obj.name;
|
|
252
|
+
}
|
|
253
|
+
// deepseek/kimi: message.tools === null is a parasite.
|
|
254
|
+
if ('tools' in obj && obj.tools === null) {
|
|
255
|
+
delete obj.tools;
|
|
256
|
+
}
|
|
257
|
+
return obj;
|
|
258
|
+
}
|
|
259
|
+
function normalizeChatChunk(obj) {
|
|
260
|
+
if (!obj || typeof obj !== 'object')
|
|
261
|
+
return obj;
|
|
262
|
+
if (Array.isArray(obj)) {
|
|
263
|
+
for (const item of obj)
|
|
264
|
+
normalizeChatChunk(item);
|
|
265
|
+
return obj;
|
|
266
|
+
}
|
|
267
|
+
const delta = obj.delta;
|
|
268
|
+
if (delta && typeof delta === 'object') {
|
|
269
|
+
// Preserve native reasoning fields for OpenCode's OpenAI-compatible adapter.
|
|
270
|
+
// They remain separate from content and can be replayed through interleaved reasoning.
|
|
271
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
272
|
+
for (const tc of delta.tool_calls)
|
|
273
|
+
normalizeToolCallShape(tc);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const message = obj.message;
|
|
277
|
+
if (message && typeof message === 'object') {
|
|
278
|
+
// Preserve native reasoning fields; never merge them into message.content.
|
|
279
|
+
normalizeToolCallShape(message);
|
|
280
|
+
if (Array.isArray(message.tool_calls)) {
|
|
281
|
+
for (const tc of message.tool_calls)
|
|
282
|
+
normalizeToolCallShape(tc);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (Array.isArray(obj.choices)) {
|
|
286
|
+
for (const ch of obj.choices)
|
|
287
|
+
normalizeChatChunk(ch);
|
|
288
|
+
}
|
|
289
|
+
return obj;
|
|
290
|
+
}
|
|
291
|
+
/** Normalize a single raw SSE `data:` payload line (JSON). Non-JSON passes through. */
|
|
292
|
+
export function normalizeChunkLine(payload) {
|
|
293
|
+
const trimmed = payload.trim();
|
|
294
|
+
if (!trimmed)
|
|
295
|
+
return payload;
|
|
296
|
+
try {
|
|
297
|
+
const obj = JSON.parse(trimmed);
|
|
298
|
+
normalizeChatChunk(obj);
|
|
299
|
+
return JSON.stringify(obj);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return payload; // e.g. [DONE]
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function streamSseUpstream(res, stream, opts) {
|
|
306
|
+
let buffer = '';
|
|
307
|
+
let currentSignature = null;
|
|
308
|
+
const flushBlock = (block) => {
|
|
309
|
+
const lines = block.split('\n');
|
|
310
|
+
const outLines = [];
|
|
311
|
+
for (const ln of lines) {
|
|
312
|
+
if (ln.startsWith('data:')) {
|
|
313
|
+
const payload = ln.slice(5).trim();
|
|
314
|
+
const normalized = normalizeChunkLine(payload);
|
|
315
|
+
outLines.push(`data: ${normalized}`);
|
|
316
|
+
if (!currentSignature) {
|
|
317
|
+
const m = normalized.match(/"thought_signature"\s*:\s*"([^"]+)"/);
|
|
318
|
+
if (m && m[1])
|
|
319
|
+
currentSignature = m[1];
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
outLines.push(ln);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
let out = outLines.join('\n');
|
|
327
|
+
// FIX: STOP REASON NORMALIZATION (kept from v6.4.10)
|
|
328
|
+
if (out.includes('"finish_reason": "tool_calls"') && out.includes('"tool_calls":null')) {
|
|
329
|
+
out = out.replace('"finish_reason": "tool_calls"', '"finish_reason": "stop"');
|
|
330
|
+
}
|
|
331
|
+
if (out.includes('"finish_reason"')) {
|
|
332
|
+
const stopRegex = /"finish_reason"\s*:\s*"(stop|STOP|did_not_finish|finished|end_turn|MAX_TOKENS)"/g;
|
|
333
|
+
if (stopRegex.test(out)) {
|
|
334
|
+
if (out.includes('"tool_calls":[') || out.includes('"tool_calls": [')) {
|
|
335
|
+
out = out.replace(stopRegex, '"finish_reason": "tool_calls"');
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
out = out.replace(stopRegex, '"finish_reason": "stop"');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
res.write(out + '\n\n');
|
|
343
|
+
};
|
|
344
|
+
for await (const chunk of stream) {
|
|
345
|
+
buffer += Buffer.from(chunk).toString().replace(/\r\n/g, '\n');
|
|
346
|
+
let idx;
|
|
347
|
+
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
|
348
|
+
const block = buffer.slice(0, idx);
|
|
349
|
+
buffer = buffer.slice(idx + 2);
|
|
350
|
+
// SAFETY STOP: SERVER-SIDE LOOP DETECTION (GUILLOTINE)
|
|
351
|
+
if (block.includes("User:") || block.includes("\nUser") || block.includes("user:")) {
|
|
352
|
+
if (block.match(/(\n|^)\s*(User|user)\s*:/)) {
|
|
353
|
+
res.end();
|
|
354
|
+
return currentSignature;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
flushBlock(block);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (buffer.trim()) {
|
|
361
|
+
flushBlock(buffer);
|
|
362
|
+
}
|
|
363
|
+
// INJECT FALLBACK NOTIFICATION AT END
|
|
364
|
+
if (opts.isFallbackActive) {
|
|
365
|
+
const warningMsg = `\n\n> ⚠️ **Safety Net**: ${opts.fallbackReason}. Switched to \`${opts.actualModel}\`.`;
|
|
366
|
+
const safeId = "fallback-" + Date.now();
|
|
367
|
+
const warningChunk = {
|
|
368
|
+
id: safeId,
|
|
369
|
+
object: "chat.completion.chunk",
|
|
370
|
+
created: Math.floor(Date.now() / 1000),
|
|
371
|
+
model: opts.actualModel,
|
|
372
|
+
choices: [{ index: 0, delta: { role: "assistant", content: warningMsg }, finish_reason: null }]
|
|
373
|
+
};
|
|
374
|
+
res.write(`data: ${JSON.stringify(warningChunk)}\n\n`);
|
|
375
|
+
}
|
|
376
|
+
return currentSignature;
|
|
204
377
|
}
|
|
205
378
|
// --- MEDIA UPLOAD HELPER (Vision Support) ---
|
|
206
379
|
// Uploads a base64 data URL to media.pollinations.ai and returns a public URL.
|
|
@@ -334,38 +507,43 @@ export async function handleChatCompletion(req, res, bodyRaw) {
|
|
|
334
507
|
isEnterprise = false;
|
|
335
508
|
actualModel = actualModel.replace('free/', '');
|
|
336
509
|
}
|
|
337
|
-
// A.1 PAID MODEL
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
510
|
+
// A.1 PAID-ONLY MODEL RESOLUTION (v6.5)
|
|
511
|
+
// Paid-only models always debit pack (upstream contract). The dynamic
|
|
512
|
+
// list is saved by generate-config.ts from the live catalog.
|
|
513
|
+
const paidOnlyRequested = isEnterprise && isPaidOnlyModel(actualModel);
|
|
514
|
+
// QUEST_ELIGIBLE_ONLY: hard-block paid_only models (no paid route).
|
|
515
|
+
if (paidOnlyRequested && config.mode === 'quest_only') {
|
|
516
|
+
log(`[QuestOnly] BLOCKED: Paid Only Model (${actualModel}).`);
|
|
517
|
+
emitStatusToast('warning', t('proxy.warnings.paid_blocked_questonly_title', { model: actualModel }), 'Quest-Only Mode');
|
|
518
|
+
const blockMsg = {
|
|
519
|
+
id: `chatcmpl-block-${Date.now()}`,
|
|
520
|
+
object: 'chat.completion',
|
|
521
|
+
created: Math.floor(Date.now() / 1000),
|
|
522
|
+
model: actualModel,
|
|
523
|
+
choices: [{
|
|
524
|
+
index: 0,
|
|
525
|
+
message: {
|
|
526
|
+
role: 'assistant',
|
|
527
|
+
content: t('proxy.warnings.paid_blocked_questonly_msg', { model: actualModel })
|
|
528
|
+
},
|
|
529
|
+
finish_reason: 'stop'
|
|
530
|
+
}],
|
|
531
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
532
|
+
};
|
|
533
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
534
|
+
res.end(JSON.stringify(blockMsg));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// Other modes: paid_only requires wallet (pack). If the wallet is
|
|
538
|
+
// empty, fall back to the free universe gracefully instead of a 402.
|
|
539
|
+
if (paidOnlyRequested && quota.walletBalance <= 0.001) { // Floating point safety
|
|
540
|
+
log(`[SafetyNet] Paid Only Model (${actualModel}) requested but Wallet is Empty ($${quota.walletBalance}). Falling back to free.`);
|
|
541
|
+
actualModel = config.fallbacks.free.main.replace('free/', '');
|
|
542
|
+
isEnterprise = false;
|
|
543
|
+
isFallbackActive = true;
|
|
544
|
+
fallbackReason = "Paid Only Model requires purchased credits";
|
|
367
545
|
}
|
|
368
|
-
// B. SAFETY NETS (
|
|
546
|
+
// B. SAFETY NETS (v6.5 — Quest/Paid semantics)
|
|
369
547
|
// 0. GLOBAL CHECK: Auth Limited (403 on Quota)
|
|
370
548
|
// If we can't read quota because of 403, we downgrade to Manual but ALLOW the request.
|
|
371
549
|
if (isEnterprise && quota.errorType === 'auth_limited') {
|
|
@@ -376,108 +554,74 @@ export async function handleChatCompletion(req, res, bodyRaw) {
|
|
|
376
554
|
config.mode = 'manual'; // Local override to skip safety nets below
|
|
377
555
|
emitStatusToast('warning', 'Clé Limitée: Passage en Mode Manuel', 'Permissions (403)');
|
|
378
556
|
}
|
|
379
|
-
// WE DO NOT RETURN 403. WE ALLOW THE REQUEST.
|
|
380
|
-
// Since config.mode is now 'manual', the next checks (alwaysfree/pro) will be skipped.
|
|
381
557
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
model: actualModel,
|
|
397
|
-
choices: [{
|
|
398
|
-
index: 0,
|
|
399
|
-
message: {
|
|
400
|
-
role: 'assistant',
|
|
401
|
-
content: t('proxy.warnings.paid_blocked_alwaysfree_msg', { model: actualModel })
|
|
402
|
-
},
|
|
403
|
-
finish_reason: 'stop'
|
|
404
|
-
}],
|
|
405
|
-
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
406
|
-
};
|
|
407
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
408
|
-
res.end(JSON.stringify(blockMsg));
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
558
|
+
const quotaReadable = quota.errorType !== 'network' && quota.errorType !== 'unknown';
|
|
559
|
+
if (config.mode === 'quest') {
|
|
560
|
+
// QUEST_PREFERRED: Quest first (server default), Paid fallback is
|
|
561
|
+
// allowed upstream. Client net only falls back to the free
|
|
562
|
+
// universe when the quota read failed, or when BOTH Quest and
|
|
563
|
+
// Paid look exhausted.
|
|
564
|
+
if (isEnterprise && !isFallbackActive) {
|
|
565
|
+
if (!quotaReadable) {
|
|
566
|
+
log(`[SafetyNet] Quest Mode: Quota Check Failed. Switching to Free Fallback.`);
|
|
567
|
+
emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), 'Quest Mode');
|
|
568
|
+
actualModel = config.fallbacks.free.main.replace('free/', '');
|
|
569
|
+
isEnterprise = false;
|
|
570
|
+
isFallbackActive = true;
|
|
571
|
+
fallbackReason = t('proxy.warnings.quota_unreachable_msg');
|
|
412
572
|
}
|
|
413
|
-
|
|
414
|
-
log(`[
|
|
573
|
+
else if (!quota.canUseEnterprise) {
|
|
574
|
+
log(`[SafetyNet] Quest Mode: Quest (~${quota.questBalance}) and Paid (~${quota.walletBalance}) exhausted. Switching.`);
|
|
575
|
+
emitStatusToast('warning', t('proxy.warnings.balance_exhausted_title'), 'Quest Mode');
|
|
576
|
+
actualModel = config.fallbacks.free.main.replace('free/', '');
|
|
577
|
+
isEnterprise = false;
|
|
578
|
+
isFallbackActive = true;
|
|
579
|
+
fallbackReason = t('proxy.warnings.balance_exhausted_msg');
|
|
415
580
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
else if (config.mode === 'quest_only') {
|
|
584
|
+
// QUEST_ELIGIBLE_ONLY: best-effort client guard. Never send an
|
|
585
|
+
// enterprise request when Quest looks exhausted. NOTE: upstream
|
|
586
|
+
// can still debit pack in a race/real-cost — documented, not a
|
|
587
|
+
// server guarantee.
|
|
588
|
+
if (isEnterprise && !isFallbackActive) {
|
|
589
|
+
if (!quotaReadable) {
|
|
590
|
+
log(`[SafetyNet] Quest-Only Mode: Quota Check Failed. Switching to Free Fallback.`);
|
|
591
|
+
emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), 'Quest-Only Mode');
|
|
420
592
|
actualModel = config.fallbacks.free.main.replace('free/', '');
|
|
421
593
|
isEnterprise = false;
|
|
422
594
|
isFallbackActive = true;
|
|
423
595
|
fallbackReason = t('proxy.warnings.quota_unreachable_msg');
|
|
424
596
|
}
|
|
425
|
-
else {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const tierRatio = effectiveLimit > 0 ? (effectiveFree / effectiveLimit) : 0;
|
|
433
|
-
if (tierRatio <= (config.thresholds.tier / 100)) {
|
|
434
|
-
log(`[SafetyNet] AlwaysFree Mode: Tier (${(tierRatio * 100).toFixed(1)}%) <= Threshold (${config.thresholds.tier}%). Switching.`);
|
|
435
|
-
emitStatusToast('warning', t('proxy.warnings.tier_limit_title', { threshold: config.thresholds.tier }), 'AlwaysFree Mode');
|
|
436
|
-
actualModel = config.fallbacks.free.main.replace('free/', '');
|
|
437
|
-
isEnterprise = false;
|
|
438
|
-
isFallbackActive = true;
|
|
439
|
-
fallbackReason = t('proxy.warnings.tier_limit_msg', { threshold: config.thresholds.tier });
|
|
440
|
-
}
|
|
597
|
+
else if (quota.questBalance <= (config.thresholds.quest ?? 0.05)) {
|
|
598
|
+
log(`[SafetyNet] Quest-Only Mode: Quest (~${quota.questBalance}) <= floor (${config.thresholds.quest ?? 0.05}). Switching.`);
|
|
599
|
+
emitStatusToast('warning', t('proxy.warnings.quest_floor_title', { floor: config.thresholds.quest ?? 0.05 }), 'Quest-Only Mode');
|
|
600
|
+
actualModel = config.fallbacks.free.main.replace('free/', '');
|
|
601
|
+
isEnterprise = false;
|
|
602
|
+
isFallbackActive = true;
|
|
603
|
+
fallbackReason = t('proxy.warnings.quest_floor_msg', { floor: config.thresholds.quest ?? 0.05 });
|
|
441
604
|
}
|
|
442
605
|
}
|
|
443
606
|
}
|
|
444
|
-
else if (config.mode === '
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
log(`[SafetyNet]
|
|
449
|
-
emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), '
|
|
607
|
+
else if (config.mode === 'paid') {
|
|
608
|
+
// PAID_ALLOWED: protect the wallet (like the old "pro" net).
|
|
609
|
+
if (isEnterprise && !isFallbackActive) {
|
|
610
|
+
if (!quotaReadable) {
|
|
611
|
+
log(`[SafetyNet] Paid Mode: Quota Unreachable. Switching to Free Fallback.`);
|
|
612
|
+
emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), 'Paid Mode');
|
|
450
613
|
actualModel = config.fallbacks.free.main.replace('free/', '');
|
|
451
614
|
isEnterprise = false;
|
|
452
615
|
isFallbackActive = true;
|
|
453
616
|
fallbackReason = t('proxy.warnings.quota_unreachable_msg');
|
|
454
617
|
}
|
|
455
|
-
else {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
isFallbackActive = true;
|
|
463
|
-
fallbackReason = t('proxy.warnings.wallet_tier_critical_msg', { wallet: config.thresholds.wallet, tier: config.thresholds.tier });
|
|
464
|
-
}
|
|
465
|
-
else if (quota.walletBalance < config.thresholds.wallet) {
|
|
466
|
-
log(`[SafetyNet] Pro Mode: Wallet < $${config.thresholds.wallet}. Switching.`);
|
|
467
|
-
emitStatusToast('warning', t('proxy.warnings.wallet_limit_title', { wallet: config.thresholds.wallet }), 'Pro Mode');
|
|
468
|
-
actualModel = config.fallbacks.free.main.replace('free/', '');
|
|
469
|
-
isEnterprise = false;
|
|
470
|
-
isFallbackActive = true;
|
|
471
|
-
fallbackReason = t('proxy.warnings.wallet_limit_msg', { threshold: config.thresholds.wallet });
|
|
472
|
-
}
|
|
473
|
-
else if (tierRatio <= (config.thresholds.tier / 100)) {
|
|
474
|
-
log(`[SafetyNet] Pro Mode: Tier < ${config.thresholds.tier}%. Switching.`);
|
|
475
|
-
emitStatusToast('warning', t('proxy.warnings.tier_limit_title', { threshold: config.thresholds.tier }), 'Pro Mode');
|
|
476
|
-
actualModel = config.fallbacks.free.main.replace('free/', '');
|
|
477
|
-
isEnterprise = false;
|
|
478
|
-
isFallbackActive = true;
|
|
479
|
-
fallbackReason = t('proxy.warnings.tier_limit_msg', { threshold: config.thresholds.tier });
|
|
480
|
-
}
|
|
618
|
+
else if (quota.walletBalance < (config.thresholds.wallet ?? 0.5)) {
|
|
619
|
+
log(`[SafetyNet] Paid Mode: Wallet (~${quota.walletBalance}) < floor (${config.thresholds.wallet ?? 0.5}). Switching.`);
|
|
620
|
+
emitStatusToast('warning', t('proxy.warnings.wallet_limit_title', { wallet: config.thresholds.wallet ?? 0.5 }), 'Paid Mode');
|
|
621
|
+
actualModel = config.fallbacks.free.main.replace('free/', '');
|
|
622
|
+
isEnterprise = false;
|
|
623
|
+
isFallbackActive = true;
|
|
624
|
+
fallbackReason = t('proxy.warnings.wallet_limit_msg', { threshold: config.thresholds.wallet ?? 0.5 });
|
|
481
625
|
}
|
|
482
626
|
}
|
|
483
627
|
}
|
|
@@ -759,27 +903,17 @@ export async function handleChatCompletion(req, res, bodyRaw) {
|
|
|
759
903
|
}
|
|
760
904
|
});
|
|
761
905
|
if (retryRes.body) {
|
|
762
|
-
let accumulated = "";
|
|
763
|
-
let currentSignature = null;
|
|
764
906
|
// @ts-ignore
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
907
|
+
const sig = await streamSseUpstream(res, retryRes.body, {
|
|
908
|
+
isFallbackActive: true,
|
|
909
|
+
actualModel,
|
|
910
|
+
fallbackReason
|
|
911
|
+
});
|
|
912
|
+
if (sig && currentRequestHash) {
|
|
913
|
+
signatureMap[currentRequestHash] = sig;
|
|
914
|
+
saveSignatureMap();
|
|
915
|
+
lastSignature = sig;
|
|
771
916
|
}
|
|
772
|
-
// INJECT NOTIFICATION AT END
|
|
773
|
-
const warningMsg = `\n\n> ⚠️ **Safety Net**: ${fallbackReason}. Switched to \`${actualModel}\`.`;
|
|
774
|
-
const safeId = "fallback-" + Date.now();
|
|
775
|
-
const warningChunk = {
|
|
776
|
-
id: safeId,
|
|
777
|
-
object: "chat.completion.chunk",
|
|
778
|
-
created: Math.floor(Date.now() / 1000),
|
|
779
|
-
model: actualModel,
|
|
780
|
-
choices: [{ index: 0, delta: { role: "assistant", content: warningMsg }, finish_reason: null }]
|
|
781
|
-
};
|
|
782
|
-
res.write(`data: ${JSON.stringify(warningChunk)}\n\n`);
|
|
783
917
|
// DASHBOARD UPDATE
|
|
784
918
|
const dashboardMsg = formatQuotaForToast(quota); // Quota is stale/empty but that's fine
|
|
785
919
|
const fullMsg = `${dashboardMsg} | ⚙️ PRO (FALLBACK)`;
|
|
@@ -790,61 +924,14 @@ export async function handleChatCompletion(req, res, bodyRaw) {
|
|
|
790
924
|
}
|
|
791
925
|
}
|
|
792
926
|
}
|
|
793
|
-
// Stream Loop
|
|
927
|
+
// Stream Loop — unified SSE processor (reasoning strip + Kimi normalization)
|
|
794
928
|
if (fetchRes.body) {
|
|
795
|
-
let accumulated = "";
|
|
796
|
-
let currentSignature = null;
|
|
797
929
|
// @ts-ignore
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
if (chunkStr.includes('"finish_reason": "tool_calls"') && chunkStr.includes('"tool_calls":null')) {
|
|
804
|
-
chunkStr = chunkStr.replace('"finish_reason": "tool_calls"', '"finish_reason": "stop"');
|
|
805
|
-
}
|
|
806
|
-
// 2. Original Logic: Ensure formatting but avoid false positives on null
|
|
807
|
-
// Only upgrade valid stops to tool_calls if we see actual tool array start
|
|
808
|
-
if (chunkStr.includes('"finish_reason"')) {
|
|
809
|
-
const stopRegex = /"finish_reason"\s*:\s*"(stop|STOP|did_not_finish|finished|end_turn|MAX_TOKENS)"/g;
|
|
810
|
-
if (stopRegex.test(chunkStr)) {
|
|
811
|
-
if (chunkStr.includes('"tool_calls":[') || chunkStr.includes('"tool_calls": [')) {
|
|
812
|
-
chunkStr = chunkStr.replace(stopRegex, '"finish_reason": "tool_calls"');
|
|
813
|
-
}
|
|
814
|
-
else {
|
|
815
|
-
chunkStr = chunkStr.replace(stopRegex, '"finish_reason": "stop"');
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
// SIGNATURE CAPTURE
|
|
820
|
-
if (!currentSignature) {
|
|
821
|
-
const match = chunkStr.match(/"thought_signature"\s*:\s*"([^"]+)"/);
|
|
822
|
-
if (match && match[1])
|
|
823
|
-
currentSignature = match[1];
|
|
824
|
-
}
|
|
825
|
-
// SAFETY STOP: SERVER-SIDE LOOP DETECTION (GUILLOTINE)
|
|
826
|
-
if (chunkStr.includes("User:") || chunkStr.includes("\nUser") || chunkStr.includes("user:")) {
|
|
827
|
-
if (chunkStr.match(/(\n|^)\s*(User|user)\s*:/)) {
|
|
828
|
-
res.end();
|
|
829
|
-
return; // HARD STOP
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
accumulated += chunkStr;
|
|
833
|
-
res.write(chunkStr);
|
|
834
|
-
}
|
|
835
|
-
// INJECT NOTIFICATION AT END
|
|
836
|
-
if (isFallbackActive) {
|
|
837
|
-
const warningMsg = `\n\n> ⚠️ **Safety Net**: ${fallbackReason}. Switched to \`${actualModel}\`.`;
|
|
838
|
-
const safeId = "fallback-" + Date.now();
|
|
839
|
-
const warningChunk = {
|
|
840
|
-
id: safeId,
|
|
841
|
-
object: "chat.completion.chunk",
|
|
842
|
-
created: Math.floor(Date.now() / 1000),
|
|
843
|
-
model: actualModel,
|
|
844
|
-
choices: [{ index: 0, delta: { role: "assistant", content: warningMsg }, finish_reason: null }]
|
|
845
|
-
};
|
|
846
|
-
res.write(`data: ${JSON.stringify(warningChunk)}\n\n`);
|
|
847
|
-
}
|
|
930
|
+
const currentSignature = await streamSseUpstream(res, fetchRes.body, {
|
|
931
|
+
isFallbackActive,
|
|
932
|
+
actualModel,
|
|
933
|
+
fallbackReason
|
|
934
|
+
});
|
|
848
935
|
// END STREAM: SAVE MAP & EMIT TOAST
|
|
849
936
|
if (currentSignature && currentRequestHash) {
|
|
850
937
|
signatureMap[currentRequestHash] = currentSignature;
|