@paytaca/opencode-plugin 0.1.6 → 0.1.8

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.
@@ -1,937 +0,0 @@
1
- // This file contains the bundled proxy script as a string
2
- // It gets written to ~/.opencode-paytaca/proxy.js at runtime
3
-
4
- export const PROXY_SCRIPT_CONTENT = `#!/usr/bin/env node
5
- /**
6
- * Paytaca AI Proxy
7
- *
8
- * Sits between OpenCode and the Django backend.
9
- * - Auto-starts by OpenCode plugin
10
- * - On 402, returns SSE typewriter loading sequence + synthetic payment prompt
11
- * - Stores pending payments; handles "yes"/"no" approval internally
12
- * - Uses only Node.js built-in modules
13
- *
14
- * Usage: node proxy.js [backend_url] [proxy_port]
15
- * Example: node proxy.js https://api.paytaca.ai 8001
16
- */
17
-
18
- const http = require('http');
19
- const https = require('https');
20
- const { spawn } = require('child_process');
21
- const { Transform } = require('stream');
22
- const fs = require('fs');
23
- const path = require('path');
24
- const os = require('os');
25
-
26
- const PROXY_PORT = parseInt(process.argv[3]) || 8001;
27
- const BACKEND_URL = process.argv[2] || 'https://api.paytaca.ai';
28
- const parsedUrl = new URL(BACKEND_URL);
29
- const DJANGO_HOST = parsedUrl.hostname;
30
- const DJANGO_PORT = parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80);
31
- const REQUester = parsedUrl.protocol === 'https:' ? https : http;
32
-
33
- // Logging setup: write to file instead of console
34
- const LOG_DIR = path.join(os.homedir(), '.opencode-paytaca');
35
- if (!fs.existsSync(LOG_DIR)) {
36
- fs.mkdirSync(LOG_DIR, { recursive: true });
37
- }
38
- const LOG_FILE = path.join(LOG_DIR, 'proxy.log');
39
- const logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });
40
-
41
- function log(message) {
42
- const timestamp = new Date().toISOString();
43
- logStream.write(timestamp + ' [Proxy] ' + message + '\\n');
44
- }
45
-
46
- // Heartbeat monitoring - proxy exits if heartbeat is stale
47
- const HEARTBEAT_FILE = path.join(LOG_DIR, 'heartbeat');
48
- const HEARTBEAT_TIMEOUT = 15000; // 15 seconds
49
-
50
- function checkHeartbeat() {
51
- try {
52
- if (!fs.existsSync(HEARTBEAT_FILE)) {
53
- // No heartbeat file yet, wait a bit
54
- return true;
55
- }
56
- const heartbeat = parseInt(fs.readFileSync(HEARTBEAT_FILE, 'utf8'));
57
- if (heartbeat === 0) {
58
- // Special value: plugin is stopping
59
- log('Heartbeat = 0, shutting down...');
60
- return false;
61
- }
62
- const elapsed = Date.now() - heartbeat;
63
- if (elapsed > HEARTBEAT_TIMEOUT) {
64
- log('Heartbeat stale (' + elapsed + 'ms), shutting down...');
65
- return false;
66
- }
67
- return true;
68
- } catch (err) {
69
- // If we can't read heartbeat, keep running (graceful degradation)
70
- return true;
71
- }
72
- }
73
-
74
- // Heartbeat checker reference (will be started after server creation)
75
- let heartbeatChecker = null;
76
-
77
- // Store pending payment requests per wallet hash
78
- const pendingPayments = new Map();
79
-
80
- // Utility: run shell command and return output
81
- function runCommand(cmd, args = []) {
82
- return new Promise((resolve, reject) => {
83
- const child = spawn(cmd, args, { shell: false });
84
- let stdout = '';
85
- let stderr = '';
86
-
87
- child.stdout.on('data', (data) => { stdout += data.toString(); });
88
- child.stderr.on('data', (data) => { stderr += data.toString(); });
89
-
90
- child.on('close', (code) => {
91
- if (code === 0) resolve(stdout.trim());
92
- else reject(new Error(stderr.trim() || 'Command exited with code ' + code));
93
- });
94
-
95
- child.on('error', (err) => reject(err));
96
- });
97
- }
98
-
99
- // Get paytaca command from environment or default to 'paytaca'
100
- const PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';
101
-
102
- // Utility: check if paytaca CLI exists
103
- async function checkPaytacaCli() {
104
- try {
105
- // Try to run version check
106
- await runCommand(PAYTACA_CMD, ['--version']);
107
- return true;
108
- } catch {
109
- return false;
110
- }
111
- }
112
-
113
- // Utility: get wallet balance in sats
114
- async function getWalletBalance() {
115
- try {
116
- const output = await runCommand(PAYTACA_CMD, ['wallet', 'info']);
117
- const match = output.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);
118
- if (match) {
119
- const bch = parseFloat(match[1]);
120
- return Math.floor(bch * 100000000);
121
- }
122
- return null;
123
- } catch (err) {
124
- log('Failed to get wallet balance: ' + err.message);
125
- return null;
126
- }
127
- }
128
-
129
- // Utility: get receiving address
130
- async function getReceivingAddress() {
131
- try {
132
- const output = await runCommand(PAYTACA_CMD, ['wallet', 'info']);
133
- const match = output.match(/Address:\\s*(bitcoincash:[a-zA-Z0-9]+)/i);
134
- return match ? match[1] : null;
135
- } catch {
136
- return null;
137
- }
138
- }
139
-
140
- // Utility: check if wallet exists
141
- async function checkWallet() {
142
- try {
143
- await runCommand(PAYTACA_CMD, ['wallet', 'info']);
144
- return true;
145
- } catch {
146
- return false;
147
- }
148
- }
149
-
150
- // SSE helper: write a data line
151
- function sseLine(res, data) {
152
- res.write('data: ' + JSON.stringify(data) + '\\n\\n');
153
- }
154
-
155
- // SSE helper: write [DONE]
156
- function sseDone(res) {
157
- res.write('data: [DONE]\\n\\n');
158
- }
159
-
160
- // Build and stream SSE loading sequence + payment prompt
161
- async function streamPaymentPrompt(res, walletHash, isRenewal = false, tokensUsed = 0, tokenLimit = 50000, carryoverDeadline = null) {
162
- res.writeHead(200, {
163
- 'Content-Type': 'text/event-stream',
164
- 'Cache-Control': 'no-cache',
165
- 'X-Payment-Required': 'true',
166
- 'Connection': 'keep-alive',
167
- });
168
-
169
- // Fetch dynamic pricing from backend config
170
- let costPhp = 10.00;
171
- let costBch = '0.00080000';
172
- let costSats = 80000;
173
- let usingDefaultRate = false;
174
-
175
- try {
176
- const configRes = await fetch(BACKEND_URL + '/v1/config');
177
- if (configRes.ok) {
178
- const config = await configRes.json();
179
- costPhp = config.cost_php || 10.00;
180
- costBch = config.cost_bch || '0.00080000';
181
- costSats = config.cost_sats || 80000;
182
- }
183
- } catch (e) {
184
- // Backend unreachable — will warn user below
185
- usingDefaultRate = true;
186
- }
187
-
188
- const baseId = isRenewal ? 'renewal' : 'payment';
189
-
190
- sseLine(res, {
191
- id: baseId + '-1',
192
- object: 'chat.completion.chunk',
193
- created: Math.floor(Date.now() / 1000),
194
- model: 'deepseek-ai/DeepSeek-V4-Flash',
195
- choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],
196
- });
197
-
198
- let balanceStr;
199
- let hasCli, hasWallet, balanceSats;
200
-
201
- if (isRenewal) {
202
- // For renewals, skip the full loading sequence and fetch balance quietly
203
- hasCli = await checkPaytacaCli();
204
- hasWallet = hasCli ? await checkWallet() : false;
205
- balanceSats = hasWallet ? await getWalletBalance() : null;
206
- if (balanceSats !== null) {
207
- balanceStr = (balanceSats / 100000000).toFixed(8) + ' BCH';
208
- } else {
209
- balanceStr = 'Unable to check (try restarting)';
210
- }
211
- } else {
212
- // First-time users: show full loading sequence
213
- sseLine(res, {
214
- id: baseId + '-2',
215
- object: 'chat.completion.chunk',
216
- choices: [{ index: 0, delta: { content: '⏳ Initializing Paytaca AI provider...\\n' }, finish_reason: null }],
217
- });
218
-
219
- hasCli = await checkPaytacaCli();
220
- sseLine(res, {
221
- id: baseId + '-3',
222
- object: 'chat.completion.chunk',
223
- choices: [{ index: 0, delta: { content: 'Checking Paytaca CLI... ' }, finish_reason: null }],
224
- });
225
- sseLine(res, {
226
- id: baseId + '-4',
227
- object: 'chat.completion.chunk',
228
- choices: [{ index: 0, delta: { content: hasCli ? '✅\\n' : '❌ Not found\\n' }, finish_reason: null }],
229
- });
230
-
231
- hasWallet = hasCli ? await checkWallet() : false;
232
- sseLine(res, {
233
- id: baseId + '-5',
234
- object: 'chat.completion.chunk',
235
- choices: [{ index: 0, delta: { content: 'Checking wallet... ' }, finish_reason: null }],
236
- });
237
- sseLine(res, {
238
- id: baseId + '-6',
239
- object: 'chat.completion.chunk',
240
- choices: [{ index: 0, delta: { content: hasWallet ? '✅\\n' : '❌ Not found\\n' }, finish_reason: null }],
241
- });
242
-
243
- balanceSats = hasWallet ? await getWalletBalance() : null;
244
- sseLine(res, {
245
- id: baseId + '-7',
246
- object: 'chat.completion.chunk',
247
- choices: [{ index: 0, delta: { content: 'Fetching balance... ' }, finish_reason: null }],
248
- });
249
-
250
- if (balanceSats !== null) {
251
- balanceStr = (balanceSats / 100000000).toFixed(8) + ' BCH';
252
- sseLine(res, {
253
- id: baseId + '-8',
254
- object: 'chat.completion.chunk',
255
- choices: [{ index: 0, delta: { content: '✅\\n\\n' }, finish_reason: null }],
256
- });
257
- } else {
258
- balanceStr = 'Unable to check (try restarting)';
259
- sseLine(res, {
260
- id: baseId + '-8',
261
- object: 'chat.completion.chunk',
262
- choices: [{ index: 0, delta: { content: '❌\\n\\n' }, finish_reason: null }],
263
- });
264
- }
265
- }
266
-
267
- let promptHeader = isRenewal
268
- ? '💳 Session Expired — Payment Required to Continue\\n\\n'
269
- : '💳 Paytaca AI — Payment Required\\n\\n';
270
-
271
- sseLine(res, {
272
- id: baseId + '-9',
273
- object: 'chat.completion.chunk',
274
- choices: [{ index: 0, delta: { content: promptHeader }, finish_reason: null }],
275
- });
276
-
277
- sseLine(res, {
278
- id: baseId + '-10',
279
- object: 'chat.completion.chunk',
280
- choices: [{ index: 0, delta: { content: 'Cost: ' + costPhp.toFixed(2) + ' PHP (~' + costBch + ' BCH)\\n' }, finish_reason: null }],
281
- });
282
-
283
- if (usingDefaultRate) {
284
- sseLine(res, {
285
- id: baseId + '-10b',
286
- object: 'chat.completion.chunk',
287
- choices: [{ index: 0, delta: { content: '⚠️ Could not reach backend for live pricing. Using default rate.\\n' }, finish_reason: null }],
288
- });
289
- }
290
-
291
- if (isRenewal) {
292
- const unusedTokens = Math.max(0, tokenLimit - tokensUsed);
293
- sseLine(res, {
294
- id: baseId + '-11',
295
- object: 'chat.completion.chunk',
296
- choices: [{ index: 0, delta: { content: 'Previous Session: ' + tokensUsed.toLocaleString() + ' / ' + tokenLimit.toLocaleString() + ' tokens used\\n' }, finish_reason: null }],
297
- });
298
- sseLine(res, {
299
- id: baseId + '-12',
300
- object: 'chat.completion.chunk',
301
- choices: [{ index: 0, delta: { content: 'Unused Tokens Carried Over: +' + unusedTokens.toLocaleString() + '\\n' }, finish_reason: null }],
302
- });
303
- if (carryoverDeadline) {
304
- const minutesLeft = Math.max(0, Math.floor((new Date(carryoverDeadline) - Date.now()) / 60000));
305
- const timeStr = minutesLeft > 0
306
- ? minutesLeft + ' min' + (minutesLeft !== 1 ? 's' : '') + ' remaining'
307
- : 'expired — renew now to keep them';
308
- sseLine(res, {
309
- id: baseId + '-12b',
310
- object: 'chat.completion.chunk',
311
- choices: [{ index: 0, delta: { content: '⏰ Carryover expires in ' + timeStr + '\\n' }, finish_reason: null }],
312
- });
313
- }
314
- }
315
-
316
- sseLine(res, {
317
- id: baseId + '-13',
318
- object: 'chat.completion.chunk',
319
- choices: [{ index: 0, delta: { content: 'Wallet Balance: ' + balanceStr + '\\n' }, finish_reason: null }],
320
- });
321
-
322
- if (balanceSats !== null) {
323
- const affordable = Math.floor(balanceSats / costSats);
324
- sseLine(res, {
325
- id: baseId + '-14',
326
- object: 'chat.completion.chunk',
327
- choices: [{ index: 0, delta: { content: 'You could afford about ~' + affordable + ' sessions\\n\\n' }, finish_reason: null }],
328
- });
329
- }
330
-
331
- if (balanceSats !== null && balanceSats < costSats) {
332
- const addr = await getReceivingAddress();
333
- if (addr) {
334
- sseLine(res, {
335
- id: baseId + '-15',
336
- object: 'chat.completion.chunk',
337
- choices: [{ index: 0, delta: { content: '⚠️ Insufficient balance for a session.\\nFund your wallet: ' + addr + '\\nOr run: paytaca receive (in another terminal) for QR code\\n\\n' }, finish_reason: null }],
338
- });
339
- }
340
- }
341
-
342
- if (balanceSats === null || balanceSats > 0) {
343
- sseLine(res, {
344
- id: baseId + '-16',
345
- object: 'chat.completion.chunk',
346
- choices: [{ index: 0, delta: { content: 'Approve payment? (yes/no)' }, finish_reason: 'stop' }],
347
- });
348
- }
349
-
350
- sseLine(res, {
351
- id: baseId + '-17',
352
- object: 'chat.completion.chunk',
353
- choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
354
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
355
- });
356
-
357
- sseDone(res);
358
- res.end();
359
- }
360
-
361
- // Forward request to Django and return response (buffered, for non-streaming)
362
- function forwardToDjango(req, body, callback) {
363
- const options = {
364
- hostname: DJANGO_HOST,
365
- port: DJANGO_PORT,
366
- path: req.url,
367
- method: req.method,
368
- headers: {
369
- 'Content-Type': req.headers['content-type'] || 'application/json',
370
- 'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',
371
- 'Content-Length': Buffer.byteLength(body),
372
- },
373
- };
374
-
375
- const startTime = Date.now();
376
- log('forwardToDjango -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);
377
-
378
- const djangoReq = REQUester.request(options, (djangoRes) => {
379
- let responseBody = '';
380
- djangoRes.on('data', chunk => { responseBody += chunk; });
381
- djangoRes.on('end', () => {
382
- const elapsed = Date.now() - startTime;
383
- log('Django responded in ' + elapsed + 'ms: status=' + djangoRes.statusCode + ', bodyLen=' + responseBody.length);
384
- callback(null, djangoRes.statusCode, djangoRes.headers, responseBody);
385
- });
386
- });
387
-
388
- djangoReq.setTimeout(30000, () => {
389
- djangoReq.destroy();
390
- callback(new Error('Django request timed out after 30s'));
391
- });
392
-
393
- djangoReq.on('error', (err) => {
394
- log('Django request error: ' + err.message);
395
- callback(err);
396
- });
397
-
398
- djangoReq.write(body);
399
- djangoReq.end();
400
- }
401
-
402
- // Forward streaming request to Django
403
- function forwardStreaming(req, res, body, callback) {
404
- const options = {
405
- hostname: DJANGO_HOST,
406
- port: DJANGO_PORT,
407
- path: req.url,
408
- method: req.method,
409
- headers: {
410
- 'Content-Type': req.headers['content-type'] || 'application/json',
411
- 'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',
412
- 'Content-Length': Buffer.byteLength(body),
413
- },
414
- };
415
-
416
- const startTime = Date.now();
417
- log('forwardStreaming -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);
418
-
419
- const djangoReq = REQUester.request(options, (djangoRes) => {
420
- const elapsed = Date.now() - startTime;
421
- log('Django response started in ' + elapsed + 'ms: status=' + djangoRes.statusCode);
422
-
423
- if (djangoRes.statusCode === 402) {
424
- let responseBody = '';
425
- djangoRes.on('data', chunk => { responseBody += chunk; });
426
- djangoRes.on('end', () => {
427
- callback(null, 402, djangoRes.headers, responseBody);
428
- });
429
- return;
430
- }
431
-
432
- res.writeHead(djangoRes.statusCode, {
433
- 'Content-Type': djangoRes.headers['content-type'] || 'text/event-stream',
434
- 'Cache-Control': 'no-cache',
435
- 'Connection': 'keep-alive',
436
- });
437
-
438
- djangoRes.pipe(res);
439
-
440
- res.on('close', () => {
441
- log('Client connection closed');
442
- });
443
-
444
- djangoRes.on('end', () => {
445
- callback(null, djangoRes.statusCode, {}, '');
446
- });
447
- });
448
-
449
- djangoReq.setTimeout(30000, () => {
450
- djangoReq.destroy();
451
- callback(new Error('Django streaming request timed out after 30s'));
452
- });
453
-
454
- djangoReq.on('error', (err) => {
455
- log('Django streaming request error: ' + err.message);
456
- callback(err);
457
- });
458
-
459
- djangoReq.write(body);
460
- djangoReq.end();
461
- }
462
-
463
- // Force stream=false in body because paytaca pay reads the response as text
464
- function forceNonStreaming(body) {
465
- try {
466
- const data = JSON.parse(body);
467
- data.stream = false;
468
- return JSON.stringify(data);
469
- } catch {
470
- return body;
471
- }
472
- }
473
-
474
- // Convert a chat.completion JSON object to SSE format
475
- function jsonToSse(res, chatCompletion) {
476
- const content = chatCompletion.choices?.[0]?.message?.content || '';
477
- const model = chatCompletion.model || 'deepseek-ai/DeepSeek-V4-Flash';
478
- const created = chatCompletion.created || Math.floor(Date.now() / 1000);
479
-
480
-
481
- try {
482
- res.writeHead(200, {
483
- 'Content-Type': 'text/event-stream',
484
- 'Cache-Control': 'no-cache',
485
- 'Connection': 'keep-alive',
486
- });
487
- } catch (e) {
488
- return;
489
- }
490
-
491
- try {
492
- sseLine(res, {
493
- id: 'chatcmpl-1',
494
- object: 'chat.completion.chunk',
495
- created,
496
- model,
497
- choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],
498
- });
499
- } catch (e) {
500
- }
501
-
502
- const chunkSize = 20;
503
- let chunksWritten = 0;
504
- for (let i = 0; i < content.length; i += chunkSize) {
505
- try {
506
- sseLine(res, {
507
- id: 'chatcmpl-' + (i + 2),
508
- object: 'chat.completion.chunk',
509
- created,
510
- model,
511
- choices: [{ index: 0, delta: { content: content.slice(i, i + chunkSize) }, finish_reason: null }],
512
- });
513
- chunksWritten++;
514
- } catch (e) {
515
- break;
516
- }
517
- }
518
-
519
- try {
520
- sseLine(res, {
521
- id: 'chatcmpl-done',
522
- object: 'chat.completion.chunk',
523
- created,
524
- model,
525
- choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
526
- usage: chatCompletion.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
527
- });
528
- } catch (e) {
529
- }
530
-
531
- try {
532
- sseDone(res);
533
- } catch (e) {
534
- }
535
-
536
- try {
537
- res.end();
538
- } catch (e) {
539
- }
540
- }
541
-
542
- // Run paytaca pay internally and return the response
543
- function runPaytacaPay(djangoUrl, body, walletHash, callback) {
544
- const url = djangoUrl + '/chat/completions?wallet_hash=' + encodeURIComponent(walletHash || '');
545
- const payBody = forceNonStreaming(body);
546
-
547
- // Write body to a temp file to avoid CLI arg length limits
548
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-pay-'));
549
- const bodyFile = path.join(tmpDir, 'body.json');
550
- const configFile = path.join(tmpDir, 'config.json');
551
-
552
- try {
553
- fs.writeFileSync(bodyFile, payBody, 'utf8');
554
- } catch (err) {
555
- return callback(new Error('Failed to write temp body file: ' + err.message));
556
- }
557
-
558
- const config = {
559
- url,
560
- method: 'POST',
561
- headers: { 'Content-Type': 'application/json' },
562
- bodyFile,
563
- confirmed: true,
564
- };
565
-
566
- try {
567
- fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');
568
- } catch (err) {
569
- return callback(new Error('Failed to write temp config file: ' + err.message));
570
- }
571
-
572
- // Path to the wrapper script
573
- const wrapperScript = path.join(LOG_DIR, 'paytaca-pay-wrapper.mjs');
574
- log('Running paytaca pay via wrapper script...');
575
-
576
- const child = spawn('node', [wrapperScript, configFile], { shell: false });
577
- let stdout = '';
578
- let stderr = '';
579
-
580
- child.stdout.on('data', (data) => {
581
- stdout += data.toString();
582
- });
583
- child.stderr.on('data', (data) => {
584
- stderr += data.toString();
585
- });
586
-
587
- child.on('close', (code) => {
588
- // Clean up temp files
589
- try {
590
- fs.unlinkSync(bodyFile);
591
- fs.unlinkSync(configFile);
592
- fs.rmdirSync(tmpDir);
593
- } catch {}
594
-
595
- if (code === 0) {
596
- try {
597
- const responseJson = JSON.parse(stdout.trim());
598
- callback(null, responseJson);
599
- } catch (err) {
600
- callback(new Error('Could not parse paytaca pay response: ' + err.message));
601
- }
602
- } else {
603
- callback(new Error(stderr.trim() || 'paytaca pay wrapper exited with code ' + code));
604
- }
605
- });
606
-
607
- child.on('error', (err) => {
608
- // Clean up temp files on error
609
- try {
610
- fs.unlinkSync(bodyFile);
611
- fs.unlinkSync(configFile);
612
- fs.rmdirSync(tmpDir);
613
- } catch {}
614
- callback(new Error('Failed to run paytaca pay wrapper: ' + err.message));
615
- });
616
- }
617
-
618
- // Extract the last user message content from a chat payload
619
- function getLastUserMessageContent(body) {
620
- try {
621
- const data = JSON.parse(body);
622
- const messages = data.messages || [];
623
- for (let i = messages.length - 1; i >= 0; i--) {
624
- if (messages[i].role === 'user') {
625
- const content = messages[i].content;
626
- if (Array.isArray(content)) {
627
- const parts = [];
628
- for (const part of content) {
629
- if (part && typeof part === 'object' && part.type === 'text') {
630
- parts.push(part.text || '');
631
- } else if (typeof part === 'string') {
632
- parts.push(part);
633
- } else {
634
- parts.push(JSON.stringify(part));
635
- }
636
- }
637
- return parts.join('').trim().toLowerCase();
638
- }
639
- return String(content || '').trim().toLowerCase();
640
- }
641
- }
642
- return '';
643
- } catch {
644
- return '';
645
- }
646
- }
647
-
648
- // Main proxy server
649
- const server = http.createServer(async (req, res) => {
650
- // Enable CORS
651
- res.setHeader('Access-Control-Allow-Origin', '*');
652
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
653
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Wallet-Hash, Authorization');
654
-
655
- if (req.method === 'OPTIONS') {
656
- res.writeHead(200);
657
- res.end();
658
- return;
659
- }
660
-
661
- // Discovery endpoint - fetch from backend to get actual config
662
- if (req.url === '/v1/config' && req.method === 'GET') {
663
- try {
664
- const backendConfig = await fetch(BACKEND_URL + '/v1/config');
665
- if (backendConfig.ok) {
666
- const config = await backendConfig.json();
667
- // Add proxy-specific info
668
- config.proxy_url = 'http://localhost:' + PROXY_PORT + '/v1';
669
- res.writeHead(200, { 'Content-Type': 'application/json' });
670
- res.end(JSON.stringify(config));
671
- return;
672
- }
673
- } catch (err) {
674
- log('Failed to fetch backend config: ' + err.message);
675
- }
676
-
677
- // Fallback to static values if backend unavailable
678
- res.writeHead(200, { 'Content-Type': 'application/json' });
679
- res.end(JSON.stringify({
680
- proxy_url: 'http://localhost:' + PROXY_PORT + '/v1',
681
- django_url: BACKEND_URL + '/v1',
682
- cost_sats: 6000,
683
- cost_bch: '0.00006',
684
- payment_address: '',
685
- session_duration_minutes: 5,
686
- token_limit: 50000,
687
- context_retention_hours: 2,
688
- }));
689
- return;
690
- }
691
-
692
- // All other endpoints — read body and forward to Django
693
- let body = '';
694
- req.on('data', chunk => { body += chunk; });
695
- req.on('end', async () => {
696
- try {
697
- const walletHash = req.headers['x-wallet-hash'];
698
- const lastContent = getLastUserMessageContent(body);
699
-
700
- // DEBUG: Log full body and parsed content
701
-
702
- log('Request received: wallet=' + (walletHash?.substring(0, 16) || 'none') + '..., bodyLen=' + body.length + ', pending=' + pendingPayments.has(walletHash));
703
-
704
- // Guard: wallet hash is required for payment flow
705
- if (!walletHash) {
706
- res.writeHead(400, { 'Content-Type': 'application/json' });
707
- res.end(JSON.stringify({ error: 'X-Wallet-Hash header required' }));
708
- return;
709
- }
710
-
711
- // Check if there's a pending payment for this wallet
712
- const pendingPayload = pendingPayments.get(walletHash);
713
-
714
-
715
- if (pendingPayload) {
716
- // User responded to a payment prompt
717
- if (lastContent === 'yes') {
718
- // User approved — run paytaca pay with the stored original payload
719
- log('Payment approved by wallet ' + walletHash?.substring(0, 16) + '...');
720
- pendingPayments.delete(walletHash);
721
-
722
- runPaytacaPay(BACKEND_URL + '/v1', pendingPayload, walletHash, (err, responseJson) => {
723
- if (err) {
724
- log('paytaca pay failed: ' + err.message);
725
- res.writeHead(500, { 'Content-Type': 'application/json' });
726
- res.end(JSON.stringify({
727
- error: 'Payment failed',
728
- message: err.message,
729
- details: 'Please check your wallet balance and try again.'
730
- }));
731
- return;
732
- }
733
-
734
-
735
- // Check if the response indicates success
736
- if (!responseJson.success) {
737
- res.writeHead(responseJson.status || 500, { 'Content-Type': 'application/json' });
738
- res.end(JSON.stringify({
739
- error: 'Payment failed',
740
- message: responseJson.error,
741
- details: 'Payment was not successful. Please check your balance and try again.'
742
- }));
743
- return;
744
- }
745
-
746
- const chatCompletion = responseJson?.data || responseJson;
747
- if (chatCompletion.choices) {
748
- }
749
-
750
- let wasStreaming = false;
751
- try {
752
- wasStreaming = JSON.parse(pendingPayload).stream === true;
753
- } catch {}
754
-
755
- log('paytaca pay succeeded. Returning chat response.');
756
-
757
- if (wasStreaming) {
758
- try {
759
- jsonToSse(res, chatCompletion);
760
- } catch (e) {
761
- }
762
- } else {
763
- try {
764
- res.writeHead(200, { 'Content-Type': 'application/json' });
765
- res.end(JSON.stringify(chatCompletion));
766
- } catch (e) {
767
- }
768
- }
769
- });
770
- return;
771
-
772
- } else if (lastContent === 'no') {
773
- // User declined
774
- log('Payment declined by wallet ' + walletHash?.substring(0, 16) + '...');
775
- pendingPayments.delete(walletHash);
776
-
777
- const addr = await getReceivingAddress();
778
- const fundMsg = addr
779
- ? 'Fund your wallet: ' + addr
780
- : 'You can fund your wallet by running: paytaca receive';
781
-
782
- const declineCompletion = {
783
- id: 'payment-declined',
784
- object: 'chat.completion',
785
- created: Math.floor(Date.now() / 1000),
786
- model: 'deepseek-ai/DeepSeek-V4-Flash',
787
- choices: [{
788
- index: 0,
789
- message: {
790
- role: 'assistant',
791
- content: 'Payment declined. Chat cannot continue without funding.\\n\\n' + fundMsg,
792
- },
793
- finish_reason: 'stop',
794
- }],
795
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
796
- };
797
- jsonToSse(res, declineCompletion);
798
- return;
799
-
800
- } else {
801
- log('New message while payment pending for wallet ' + walletHash?.substring(0, 16) + '...');
802
- }
803
- }
804
-
805
- let isStreaming = true;
806
- try { isStreaming = JSON.parse(body).stream !== false; } catch {}
807
-
808
- const handleResponse = async (err, statusCode, headers, responseBody) => {
809
- if (err) {
810
- if (!res.headersSent) {
811
- log('Django connection error: ' + err.message);
812
- res.writeHead(502, { 'Content-Type': 'application/json' });
813
- res.end(JSON.stringify({ error: 'Backend unavailable', details: err.message }));
814
- }
815
- return;
816
- }
817
-
818
- if (statusCode === 402) {
819
- log('402 intercepted for wallet ' + walletHash?.substring(0, 16) + '...');
820
- pendingPayments.set(walletHash, body);
821
-
822
- // Check session status to determine if this is a renewal
823
- let isRenewal = false;
824
- let tokensUsed = 0;
825
- let tokenLimit = 50000;
826
- let carryoverDeadline = null;
827
-
828
- try {
829
- const statusResponse = await new Promise((resolve, reject) => {
830
- const statusReq = REQUester.get({
831
- hostname: DJANGO_HOST,
832
- port: DJANGO_PORT,
833
- path: '/v1/wallet/status',
834
- headers: { 'X-Wallet-Hash': walletHash }
835
- }, (res) => {
836
- let data = '';
837
- res.on('data', chunk => data += chunk);
838
- res.on('end', () => {
839
- try {
840
- resolve(JSON.parse(data));
841
- } catch {
842
- resolve({});
843
- }
844
- });
845
- });
846
- statusReq.on('error', reject);
847
- statusReq.setTimeout(5000, () => reject(new Error('timeout')));
848
- });
849
-
850
- if (statusResponse) {
851
- tokensUsed = statusResponse.tokens_used || 0;
852
- tokenLimit = statusResponse.token_limit || 50000;
853
- carryoverDeadline = statusResponse.carryover_deadline || null;
854
-
855
- const hasExpiredSession = !statusResponse.session_active && tokensUsed > 0;
856
- const carryoverStillValid = (statusResponse.carryover_remaining_minutes || 0) > 0;
857
-
858
- // Renewal if: (1) session active but tokens exhausted, OR (2) session expired with valid carryover
859
- isRenewal = (statusResponse.session_active && tokensUsed >= tokenLimit) ||
860
- (hasExpiredSession && carryoverStillValid);
861
- }
862
- } catch (err) {
863
- log('Failed to check session status: ' + err.message);
864
- }
865
-
866
- await streamPaymentPrompt(res, walletHash, isRenewal, tokensUsed, tokenLimit, carryoverDeadline);
867
- } else {
868
- if (res.headersSent) {
869
- log('Streaming response completed and already sent');
870
- pendingPayments.delete(walletHash);
871
- return;
872
- }
873
-
874
- log('Forwarding normal response to OpenCode: status=' + statusCode + ', bodyLen=' + responseBody.length);
875
- pendingPayments.delete(walletHash);
876
- res.writeHead(statusCode, {
877
- 'Content-Type': headers['content-type'] || 'application/json',
878
- });
879
- res.end(responseBody);
880
- }
881
- };
882
-
883
- if (isStreaming) {
884
- forwardStreaming(req, res, body, handleResponse);
885
- } else {
886
- forwardToDjango(req, body, handleResponse);
887
- }
888
-
889
- } catch (err) {
890
- log('Error: ' + err.message);
891
- res.writeHead(500, { 'Content-Type': 'application/json' });
892
- res.end(JSON.stringify({ error: 'Internal proxy error' }));
893
- }
894
- });
895
- });
896
-
897
- server.on('error', (err) => {
898
- if (err.code === 'EADDRINUSE') {
899
- log('Port ' + PROXY_PORT + ' is already in use. Another proxy instance may be running.');
900
- log('Exiting cleanly (code 0) so the plugin can detect the existing proxy.');
901
- process.exit(0);
902
- }
903
- log('Server error: ' + err.message);
904
- process.exit(1);
905
- });
906
-
907
- server.listen(PROXY_PORT, () => {
908
- log('Paytaca AI Proxy running on http://localhost:' + PROXY_PORT);
909
- log('Forwarding to Django at ' + BACKEND_URL);
910
- log('Discovery: http://localhost:' + PROXY_PORT + '/v1/config');
911
- log('Managed by OpenCode plugin');
912
- });
913
-
914
- // Start heartbeat checker after server is created
915
- heartbeatChecker = setInterval(() => {
916
- if (!checkHeartbeat()) {
917
- clearInterval(heartbeatChecker);
918
- log('Closing server due to missing heartbeat');
919
- server.close(() => {
920
- process.exit(0);
921
- });
922
- // Force exit after 2 seconds if graceful shutdown fails
923
- setTimeout(() => process.exit(0), 2000);
924
- }
925
- }, 5000);
926
-
927
- // Graceful shutdown
928
- process.on('SIGTERM', () => {
929
- log('Shutting down proxy...');
930
- server.close(() => process.exit(0));
931
- });
932
-
933
- process.on('SIGINT', () => {
934
- log('Shutting down proxy...');
935
- server.close(() => process.exit(0));
936
- });
937
- `;