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.
Files changed (54) hide show
  1. package/README.de.md +67 -55
  2. package/README.es.md +79 -67
  3. package/README.fr.md +70 -58
  4. package/README.it.md +78 -66
  5. package/README.md +33 -29
  6. package/README.zh.md +77 -65
  7. package/dist/locales/de.json +82 -50
  8. package/dist/locales/en.json +84 -52
  9. package/dist/locales/es.json +82 -50
  10. package/dist/locales/fr.json +81 -49
  11. package/dist/locales/it.json +82 -50
  12. package/dist/locales/zh.json +82 -50
  13. package/dist/server/commands.js +112 -110
  14. package/dist/server/config.d.ts +24 -6
  15. package/dist/server/config.js +45 -4
  16. package/dist/server/connect-response.js +7 -7
  17. package/dist/server/generate-config.d.ts +7 -0
  18. package/dist/server/generate-config.js +22 -16
  19. package/dist/server/models/cache.d.ts +23 -10
  20. package/dist/server/models/cache.js +46 -24
  21. package/dist/server/models/fetcher.js +9 -0
  22. package/dist/server/models/types.d.ts +8 -0
  23. package/dist/server/models/worker.js +32 -8
  24. package/dist/server/proxy.d.ts +6 -0
  25. package/dist/server/proxy.js +297 -210
  26. package/dist/server/quota.d.ts +23 -32
  27. package/dist/server/quota.js +45 -185
  28. package/dist/server/status.js +1 -2
  29. package/dist/server/toast.js +1 -1
  30. package/dist/tools/index.d.ts +2 -1
  31. package/dist/tools/index.js +8 -3
  32. package/dist/tools/pollinations/artifact-core.d.ts +53 -0
  33. package/dist/tools/pollinations/artifact-core.js +159 -0
  34. package/dist/tools/pollinations/beta_discovery.js +2 -1
  35. package/dist/tools/pollinations/cost-guard.d.ts +2 -2
  36. package/dist/tools/pollinations/error-parser.d.ts +38 -0
  37. package/dist/tools/pollinations/error-parser.js +112 -0
  38. package/dist/tools/pollinations/gen_3d.d.ts +17 -0
  39. package/dist/tools/pollinations/gen_3d.js +207 -0
  40. package/dist/tools/pollinations/gen_image.js +29 -10
  41. package/dist/tools/pollinations/gen_music.js +3 -2
  42. package/dist/tools/pollinations/gen_video.js +13 -2
  43. package/dist/tools/pollinations/polli_config.js +18 -21
  44. package/dist/tools/pollinations/polli_gen_confirm.js +2 -0
  45. package/dist/tools/pollinations/shared.d.ts +1 -1
  46. package/dist/tools/pollinations/shared.js +53 -142
  47. package/dist/tools/pollinations/timeout-policy.d.ts +80 -0
  48. package/dist/tools/pollinations/timeout-policy.js +124 -0
  49. package/dist/tools/pollinations/tool-capability-registry.d.ts +51 -0
  50. package/dist/tools/pollinations/tool-capability-registry.js +215 -0
  51. package/dist/tools/pollinations/transcribe_audio.js +5 -24
  52. package/package.json +6 -4
  53. package/dist/server/tier-info.d.ts +0 -36
  54. package/dist/server/tier-info.js +0 -107
@@ -166,41 +166,214 @@ function truncateTools(tools, limit = 120) {
166
166
  return tools;
167
167
  return tools.slice(0, limit);
168
168
  }
169
- const MAX_RETRIES = 3;
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
- async function fetchWithRetry(url, options, retries = MAX_RETRIES) {
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
- 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);
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
- if (retries > 0) {
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 ENFORCEMENT (V5.5 Strategy)
338
- // Check dynamic list saved by generate-config.ts
339
- if (isEnterprise) {
340
- try {
341
- const standardPaidPath = path.join(getConfigDir(), 'pollinations-paid-models.json');
342
- if (fs.existsSync(standardPaidPath)) {
343
- const paidModels = JSON.parse(fs.readFileSync(standardPaidPath, 'utf-8'));
344
- if (paidModels.includes(actualModel)) {
345
- // IT IS A PAID ONLY MODEL.
346
- // STRICT CHECK: Wallet > 0 required. (Not just Tier)
347
- if (quota.walletBalance <= 0.001) { // Floating point safety
348
- log(`[SafetyNet] Paid Only Model (${actualModel}) requested but Wallet is Empty ($${quota.walletBalance}). BLOCKING.`);
349
- // Immediate Block or Fallback?
350
- // Text says: "💎 Paid Only models require purchased pollen only"
351
- // Blocking is safer/clearer than falling back to a free model which might not be what the user expects for a "Pro" feature?
352
- // Actually, Fallback to Free is usually better for UX if configured, BUT for specific "Paid Only" requests, the user explicitly chose a powerful model.
353
- // Falling back to Mistral might be confusing if they asked for Gemini-Large.
354
- // BUT we are failing gracefully.
355
- // Let's Fallback to Free Default and Warn.
356
- actualModel = config.fallbacks.free.main.replace('free/', '');
357
- isEnterprise = false;
358
- isFallbackActive = true;
359
- fallbackReason = "Paid Only Model requires purchased credits";
360
- }
361
- }
362
- }
363
- }
364
- catch (e) {
365
- log(`[Proxy] Error checking paid models: ${e}`);
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 (The Core V5 Logic)
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
- if (config.mode === 'alwaysfree') {
383
- if (isEnterprise) {
384
- // Paid Only Check: BLOCK (not fallback) in AlwaysFree mode
385
- try {
386
- const standardPaidPath = path.join(getConfigDir(), 'pollinations-paid-models.json');
387
- if (fs.existsSync(standardPaidPath)) {
388
- const paidModels = JSON.parse(fs.readFileSync(standardPaidPath, 'utf-8'));
389
- if (paidModels.includes(actualModel)) {
390
- log(`[AlwaysFree] BLOCKED: Paid Only Model (${actualModel}).`);
391
- emitStatusToast('warning', t('proxy.warnings.paid_blocked_alwaysfree_title', { model: actualModel }), 'AlwaysFree Mode');
392
- const blockMsg = {
393
- id: `chatcmpl-block-${Date.now()}`,
394
- object: 'chat.completion',
395
- created: Math.floor(Date.now() / 1000),
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
- catch (e) {
414
- log(`[Proxy AlwaysFree] Error checking paid models: ${e}`);
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
- if (!isFallbackActive && quota.tier === 'error') {
417
- // Network error or unknown error (but NOT auth_limited, handled above)
418
- log(`[SafetyNet] AlwaysFree Mode: Quota Check Failed. Switching to Free Fallback.`);
419
- emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), 'AlwaysFree Mode');
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
- const effectiveFree = config.questStashInFreeMode !== false
427
- ? quota.tierRemaining + (quota.questStash || 0)
428
- : quota.tierRemaining;
429
- const effectiveLimit = config.questStashInFreeMode !== false
430
- ? quota.tierLimit + (quota.questStash || 0)
431
- : quota.tierLimit;
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 === 'pro') {
445
- if (isEnterprise) {
446
- if (quota.tier === 'error') {
447
- // Network error or unknown
448
- log(`[SafetyNet] Pro Mode: Quota Unreachable. Switching to Free Fallback.`);
449
- emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), 'Pro Mode');
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
- const tierRatio = quota.tierLimit > 0 ? (quota.tierRemaining / quota.tierLimit) : 0;
457
- if (quota.walletBalance < config.thresholds.wallet && tierRatio <= (config.thresholds.tier / 100)) {
458
- log(`[SafetyNet] Pro Mode: Wallet < $${config.thresholds.wallet} AND Tier < ${config.thresholds.tier}%. Switching.`);
459
- emitStatusToast('warning', t('proxy.warnings.wallet_tier_critical_title', { wallet: config.thresholds.wallet, tier: config.thresholds.tier }), 'Pro Mode');
460
- actualModel = config.fallbacks.free.main.replace('free/', '');
461
- isEnterprise = false;
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
- for await (const chunk of retryRes.body) {
766
- const buffer = Buffer.from(chunk);
767
- const chunkStr = buffer.toString();
768
- // ... (Copy basic stream logic or genericize? Copying safe for hotfix)
769
- accumulated += chunkStr;
770
- res.write(chunkStr);
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
- for await (const chunk of fetchRes.body) {
799
- const buffer = Buffer.from(chunk);
800
- let chunkStr = buffer.toString();
801
- // FIX: STOP REASON NORMALIZATION using Regex Safely
802
- // 1. If Kimi/Model sends "tool_calls" reason but "tool_calls":null, FORCE STOP.
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;