opencode-pollinations-plugin 6.4.10 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) 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 +22 -6
  15. package/dist/server/config.js +44 -4
  16. package/dist/server/connect-response.js +7 -7
  17. package/dist/server/models/cache.d.ts +23 -10
  18. package/dist/server/models/cache.js +46 -24
  19. package/dist/server/models/worker.js +4 -4
  20. package/dist/server/proxy.d.ts +6 -0
  21. package/dist/server/proxy.js +313 -210
  22. package/dist/server/quota.d.ts +23 -32
  23. package/dist/server/quota.js +44 -184
  24. package/dist/server/status.js +1 -2
  25. package/dist/server/toast.js +1 -1
  26. package/dist/tools/index.d.ts +2 -1
  27. package/dist/tools/index.js +3 -1
  28. package/dist/tools/pollinations/artifact-core.d.ts +53 -0
  29. package/dist/tools/pollinations/artifact-core.js +159 -0
  30. package/dist/tools/pollinations/beta_discovery.js +2 -1
  31. package/dist/tools/pollinations/cost-guard.d.ts +2 -2
  32. package/dist/tools/pollinations/error-parser.d.ts +38 -0
  33. package/dist/tools/pollinations/error-parser.js +112 -0
  34. package/dist/tools/pollinations/gen_3d.d.ts +17 -0
  35. package/dist/tools/pollinations/gen_3d.js +207 -0
  36. package/dist/tools/pollinations/gen_image.js +29 -10
  37. package/dist/tools/pollinations/gen_music.js +3 -2
  38. package/dist/tools/pollinations/gen_video.js +13 -2
  39. package/dist/tools/pollinations/polli_config.js +15 -21
  40. package/dist/tools/pollinations/polli_gen_confirm.js +2 -0
  41. package/dist/tools/pollinations/shared.d.ts +1 -1
  42. package/dist/tools/pollinations/shared.js +53 -142
  43. package/dist/tools/pollinations/timeout-policy.d.ts +80 -0
  44. package/dist/tools/pollinations/timeout-policy.js +124 -0
  45. package/dist/tools/pollinations/tool-capability-registry.d.ts +51 -0
  46. package/dist/tools/pollinations/tool-capability-registry.js +215 -0
  47. package/dist/tools/pollinations/transcribe_audio.js +5 -24
  48. package/package.json +6 -4
  49. package/dist/server/tier-info.d.ts +0 -36
  50. package/dist/server/tier-info.js +0 -107
@@ -166,41 +166,230 @@ 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
+ // REASONING NORMALIZATION (v6.5) — M8/M9
236
+ // DeepSeek/Kimi expose `reasoning_content`; Qwen exposes `reasoning` +
237
+ // `reasoning_details` (Responses hybrid). These must never leak into OpenCode
238
+ // as text. Kimi also emits top-level `tool_calls[].name: null` (canonical is
239
+ // `function.name`) and `message.tools: null`.
240
+ // Rule: never merge reasoning* into content; strip the backend-specific fields;
241
+ // preserve usage.completion_tokens_details.reasoning_tokens.
242
+ // ============================================================================
243
+ const REASONING_KEYS = ['reasoning_content', 'reasoning', 'reasoning_details'];
244
+ function stripReasoning(obj) {
245
+ if (!obj || typeof obj !== 'object')
246
+ return obj;
247
+ if (Array.isArray(obj)) {
248
+ for (const item of obj)
249
+ stripReasoning(item);
250
+ return obj;
251
+ }
252
+ for (const key of REASONING_KEYS) {
253
+ if (key in obj)
254
+ delete obj[key];
255
+ }
256
+ return obj;
257
+ }
258
+ function normalizeToolCallShape(obj) {
259
+ if (!obj || typeof obj !== 'object')
260
+ return obj;
261
+ if (Array.isArray(obj)) {
262
+ for (const item of obj)
263
+ normalizeToolCallShape(item);
264
+ return obj;
265
+ }
266
+ // Kimi: top-level name === null is a parasite; function.name is canonical.
267
+ if ('name' in obj && obj.name === null) {
268
+ delete obj.name;
269
+ }
270
+ // deepseek/kimi: message.tools === null is a parasite.
271
+ if ('tools' in obj && obj.tools === null) {
272
+ delete obj.tools;
273
+ }
274
+ return obj;
275
+ }
276
+ function normalizeChatChunk(obj) {
277
+ if (!obj || typeof obj !== 'object')
278
+ return obj;
279
+ if (Array.isArray(obj)) {
280
+ for (const item of obj)
281
+ normalizeChatChunk(item);
282
+ return obj;
283
+ }
284
+ const delta = obj.delta;
285
+ if (delta && typeof delta === 'object') {
286
+ stripReasoning(delta);
287
+ if (Array.isArray(delta.tool_calls)) {
288
+ for (const tc of delta.tool_calls)
289
+ normalizeToolCallShape(tc);
290
+ }
291
+ }
292
+ const message = obj.message;
293
+ if (message && typeof message === 'object') {
294
+ stripReasoning(message);
295
+ normalizeToolCallShape(message);
296
+ if (Array.isArray(message.tool_calls)) {
297
+ for (const tc of message.tool_calls)
298
+ normalizeToolCallShape(tc);
299
+ }
300
+ }
301
+ if (Array.isArray(obj.choices)) {
302
+ for (const ch of obj.choices)
303
+ normalizeChatChunk(ch);
304
+ }
305
+ return obj;
306
+ }
307
+ /** Normalize a single raw SSE `data:` payload line (JSON). Non-JSON passes through. */
308
+ export function normalizeChunkLine(payload) {
309
+ const trimmed = payload.trim();
310
+ if (!trimmed)
311
+ return payload;
312
+ try {
313
+ const obj = JSON.parse(trimmed);
314
+ normalizeChatChunk(obj);
315
+ return JSON.stringify(obj);
316
+ }
317
+ catch {
318
+ return payload; // e.g. [DONE]
319
+ }
320
+ }
321
+ async function streamSseUpstream(res, stream, opts) {
322
+ let buffer = '';
323
+ let currentSignature = null;
324
+ const flushBlock = (block) => {
325
+ const lines = block.split('\n');
326
+ const outLines = [];
327
+ for (const ln of lines) {
328
+ if (ln.startsWith('data:')) {
329
+ const payload = ln.slice(5).trim();
330
+ const normalized = normalizeChunkLine(payload);
331
+ outLines.push(`data: ${normalized}`);
332
+ if (!currentSignature) {
333
+ const m = normalized.match(/"thought_signature"\s*:\s*"([^"]+)"/);
334
+ if (m && m[1])
335
+ currentSignature = m[1];
336
+ }
337
+ }
338
+ else {
339
+ outLines.push(ln);
340
+ }
341
+ }
342
+ let out = outLines.join('\n');
343
+ // FIX: STOP REASON NORMALIZATION (kept from v6.4.10)
344
+ if (out.includes('"finish_reason": "tool_calls"') && out.includes('"tool_calls":null')) {
345
+ out = out.replace('"finish_reason": "tool_calls"', '"finish_reason": "stop"');
346
+ }
347
+ if (out.includes('"finish_reason"')) {
348
+ const stopRegex = /"finish_reason"\s*:\s*"(stop|STOP|did_not_finish|finished|end_turn|MAX_TOKENS)"/g;
349
+ if (stopRegex.test(out)) {
350
+ if (out.includes('"tool_calls":[') || out.includes('"tool_calls": [')) {
351
+ out = out.replace(stopRegex, '"finish_reason": "tool_calls"');
352
+ }
353
+ else {
354
+ out = out.replace(stopRegex, '"finish_reason": "stop"');
355
+ }
356
+ }
357
+ }
358
+ res.write(out + '\n\n');
359
+ };
360
+ for await (const chunk of stream) {
361
+ buffer += Buffer.from(chunk).toString().replace(/\r\n/g, '\n');
362
+ let idx;
363
+ while ((idx = buffer.indexOf('\n\n')) >= 0) {
364
+ const block = buffer.slice(0, idx);
365
+ buffer = buffer.slice(idx + 2);
366
+ // SAFETY STOP: SERVER-SIDE LOOP DETECTION (GUILLOTINE)
367
+ if (block.includes("User:") || block.includes("\nUser") || block.includes("user:")) {
368
+ if (block.match(/(\n|^)\s*(User|user)\s*:/)) {
369
+ res.end();
370
+ return currentSignature;
371
+ }
372
+ }
373
+ flushBlock(block);
374
+ }
375
+ }
376
+ if (buffer.trim()) {
377
+ flushBlock(buffer);
378
+ }
379
+ // INJECT FALLBACK NOTIFICATION AT END
380
+ if (opts.isFallbackActive) {
381
+ const warningMsg = `\n\n> ⚠️ **Safety Net**: ${opts.fallbackReason}. Switched to \`${opts.actualModel}\`.`;
382
+ const safeId = "fallback-" + Date.now();
383
+ const warningChunk = {
384
+ id: safeId,
385
+ object: "chat.completion.chunk",
386
+ created: Math.floor(Date.now() / 1000),
387
+ model: opts.actualModel,
388
+ choices: [{ index: 0, delta: { role: "assistant", content: warningMsg }, finish_reason: null }]
389
+ };
390
+ res.write(`data: ${JSON.stringify(warningChunk)}\n\n`);
391
+ }
392
+ return currentSignature;
204
393
  }
205
394
  // --- MEDIA UPLOAD HELPER (Vision Support) ---
206
395
  // Uploads a base64 data URL to media.pollinations.ai and returns a public URL.
@@ -334,38 +523,43 @@ export async function handleChatCompletion(req, res, bodyRaw) {
334
523
  isEnterprise = false;
335
524
  actualModel = actualModel.replace('free/', '');
336
525
  }
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
- }
526
+ // A.1 PAID-ONLY MODEL RESOLUTION (v6.5)
527
+ // Paid-only models always debit pack (upstream contract). The dynamic
528
+ // list is saved by generate-config.ts from the live catalog.
529
+ const paidOnlyRequested = isEnterprise && isPaidOnlyModel(actualModel);
530
+ // QUEST_ELIGIBLE_ONLY: hard-block paid_only models (no paid route).
531
+ if (paidOnlyRequested && config.mode === 'quest_only') {
532
+ log(`[QuestOnly] BLOCKED: Paid Only Model (${actualModel}).`);
533
+ emitStatusToast('warning', t('proxy.warnings.paid_blocked_questonly_title', { model: actualModel }), 'Quest-Only Mode');
534
+ const blockMsg = {
535
+ id: `chatcmpl-block-${Date.now()}`,
536
+ object: 'chat.completion',
537
+ created: Math.floor(Date.now() / 1000),
538
+ model: actualModel,
539
+ choices: [{
540
+ index: 0,
541
+ message: {
542
+ role: 'assistant',
543
+ content: t('proxy.warnings.paid_blocked_questonly_msg', { model: actualModel })
544
+ },
545
+ finish_reason: 'stop'
546
+ }],
547
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
548
+ };
549
+ res.writeHead(200, { 'Content-Type': 'application/json' });
550
+ res.end(JSON.stringify(blockMsg));
551
+ return;
367
552
  }
368
- // B. SAFETY NETS (The Core V5 Logic)
553
+ // Other modes: paid_only requires wallet (pack). If the wallet is
554
+ // empty, fall back to the free universe gracefully instead of a 402.
555
+ if (paidOnlyRequested && quota.walletBalance <= 0.001) { // Floating point safety
556
+ log(`[SafetyNet] Paid Only Model (${actualModel}) requested but Wallet is Empty ($${quota.walletBalance}). Falling back to free.`);
557
+ actualModel = config.fallbacks.free.main.replace('free/', '');
558
+ isEnterprise = false;
559
+ isFallbackActive = true;
560
+ fallbackReason = "Paid Only Model requires purchased credits";
561
+ }
562
+ // B. SAFETY NETS (v6.5 — Quest/Paid semantics)
369
563
  // 0. GLOBAL CHECK: Auth Limited (403 on Quota)
370
564
  // If we can't read quota because of 403, we downgrade to Manual but ALLOW the request.
371
565
  if (isEnterprise && quota.errorType === 'auth_limited') {
@@ -376,108 +570,74 @@ export async function handleChatCompletion(req, res, bodyRaw) {
376
570
  config.mode = 'manual'; // Local override to skip safety nets below
377
571
  emitStatusToast('warning', 'Clé Limitée: Passage en Mode Manuel', 'Permissions (403)');
378
572
  }
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
573
  }
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
- }
574
+ const quotaReadable = quota.errorType !== 'network' && quota.errorType !== 'unknown';
575
+ if (config.mode === 'quest') {
576
+ // QUEST_PREFERRED: Quest first (server default), Paid fallback is
577
+ // allowed upstream. Client net only falls back to the free
578
+ // universe when the quota read failed, or when BOTH Quest and
579
+ // Paid look exhausted.
580
+ if (isEnterprise && !isFallbackActive) {
581
+ if (!quotaReadable) {
582
+ log(`[SafetyNet] Quest Mode: Quota Check Failed. Switching to Free Fallback.`);
583
+ emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), 'Quest Mode');
584
+ actualModel = config.fallbacks.free.main.replace('free/', '');
585
+ isEnterprise = false;
586
+ isFallbackActive = true;
587
+ fallbackReason = t('proxy.warnings.quota_unreachable_msg');
412
588
  }
413
- catch (e) {
414
- log(`[Proxy AlwaysFree] Error checking paid models: ${e}`);
589
+ else if (!quota.canUseEnterprise) {
590
+ log(`[SafetyNet] Quest Mode: Quest (~${quota.questBalance}) and Paid (~${quota.walletBalance}) exhausted. Switching.`);
591
+ emitStatusToast('warning', t('proxy.warnings.balance_exhausted_title'), 'Quest Mode');
592
+ actualModel = config.fallbacks.free.main.replace('free/', '');
593
+ isEnterprise = false;
594
+ isFallbackActive = true;
595
+ fallbackReason = t('proxy.warnings.balance_exhausted_msg');
415
596
  }
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');
597
+ }
598
+ }
599
+ else if (config.mode === 'quest_only') {
600
+ // QUEST_ELIGIBLE_ONLY: best-effort client guard. Never send an
601
+ // enterprise request when Quest looks exhausted. NOTE: upstream
602
+ // can still debit pack in a race/real-cost — documented, not a
603
+ // server guarantee.
604
+ if (isEnterprise && !isFallbackActive) {
605
+ if (!quotaReadable) {
606
+ log(`[SafetyNet] Quest-Only Mode: Quota Check Failed. Switching to Free Fallback.`);
607
+ emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), 'Quest-Only Mode');
420
608
  actualModel = config.fallbacks.free.main.replace('free/', '');
421
609
  isEnterprise = false;
422
610
  isFallbackActive = true;
423
611
  fallbackReason = t('proxy.warnings.quota_unreachable_msg');
424
612
  }
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
- }
613
+ else if (quota.questBalance <= (config.thresholds.quest ?? 0.05)) {
614
+ log(`[SafetyNet] Quest-Only Mode: Quest (~${quota.questBalance}) <= floor (${config.thresholds.quest ?? 0.05}). Switching.`);
615
+ emitStatusToast('warning', t('proxy.warnings.quest_floor_title', { floor: config.thresholds.quest ?? 0.05 }), 'Quest-Only Mode');
616
+ actualModel = config.fallbacks.free.main.replace('free/', '');
617
+ isEnterprise = false;
618
+ isFallbackActive = true;
619
+ fallbackReason = t('proxy.warnings.quest_floor_msg', { floor: config.thresholds.quest ?? 0.05 });
441
620
  }
442
621
  }
443
622
  }
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');
623
+ else if (config.mode === 'paid') {
624
+ // PAID_ALLOWED: protect the wallet (like the old "pro" net).
625
+ if (isEnterprise && !isFallbackActive) {
626
+ if (!quotaReadable) {
627
+ log(`[SafetyNet] Paid Mode: Quota Unreachable. Switching to Free Fallback.`);
628
+ emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), 'Paid Mode');
450
629
  actualModel = config.fallbacks.free.main.replace('free/', '');
451
630
  isEnterprise = false;
452
631
  isFallbackActive = true;
453
632
  fallbackReason = t('proxy.warnings.quota_unreachable_msg');
454
633
  }
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
- }
634
+ else if (quota.walletBalance < (config.thresholds.wallet || 0.5)) {
635
+ log(`[SafetyNet] Paid Mode: Wallet (~${quota.walletBalance}) < floor (${config.thresholds.wallet || 0.5}). Switching.`);
636
+ emitStatusToast('warning', t('proxy.warnings.wallet_limit_title', { wallet: config.thresholds.wallet || 0.5 }), 'Paid Mode');
637
+ actualModel = config.fallbacks.free.main.replace('free/', '');
638
+ isEnterprise = false;
639
+ isFallbackActive = true;
640
+ fallbackReason = t('proxy.warnings.wallet_limit_msg', { threshold: config.thresholds.wallet || 0.5 });
481
641
  }
482
642
  }
483
643
  }
@@ -759,27 +919,17 @@ export async function handleChatCompletion(req, res, bodyRaw) {
759
919
  }
760
920
  });
761
921
  if (retryRes.body) {
762
- let accumulated = "";
763
- let currentSignature = null;
764
922
  // @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);
923
+ const sig = await streamSseUpstream(res, retryRes.body, {
924
+ isFallbackActive: true,
925
+ actualModel,
926
+ fallbackReason
927
+ });
928
+ if (sig && currentRequestHash) {
929
+ signatureMap[currentRequestHash] = sig;
930
+ saveSignatureMap();
931
+ lastSignature = sig;
771
932
  }
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
933
  // DASHBOARD UPDATE
784
934
  const dashboardMsg = formatQuotaForToast(quota); // Quota is stale/empty but that's fine
785
935
  const fullMsg = `${dashboardMsg} | ⚙️ PRO (FALLBACK)`;
@@ -790,61 +940,14 @@ export async function handleChatCompletion(req, res, bodyRaw) {
790
940
  }
791
941
  }
792
942
  }
793
- // Stream Loop
943
+ // Stream Loop — unified SSE processor (reasoning strip + Kimi normalization)
794
944
  if (fetchRes.body) {
795
- let accumulated = "";
796
- let currentSignature = null;
797
945
  // @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
- }
946
+ const currentSignature = await streamSseUpstream(res, fetchRes.body, {
947
+ isFallbackActive,
948
+ actualModel,
949
+ fallbackReason
950
+ });
848
951
  // END STREAM: SAVE MAP & EMIT TOAST
849
952
  if (currentSignature && currentRequestHash) {
850
953
  signatureMap[currentRequestHash] = currentSignature;