@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.
- package/README.md +30 -1
- package/dist/bundled/mcp.d.ts +1 -1
- package/dist/bundled/mcp.d.ts.map +1 -1
- package/dist/bundled/mcp.js +251 -23
- package/dist/bundled/mcp.js.map +1 -1
- package/dist/bundled/proxy.d.ts +1 -1
- package/dist/bundled/proxy.d.ts.map +1 -1
- package/dist/bundled/proxy.js +393 -43
- package/dist/bundled/proxy.js.map +1 -1
- package/dist/bundled/wrapper.d.ts +1 -1
- package/dist/bundled/wrapper.d.ts.map +1 -1
- package/dist/bundled/wrapper.js +123 -3
- package/dist/bundled/wrapper.js.map +1 -1
- package/dist/credits.d.ts +9 -0
- package/dist/credits.d.ts.map +1 -0
- package/dist/credits.js +120 -0
- package/dist/credits.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- package/dist/index.js.map +1 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +6 -4
- package/dist/proxy.js.map +1 -1
- package/package.json +1 -1
package/dist/bundled/proxy.js
CHANGED
|
@@ -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:
|
|
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 + '),
|
|
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
|
-
//
|
|
1173
|
-
|
|
1174
|
-
if (
|
|
1175
|
-
selectedIndex =
|
|
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
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
|
|
1204
|
-
if (
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
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
|
|
1
|
+
{"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAk9DnC,CAAC"}
|