@paytaca/opencode-plugin 0.2.2 → 0.3.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.
@@ -64,7 +64,7 @@ let requestCounter = 0;
64
64
  // Utility: run shell command and return output
65
65
  function runCommand(cmd, args = []) {
66
66
  return new Promise((resolve, reject) => {
67
- const child = spawn(cmd, args, { shell: false });
67
+ const child = spawn(cmd, args, { shell: process.platform === 'win32' });
68
68
  let stdout = '';
69
69
  let stderr = '';
70
70
 
@@ -110,6 +110,60 @@ async function getWalletBalance() {
110
110
  }
111
111
  }
112
112
 
113
+ // LIFT token balance in base units (2 decimals); null when unavailable.
114
+ const LIFT_TOKEN_ID = '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';
115
+ async function getLiftBalance() {
116
+ try {
117
+ const output = await runCommand(PAYTACA_CMD, ['token', 'info', LIFT_TOKEN_ID]);
118
+ const match = output.match(/Balance:\s*([\d.]+)\s*LIFT/i);
119
+ if (match) return Math.round(parseFloat(match[1]) * 100);
120
+ return null;
121
+ } catch (err) {
122
+ log('Failed to get LIFT balance: ' + err.message);
123
+ return null;
124
+ }
125
+ }
126
+
127
+ // Short-lived cache of wallet + LIFT balances so we don't shell out to the CLI
128
+ // on every forwarded prompt. The backend concierge reads these headers to give
129
+ // free balance answers when the wallet has no paid capacity.
130
+ const BALANCE_CACHE_TTL = 15000;
131
+ let balanceCache = { at: 0, sats: null, lift: null };
132
+
133
+ async function getCachedBalances() {
134
+ const now = Date.now();
135
+ if (balanceCache.at && now - balanceCache.at < BALANCE_CACHE_TTL) {
136
+ return { sats: balanceCache.sats, lift: balanceCache.lift };
137
+ }
138
+ const sats = await getWalletBalance();
139
+ const lift = await getLiftBalance();
140
+ balanceCache = { at: now, sats, lift };
141
+ return { sats, lift };
142
+ }
143
+
144
+ // LIFT payment discount percent advertised by the backend (/v1/config), cached
145
+ // briefly (30s) so a server-side rate change is picked up quickly. Returns 0
146
+ // when unset/unavailable so callers can fall back to no-discount messaging.
147
+ let liftDiscountCache = { at: 0, percent: 0 };
148
+ async function getLiftDiscountPercent() {
149
+ const now = Date.now();
150
+ if (liftDiscountCache.at && now - liftDiscountCache.at < 30000) {
151
+ return liftDiscountCache.percent;
152
+ }
153
+ let percent = 0;
154
+ try {
155
+ const configRes = await fetch(BACKEND_URL + '/v1/config');
156
+ if (configRes.ok) {
157
+ const data = await configRes.json();
158
+ percent = Number(data.lift_payment_discount_percent) || 0;
159
+ }
160
+ } catch (err) {
161
+ log('Failed to fetch LIFT discount config: ' + err.message);
162
+ }
163
+ liftDiscountCache = { at: now, percent };
164
+ return percent;
165
+ }
166
+
113
167
  // Utility: get receiving address
114
168
  async function getReceivingAddress() {
115
169
  try {
@@ -255,10 +309,20 @@ async function streamTierSelectionBody(res, walletHash, modelName, tiers, includ
255
309
  choices: [{ index: 0, delta: { content: tiersContent }, finish_reason: null }],
256
310
  });
257
311
 
312
+ // Advertise the LIFT discount when the backend advertises one.
313
+ const liftPercent = await getLiftDiscountPercent();
314
+ if (liftPercent > 0) {
315
+ sseLine(res, {
316
+ id: 'tier-10b',
317
+ object: 'chat.completion.chunk',
318
+ choices: [{ index: 0, delta: { content: '\\nšŸ’” **' + liftPercent + '% off** when you pay with LIFT tokens — type \`LIFT\` to pay with LIFT and get the discount.\\n' }, finish_reason: null }],
319
+ });
320
+ }
321
+
258
322
  sseLine(res, {
259
323
  id: 'tier-11',
260
324
  object: 'chat.completion.chunk',
261
- choices: [{ index: 0, delta: { content: '\\nEnter a number (1-' + tiers.length + '), e.g. type ' + tiers[0].minutes + ':\\n' }, finish_reason: 'stop' }],
325
+ choices: [{ index: 0, delta: { content: '\\nEnter a number (1-' + tiers.length + ') to pay with BCH, or type \`LIFT\` to pay with LIFT tokens' + (liftPercent > 0 ? ' and get ' + liftPercent + '% off' : '') + ':\\n' }, finish_reason: 'stop' }],
262
326
  });
263
327
 
264
328
  // If other models still have paid credits, tell the user they can switch
@@ -334,7 +398,8 @@ async function streamLowBalanceNotice(res, modelName, otherModels) {
334
398
  }
335
399
 
336
400
  // Forward request to Django and return response (buffered, for non-streaming)
337
- function forwardToDjango(req, body, callback) {
401
+ async function forwardToDjango(req, body, callback) {
402
+ const balances = await getCachedBalances();
338
403
  const options = {
339
404
  hostname: DJANGO_HOST,
340
405
  port: DJANGO_PORT,
@@ -343,6 +408,8 @@ function forwardToDjango(req, body, callback) {
343
408
  headers: {
344
409
  'Content-Type': req.headers['content-type'] || 'application/json',
345
410
  'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',
411
+ 'X-Wallet-Balance-Sats': balances.sats !== null ? String(balances.sats) : '',
412
+ 'X-Lift-Balance-Units': balances.lift !== null ? String(balances.lift) : '',
346
413
  'Content-Length': Buffer.byteLength(body),
347
414
  },
348
415
  };
@@ -386,7 +453,8 @@ function forwardToDjango(req, body, callback) {
386
453
  }
387
454
 
388
455
  // Forward streaming request to Django
389
- function forwardStreaming(req, res, body, callback) {
456
+ async function forwardStreaming(req, res, body, callback) {
457
+ const balances = await getCachedBalances();
390
458
  const options = {
391
459
  hostname: DJANGO_HOST,
392
460
  port: DJANGO_PORT,
@@ -395,6 +463,8 @@ function forwardStreaming(req, res, body, callback) {
395
463
  headers: {
396
464
  'Content-Type': req.headers['content-type'] || 'application/json',
397
465
  'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',
466
+ 'X-Wallet-Balance-Sats': balances.sats !== null ? String(balances.sats) : '',
467
+ 'X-Lift-Balance-Units': balances.lift !== null ? String(balances.lift) : '',
398
468
  'Content-Length': Buffer.byteLength(body),
399
469
  },
400
470
  };
@@ -731,7 +801,7 @@ async function streamPaymentFailureAndRetry(res, walletHash, pendingPayload, mes
731
801
  }
732
802
 
733
803
  // Run paytaca pay internally and return the response
734
- function runPaytacaPay(djangoUrl, body, walletHash, extraHeaders, callback) {
804
+ function runPaytacaPay(djangoUrl, body, walletHash, extraHeaders, paymentMethod, callback) {
735
805
  const url = djangoUrl + '/chat/completions?wallet_hash=' + encodeURIComponent(walletHash || '');
736
806
  const payBody = forceNonStreaming(body);
737
807
 
@@ -753,6 +823,9 @@ function runPaytacaPay(djangoUrl, body, walletHash, extraHeaders, callback) {
753
823
  bodyFile,
754
824
  confirmed: true,
755
825
  };
826
+ if (paymentMethod === 'lift') {
827
+ config.paymentMethod = 'lift';
828
+ }
756
829
 
757
830
  try {
758
831
  fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');
@@ -816,6 +889,184 @@ function runPaytacaPay(djangoUrl, body, walletHash, extraHeaders, callback) {
816
889
  });
817
890
  }
818
891
 
892
+ // ---------------------------------------------------------------------------
893
+ // Auto-refill: when armed via the MCP auto_refill tool, the proxy silently buys
894
+ // a plan of the configured size on 402 (credits exhausted) and retries instead
895
+ // of showing the interactive tier prompt. It stops when the cumulative budget
896
+ // (maxMinutes) is reached, the requested model mismatches, funds are short, or
897
+ // 24h pass without a refill (so a stale armed state cannot keep spending in a
898
+ // later session).
899
+ const AUTO_REFILL_FILE = path.join(LOG_DIR, 'auto-refill.json');
900
+
901
+ function getAutoRefillState() {
902
+ try {
903
+ const s = JSON.parse(fs.readFileSync(AUTO_REFILL_FILE, 'utf8'));
904
+ if (!s || s.enabled !== true) {
905
+ return null;
906
+ }
907
+ return {
908
+ enabled: true,
909
+ minutes: Number(s.minutes) || 0,
910
+ maxMinutes: Number(s.maxMinutes) || 0,
911
+ spentMinutes: Number(s.spentMinutes) || 0,
912
+ model: s.model ? String(s.model) : '',
913
+ paymentMethod: s.paymentMethod === 'lift' ? 'lift' : 'bch',
914
+ startedAt: s.startedAt || new Date().toISOString(),
915
+ lastRefillAt: s.lastRefillAt || null,
916
+ };
917
+ } catch (err) {
918
+ log('Failed to read auto-refill state: ' + err.message);
919
+ return null;
920
+ }
921
+ }
922
+
923
+ function saveAutoRefillState(s) {
924
+ try {
925
+ fs.writeFileSync(AUTO_REFILL_FILE, JSON.stringify(s, null, 2), 'utf8');
926
+ } catch (err) {
927
+ log('Failed to write auto-refill state: ' + err.message);
928
+ }
929
+ }
930
+
931
+ // Can the proxy buy another plan right now? Handles budget, model scope and a
932
+ // staleness rule — when it returns false the caller falls through to the normal
933
+ // interactive tier prompt so the user can buy manually.
934
+ function autoRefillCanBuy(s, modelId) {
935
+ if (!s || !s.enabled) return false;
936
+ if (s.minutes <= 0 || s.maxMinutes < s.minutes) {
937
+ log('Auto-refill disarmed: bad config (minutes=' + s.minutes + ' maxMinutes=' + s.maxMinutes + ')');
938
+ s.enabled = false;
939
+ saveAutoRefillState(s);
940
+ return false;
941
+ }
942
+ if (s.spentMinutes >= s.maxMinutes) {
943
+ return false;
944
+ }
945
+ if (s.model && (!modelId || s.model !== modelId)) {
946
+ return false;
947
+ }
948
+ const now = Date.now();
949
+ const anchorMs = new Date(s.lastRefillAt || s.startedAt || now).getTime();
950
+ if (isNaN(anchorMs) || now - anchorMs > 24 * 3600 * 1000) {
951
+ log('Auto-refill disarmed: stale (no refill for 24h)');
952
+ s.enabled = false;
953
+ saveAutoRefillState(s);
954
+ return false;
955
+ }
956
+ return true;
957
+ }
958
+
959
+ // Buy the configured plan automatically and retry the request. Mirrors the
960
+ // interactive payment path (keepalive + paytaca pay + SSE delivery) but never
961
+ // asks the user to pick a tier. On failure it disarms auto-refill and streams
962
+ // the payment-error prompt so the user can retry by hand.
963
+ async function autoRefillAndRetry(res, walletHash, pendingPayload, refill) {
964
+ log('AUTO-REFILL: buying ' + refill.minutes + '-min plan for wallet ' + walletHash?.substring(0, 16) + '...');
965
+ pendingPayload.step = 'processing';
966
+ const extraHeaders = {};
967
+ if (pendingPayload.modelId) {
968
+ extraHeaders['X-Model-Id'] = pendingPayload.modelId;
969
+ }
970
+ extraHeaders['X-Duration-Minutes'] = String(refill.minutes);
971
+ if (refill.paymentMethod === 'lift') {
972
+ extraHeaders['X-Payment-Method'] = 'lift';
973
+ }
974
+
975
+ if (!res.headersSent) {
976
+ try {
977
+ res.writeHead(200, {
978
+ 'Content-Type': 'text/event-stream',
979
+ 'Cache-Control': 'no-cache',
980
+ 'Connection': 'keep-alive',
981
+ 'X-Payment-Processing': 'true',
982
+ });
983
+ } catch (e) {
984
+ log('autoRefillAndRetry writeHead failed: ' + e.message);
985
+ }
986
+ }
987
+ const keepalive = setInterval(() => {
988
+ if (res.destroyed || res.writableEnded) { clearInterval(keepalive); return; }
989
+ res.write(': keepalive\\n\\n');
990
+ }, 2000);
991
+
992
+ runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, refill.paymentMethod || 'bch', async (err, responseJson) => {
993
+ clearInterval(keepalive);
994
+ pendingPayments.delete(walletHash);
995
+
996
+ const disarmAndFail = async (msg) => {
997
+ refill.enabled = false;
998
+ saveAutoRefillState(refill);
999
+ log('AUTO-REFILL: failed (' + msg + ') — disarmed');
1000
+ if (res.headersSent && !res.destroyed && !res.writableEnded) {
1001
+ await streamPaymentFailureAndRetry(res, walletHash, pendingPayload, '\\n\\nāŒ Auto-refill payment failed: ' + msg + '\\n\\n');
1002
+ } else if (!res.headersSent) {
1003
+ try {
1004
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1005
+ res.end(JSON.stringify({ error: 'Payment failed', message: msg }));
1006
+ } catch (e) { log('Failed to write auto-refill error JSON: ' + e.message); }
1007
+ } else {
1008
+ log('Cannot send auto-refill failure — response already ended or destroyed');
1009
+ }
1010
+ };
1011
+
1012
+ if (err) {
1013
+ return await disarmAndFail(err.message);
1014
+ }
1015
+ if (!responseJson.success) {
1016
+ const msg = responseJson.timeout
1017
+ ? 'response timed out after payment — check credits with \\'credits\\' and retry'
1018
+ : (responseJson.error || 'Unknown payment error');
1019
+ return await disarmAndFail(msg);
1020
+ }
1021
+
1022
+ // Payment succeeded — count it toward the budget.
1023
+ const spent = Math.min(refill.spentMinutes + refill.minutes, refill.maxMinutes);
1024
+ const exhausted = spent >= refill.maxMinutes;
1025
+ refill.spentMinutes = spent;
1026
+ refill.lastRefillAt = new Date().toISOString();
1027
+ if (exhausted) {
1028
+ refill.enabled = false;
1029
+ }
1030
+ saveAutoRefillState(refill);
1031
+
1032
+ const chatCompletion = responseJson?.data || responseJson;
1033
+ let wasStreaming = false;
1034
+ try {
1035
+ wasStreaming = JSON.parse(pendingPayload.body).stream === true;
1036
+ } catch (e) {}
1037
+
1038
+ log('AUTO-REFILL: payment ok, spent=' + spent + ' min, exhausted=' + exhausted);
1039
+
1040
+ if (res.destroyed || res.writableEnded) {
1041
+ log('Auto-refill payment succeeded but response connection is gone — cannot deliver chat response');
1042
+ return;
1043
+ }
1044
+
1045
+ let note = '⚔ Auto-refill active: bought a ' + refill.minutes + '-minute plan for ' + (pendingPayload.displayName || pendingPayload.modelId || 'this model') + ' (' + spent + '/' + refill.maxMinutes + ' min budget used)';
1046
+ if (exhausted) {
1047
+ note += ' — **budget reached, auto-refill is now OFF**. Reply to buy more manually if you need to keep going.';
1048
+ }
1049
+ note += '. Generating your response...\\n\\n';
1050
+
1051
+ if (wasStreaming) {
1052
+ try {
1053
+ jsonToSse(res, chatCompletion, { prependContent: PROXY_MARKER + '\\n' + note });
1054
+ } catch (e) {
1055
+ log('auto-refill jsonToSse threw: ' + e.message);
1056
+ }
1057
+ } else {
1058
+ try {
1059
+ if (!res.headersSent) {
1060
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1061
+ }
1062
+ res.end(JSON.stringify(chatCompletion));
1063
+ } catch (e) {
1064
+ log('auto-refill non-stream send failed: ' + e.message);
1065
+ }
1066
+ }
1067
+ });
1068
+ }
1069
+
819
1070
  // Extract the last user message content from a chat payload
820
1071
  function getLastUserMessageContent(body) {
821
1072
  try {
@@ -1167,20 +1418,43 @@ const server = http.createServer(async (req, res) => {
1167
1418
  await handlePricingCommand(res);
1168
1419
  return;
1169
1420
  }
1421
+
1422
+ // LIFT payment option: user typed "LIFT" (optionally followed by a
1423
+ // tier number, e.g. "LIFT 2"). Defaults to the first tier.
1424
+ let paymentMethod = 'bch';
1425
+ if (timeCmd === 'lift' || (timeCmd && /^lift[\s]+\\d+$/.test(timeCmd))) {
1426
+ paymentMethod = 'lift';
1427
+ }
1428
+ let liftTierIndex = -1;
1429
+ if (timeCmd && /^lift[\s]+\\d+$/.test(timeCmd)) {
1430
+ const liftNum = parseInt(timeCmd.split(/\\s+/)[1], 10);
1431
+ if (!isNaN(liftNum) && liftNum >= 1 && liftNum <= pendingPayload.tiers.length) {
1432
+ liftTierIndex = liftNum - 1;
1433
+ }
1434
+ }
1435
+
1170
1436
  let selectedIndex = -1;
1171
1437
 
1172
- // Try to parse user input as a number (1-based)
1173
- const num = parseInt(userInput, 10);
1174
- if (!isNaN(num) && num >= 1 && num <= pendingPayload.tiers.length) {
1175
- selectedIndex = num - 1;
1438
+ // "LIFT" alone selects the first (cheapest) tier paid with LIFT;
1439
+ // "LIFT N" selects tier N.
1440
+ if (paymentMethod === 'lift' && liftTierIndex >= 0) {
1441
+ selectedIndex = liftTierIndex;
1442
+ } else if (paymentMethod === 'lift') {
1443
+ selectedIndex = 0;
1176
1444
  } else {
1177
- // Try to match by duration minutes
1178
- for (let i = 0; i < pendingPayload.tiers.length; i++) {
1179
- if (userInput === String(pendingPayload.tiers[i].minutes) ||
1180
- userInput === pendingPayload.tiers[i].minutes + ' minutes' ||
1181
- userInput === pendingPayload.tiers[i].minutes + ' min') {
1182
- selectedIndex = i;
1183
- break;
1445
+ // Try to parse user input as a number (1-based)
1446
+ const num = parseInt(userInput, 10);
1447
+ if (!isNaN(num) && num >= 1 && num <= pendingPayload.tiers.length) {
1448
+ selectedIndex = num - 1;
1449
+ } else {
1450
+ // Try to match by duration minutes
1451
+ for (let i = 0; i < pendingPayload.tiers.length; i++) {
1452
+ if (userInput === String(pendingPayload.tiers[i].minutes) ||
1453
+ userInput === pendingPayload.tiers[i].minutes + ' minutes' ||
1454
+ userInput === pendingPayload.tiers[i].minutes + ' min') {
1455
+ selectedIndex = i;
1456
+ break;
1457
+ }
1184
1458
  }
1185
1459
  }
1186
1460
  }
@@ -1188,9 +1462,10 @@ const server = http.createServer(async (req, res) => {
1188
1462
  if (selectedIndex >= 0) {
1189
1463
  const selectedTier = pendingPayload.tiers[selectedIndex];
1190
1464
  pendingPayload.durationMinutes = selectedTier.minutes;
1465
+ pendingPayload.paymentMethod = paymentMethod;
1191
1466
  pendingPayload.step = 'processing';
1192
1467
 
1193
- log('Tier selected: ' + selectedTier.minutes + ' min for wallet ' + walletHash?.substring(0, 16) + '...');
1468
+ log('Tier selected: ' + selectedTier.minutes + ' min (' + paymentMethod + ') for wallet ' + walletHash?.substring(0, 16) + '...');
1194
1469
 
1195
1470
  // Build extra headers for payment wrapper
1196
1471
  const extraHeaders = {};
@@ -1198,28 +1473,56 @@ const server = http.createServer(async (req, res) => {
1198
1473
  extraHeaders['X-Model-Id'] = pendingPayload.modelId;
1199
1474
  }
1200
1475
  extraHeaders['X-Duration-Minutes'] = String(selectedTier.minutes);
1476
+ if (paymentMethod === 'lift') {
1477
+ extraHeaders['X-Payment-Method'] = 'lift';
1478
+ }
1201
1479
 
1202
- // Check wallet balance before attempting payment
1203
- const currentBalanceSats = await getWalletBalance();
1204
- if (currentBalanceSats !== null && selectedTier.price_sats && currentBalanceSats < selectedTier.price_sats) {
1205
- log('Insufficient balance for wallet ' + walletHash?.substring(0, 16) + '...: ' + currentBalanceSats + ' sats < ' + selectedTier.price_sats + ' sats needed');
1206
- pendingPayments.delete(walletHash);
1207
- const addr = await getReceivingAddress();
1208
- const neededBch = (selectedTier.price_sats - currentBalanceSats) / 100000000;
1209
- const neededLine = addr ? '\\n\\nšŸ“„ **Fund your wallet:** \\\`' + addr + '\\\`\\nOr run: paytaca receive (in another terminal) for QR code' : '';
1210
- sseLine(res, {
1211
- id: 'balance-err',
1212
- object: 'chat.completion.chunk',
1213
- choices: [{ index: 0, delta: { content: PROXY_MARKER + '\\n\\nāŒ **Insufficient balance** — You have **' + (currentBalanceSats / 100000000).toFixed(8) + ' BCH** but need **' + (selectedTier.price_sats / 100000000).toFixed(8) + ' BCH** for this plan. Top up at least **' + neededBch.toFixed(8) + ' BCH** more.' + neededLine + '\\n\\nType \\\`balance\\\` to re-check or try a different plan:' }, finish_reason: 'stop' }],
1214
- });
1215
- sseLine(res, {
1216
- id: 'balance-err-done',
1217
- object: 'chat.completion.chunk',
1218
- choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
1219
- });
1220
- sseDone(res);
1221
- res.end();
1222
- return;
1480
+ // Check wallet balance before attempting payment — only for BCH.
1481
+ // The LIFT path sells tokens, so no BCH balance is required.
1482
+ if (paymentMethod !== 'lift') {
1483
+ const currentBalanceSats = await getWalletBalance();
1484
+ if (currentBalanceSats !== null && selectedTier.price_sats && currentBalanceSats < selectedTier.price_sats) {
1485
+ log('Insufficient balance for wallet ' + walletHash?.substring(0, 16) + '...: ' + currentBalanceSats + ' sats < ' + selectedTier.price_sats + ' sats needed');
1486
+ pendingPayments.delete(walletHash);
1487
+ const addr = await getReceivingAddress();
1488
+ const neededBch = (selectedTier.price_sats - currentBalanceSats) / 100000000;
1489
+ const neededLine = addr ? '\\n\\nšŸ“„ **Fund your wallet:** \\\`' + addr + '\\\`\\nOr run: paytaca receive (in another terminal) for QR code' : '';
1490
+ sseLine(res, {
1491
+ id: 'balance-err',
1492
+ object: 'chat.completion.chunk',
1493
+ choices: [{ index: 0, delta: { content: PROXY_MARKER + '\\n\\nāŒ **Insufficient balance** — You have **' + (currentBalanceSats / 100000000).toFixed(8) + ' BCH** but need **' + (selectedTier.price_sats / 100000000).toFixed(8) + ' BCH** for this plan. Top up at least **' + neededBch.toFixed(8) + ' BCH** more.' + neededLine + '\\n\\nType \\\`balance\\\` to re-check or try a different plan:' }, finish_reason: 'stop' }],
1494
+ });
1495
+ sseLine(res, {
1496
+ id: 'balance-err-done',
1497
+ object: 'chat.completion.chunk',
1498
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
1499
+ });
1500
+ sseDone(res);
1501
+ res.end();
1502
+ return;
1503
+ }
1504
+ } else {
1505
+ // LIFT path: fail fast if the wallet holds no LIFT tokens.
1506
+ const liftBalanceUnits = await getLiftBalance();
1507
+ if (liftBalanceUnits !== null && liftBalanceUnits <= 0) {
1508
+ log('No LIFT tokens for wallet ' + walletHash?.substring(0, 16) + '...');
1509
+ pendingPayments.delete(walletHash);
1510
+ const addr = await getReceivingAddress();
1511
+ const fundLine = addr ? '\\n\\nšŸ“„ **Add LIFT to your wallet:** \\\`' + addr + '\\\` (send LIFT tokens) or buy LIFT on the Cauldron DEX' : '';
1512
+ sseLine(res, {
1513
+ id: 'lift-err',
1514
+ object: 'chat.completion.chunk',
1515
+ choices: [{ index: 0, delta: { content: PROXY_MARKER + '\\n\\nāŒ **No LIFT tokens** — you need LIFT to pay with tokens. Add LIFT to your wallet, then type a plan number above or \\\`LIFT\\\` again.' + fundLine + '\\n\\nType \\\`balance\\\` to re-check:' }, finish_reason: 'stop' }],
1516
+ });
1517
+ sseLine(res, {
1518
+ id: 'lift-err-done',
1519
+ object: 'chat.completion.chunk',
1520
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
1521
+ });
1522
+ sseDone(res);
1523
+ res.end();
1524
+ return;
1525
+ }
1223
1526
  }
1224
1527
 
1225
1528
  // Keepalive during payment processing
@@ -1236,7 +1539,7 @@ const server = http.createServer(async (req, res) => {
1236
1539
  res.write(': keepalive\\n\\n');
1237
1540
  }, 2000);
1238
1541
 
1239
- runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, async (err, responseJson) => {
1542
+ runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, pendingPayload.paymentMethod || 'bch', async (err, responseJson) => {
1240
1543
  pendingPayments.delete(walletHash);
1241
1544
  clearInterval(keepalive);
1242
1545
 
@@ -1299,7 +1602,18 @@ const server = http.createServer(async (req, res) => {
1299
1602
 
1300
1603
  if (wasStreaming) {
1301
1604
  try {
1302
- jsonToSse(res, chatCompletion, { prependContent: '\\nšŸ’³ Payment successful — generating your response...\\n\\n' });
1605
+ let prepend = '\\nšŸ’³ Payment successful — generating your response...\\n\\n';
1606
+ if (pendingPayload.paymentMethod === 'lift') {
1607
+ const liftPercent = await getLiftDiscountPercent();
1608
+ const selectedTier = (pendingPayload.tiers || []).find((t) => t.minutes === pendingPayload.durationMinutes);
1609
+ if (liftPercent > 0 && selectedTier && selectedTier.price_sats) {
1610
+ const savedBch = (selectedTier.price_sats * (liftPercent / 100) / 100000000).toFixed(8);
1611
+ prepend = '\\nšŸ’³ Payment successful — paid with LIFT (**' + liftPercent + '% off**, saved **' + savedBch + ' BCH**). Generating your response...\\n\\n';
1612
+ } else {
1613
+ prepend = '\\nšŸ’³ Payment successful — paid with LIFT tokens. Generating your response...\\n\\n';
1614
+ }
1615
+ }
1616
+ jsonToSse(res, chatCompletion, { prependContent: prepend });
1303
1617
  } catch (e) { log('jsonToSse threw: ' + e.message); }
1304
1618
  } else {
1305
1619
  try {
@@ -1345,7 +1659,7 @@ const server = http.createServer(async (req, res) => {
1345
1659
  res.write(': keepalive\\n\\n');
1346
1660
  }, 2000);
1347
1661
 
1348
- runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, (err, responseJson) => {
1662
+ runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, 'bch', (err, responseJson) => {
1349
1663
  clearInterval(keepalive);
1350
1664
  if (err) {
1351
1665
  log('paytaca pay failed: ' + err.message);
@@ -1522,6 +1836,42 @@ const server = http.createServer(async (req, res) => {
1522
1836
  });
1523
1837
 
1524
1838
  if (tiers && tiers.length > 0) {
1839
+ // AUTO-REFILL: when armed, buy the configured plan and retry the
1840
+ // request without the interactive tier prompt. Falls through to the
1841
+ // prompt when not armed, model mismatched, budget exhausted, plan
1842
+ // no longer offered, or the wallet cannot fund the refill.
1843
+ const refill = getAutoRefillState();
1844
+ if (refill && autoRefillCanBuy(refill, modelId || requestModel)) {
1845
+ const refillTier = (tiers || []).find((t) => Number(t.minutes) === Number(refill.minutes));
1846
+ if (refillTier && refillTier.price_sats) {
1847
+ if (refill.paymentMethod !== 'lift') {
1848
+ const currentBalanceSats = await getWalletBalance();
1849
+ if (currentBalanceSats === null || currentBalanceSats >= Number(refillTier.price_sats)) {
1850
+ const pp = pendingPayments.get(walletHash);
1851
+ await autoRefillAndRetry(res, walletHash, pp, refill);
1852
+ return;
1853
+ }
1854
+ log('AUTO-REFILL: insufficient balance (' + (currentBalanceSats === null ? 'n/a' : currentBalanceSats) + ' sats < ' + refillTier.price_sats + ') — disarming');
1855
+ refill.enabled = false;
1856
+ saveAutoRefillState(refill);
1857
+ } else {
1858
+ const liftUnits = await getLiftBalance();
1859
+ if (liftUnits !== null && liftUnits > 0) {
1860
+ const pp = pendingPayments.get(walletHash);
1861
+ await autoRefillAndRetry(res, walletHash, pp, refill);
1862
+ return;
1863
+ }
1864
+ log('AUTO-REFILL: no LIFT tokens — disarming');
1865
+ refill.enabled = false;
1866
+ saveAutoRefillState(refill);
1867
+ }
1868
+ } else {
1869
+ log('AUTO-REFILL: configured ' + refill.minutes + '-min plan not offered — disarming');
1870
+ refill.enabled = false;
1871
+ saveAutoRefillState(refill);
1872
+ }
1873
+ }
1874
+
1525
1875
  // New flow: show tier selection prompt. Also tell the user about
1526
1876
  // other models that still have paid credits, so they can switch
1527
1877
  // instead of buying a plan for the currently selected model.
@@ -1615,9 +1965,9 @@ const server = http.createServer(async (req, res) => {
1615
1965
  };
1616
1966
 
1617
1967
  if (isStreaming) {
1618
- forwardStreaming(req, res, body, handleResponse);
1968
+ await forwardStreaming(req, res, body, handleResponse);
1619
1969
  } else {
1620
- forwardToDjango(req, body, handleResponse);
1970
+ await forwardToDjango(req, body, handleResponse);
1621
1971
  }
1622
1972
 
1623
1973
  } catch (err) {
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAonDnC,CAAC"}
1
+ {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAk9DnC,CAAC"}