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.
- 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 +22 -6
- package/dist/server/config.js +44 -4
- package/dist/server/connect-response.js +7 -7
- package/dist/server/models/cache.d.ts +23 -10
- package/dist/server/models/cache.js +46 -24
- package/dist/server/models/worker.js +4 -4
- package/dist/server/proxy.d.ts +6 -0
- package/dist/server/proxy.js +313 -210
- package/dist/server/quota.d.ts +23 -32
- package/dist/server/quota.js +44 -184
- 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 +3 -1
- 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 +15 -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,230 @@ 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
|
+
// 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
|
|
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
|
-
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
|
-
//
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
414
|
-
log(`[
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 === '
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
log(`[SafetyNet]
|
|
449
|
-
emitStatusToast('warning', t('proxy.warnings.quota_unreachable_title'), '
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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;
|