@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.
@@ -47,7 +47,7 @@ function log(message) {
47
47
 
48
48
  // Heartbeat monitoring - proxy exits if heartbeat is stale
49
49
  const HEARTBEAT_FILE = path.join(LOG_DIR, 'heartbeat');
50
- const HEARTBEAT_TIMEOUT = 15000; // 15 seconds
50
+ const HEARTBEAT_TIMEOUT = 300000; // 5 minutes
51
51
 
52
52
  function checkHeartbeat() {
53
53
  try {
@@ -77,6 +77,8 @@ function checkHeartbeat() {
77
77
  let heartbeatChecker = null;
78
78
 
79
79
  // Store pending payment requests per wallet hash
80
+ // Each entry: { body, modelId, displayName, durationMinutes, tiers[], step }
81
+ // step: 'tier_select' (user must pick a tier) or 'approval' (yes/no)
80
82
  const pendingPayments = new Map();
81
83
 
82
84
  // Utility: run shell command and return output
@@ -149,6 +151,17 @@ async function checkWallet() {
149
151
  }
150
152
  }
151
153
 
154
+ // Format seconds as MM:SS or HH:MM:SS
155
+ function formatDuration(totalSeconds) {
156
+ const hours = Math.floor(totalSeconds / 3600);
157
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
158
+ const secs = totalSeconds % 60;
159
+ if (hours > 0) {
160
+ return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
161
+ }
162
+ return minutes + ':' + String(secs).padStart(2, '0');
163
+ }
164
+
152
165
  // SSE helper: write a data line
153
166
  function sseLine(res, data) {
154
167
  res.write('data: ' + JSON.stringify(data) + '\\n\\n');
@@ -159,8 +172,116 @@ function sseDone(res) {
159
172
  res.write('data: [DONE]\\n\\n');
160
173
  }
161
174
 
175
+ // Build and stream SSE tier selection prompt
176
+ async function streamTierSelectionPrompt(res, walletHash, modelName, tiers) {
177
+ res.writeHead(200, {
178
+ 'Content-Type': 'text/event-stream',
179
+ 'Cache-Control': 'no-cache',
180
+ 'X-Payment-Required': 'true',
181
+ 'Connection': 'keep-alive',
182
+ });
183
+
184
+ sseLine(res, {
185
+ id: 'tier-1',
186
+ object: 'chat.completion.chunk',
187
+ created: Math.floor(Date.now() / 1000),
188
+ model: modelName,
189
+ choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],
190
+ });
191
+
192
+ // Loading sequence
193
+ sseLine(res, {
194
+ id: 'tier-2',
195
+ object: 'chat.completion.chunk',
196
+ choices: [{ index: 0, delta: { content: '⏳ Initializing Paytaca AI provider...\\n' }, finish_reason: null }],
197
+ });
198
+
199
+ const hasCli = await checkPaytacaCli();
200
+ sseLine(res, {
201
+ id: 'tier-3',
202
+ object: 'chat.completion.chunk',
203
+ choices: [{ index: 0, delta: { content: 'Checking Paytaca CLI... ' }, finish_reason: null }],
204
+ });
205
+ sseLine(res, {
206
+ id: 'tier-4',
207
+ object: 'chat.completion.chunk',
208
+ choices: [{ index: 0, delta: { content: hasCli ? '✅\\n' : '❌ Not found\\n' }, finish_reason: null }],
209
+ });
210
+
211
+ const hasWallet = hasCli ? await checkWallet() : false;
212
+ sseLine(res, {
213
+ id: 'tier-5',
214
+ object: 'chat.completion.chunk',
215
+ choices: [{ index: 0, delta: { content: 'Checking wallet... ' }, finish_reason: null }],
216
+ });
217
+ sseLine(res, {
218
+ id: 'tier-6',
219
+ object: 'chat.completion.chunk',
220
+ choices: [{ index: 0, delta: { content: hasWallet ? '✅\\n' : '❌ Not found\\n' }, finish_reason: null }],
221
+ });
222
+
223
+ const balanceSats = hasWallet ? await getWalletBalance() : null;
224
+ sseLine(res, {
225
+ id: 'tier-7',
226
+ object: 'chat.completion.chunk',
227
+ choices: [{ index: 0, delta: { content: 'Fetching balance... ' }, finish_reason: null }],
228
+ });
229
+
230
+ let balanceStr;
231
+ if (balanceSats !== null) {
232
+ const bch = (balanceSats / 100000000).toFixed(8);
233
+ balanceStr = bch + ' BCH';
234
+ sseLine(res, {
235
+ id: 'tier-8',
236
+ object: 'chat.completion.chunk',
237
+ choices: [{ index: 0, delta: { content: '✅ — ' + balanceStr + '\\n\\n' }, finish_reason: null }],
238
+ });
239
+ } else {
240
+ balanceStr = 'Unable to check';
241
+ sseLine(res, {
242
+ id: 'tier-8',
243
+ object: 'chat.completion.chunk',
244
+ choices: [{ index: 0, delta: { content: '\\n' + balanceStr + '\\n\\n' }, finish_reason: null }],
245
+ });
246
+ }
247
+
248
+ // Tier selection
249
+ sseLine(res, {
250
+ id: 'tier-9',
251
+ object: 'chat.completion.chunk',
252
+ choices: [{ index: 0, delta: { content: '💳 Select a plan for ' + (modelName || 'AI Model') + '\\n\\n' }, finish_reason: null }],
253
+ });
254
+
255
+ for (let i = 0; i < tiers.length; i++) {
256
+ const tier = tiers[i];
257
+ const bchAmount = (tier.price_sats / 100000000).toFixed(8);
258
+ const label = String(i + 1) + '️⃣ ';
259
+ sseLine(res, {
260
+ id: 'tier-10-' + i,
261
+ object: 'chat.completion.chunk',
262
+ choices: [{ index: 0, delta: { content: label + tier.minutes + ' minutes — ₱' + tier.price_php.toFixed(2) + ' (' + bchAmount + ' BCH)\\n' }, finish_reason: null }],
263
+ });
264
+ }
265
+
266
+ sseLine(res, {
267
+ id: 'tier-11',
268
+ object: 'chat.completion.chunk',
269
+ choices: [{ index: 0, delta: { content: '\\nEnter a number (1-' + tiers.length + '), e.g. type ' + tiers[0].minutes + ':' }, finish_reason: 'stop' }],
270
+ });
271
+
272
+ sseLine(res, {
273
+ id: 'tier-12',
274
+ object: 'chat.completion.chunk',
275
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
276
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
277
+ });
278
+
279
+ sseDone(res);
280
+ res.end();
281
+ }
282
+
162
283
  // Build and stream SSE loading sequence + payment prompt
163
- async function streamPaymentPrompt(res, walletHash, isRenewal = false, tokensUsed = 0, tokenLimit = 50000, carryoverDeadline = null) {
284
+ async function streamPaymentPrompt(res, walletHash, isRenewal = false, tokensUsed = 0, tokenLimit = 50000, timeRemainingSeconds = 0) {
164
285
  res.writeHead(200, {
165
286
  'Content-Type': 'text/event-stream',
166
287
  'Cache-Control': 'no-cache',
@@ -193,7 +314,7 @@ async function streamPaymentPrompt(res, walletHash, isRenewal = false, tokensUse
193
314
  id: baseId + '-1',
194
315
  object: 'chat.completion.chunk',
195
316
  created: Math.floor(Date.now() / 1000),
196
- model: 'deepseek-ai/DeepSeek-V4-Flash',
317
+ model: 'deepseek/deepseek-v4-flash',
197
318
  choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],
198
319
  });
199
320
 
@@ -291,7 +412,8 @@ async function streamPaymentPrompt(res, walletHash, isRenewal = false, tokensUse
291
412
  }
292
413
 
293
414
  if (isRenewal) {
294
- const unusedTokens = Math.max(0, tokenLimit - tokensUsed);
415
+ const usedMinutes = Math.round(timeRemainingSeconds / 60);
416
+ const remainingAttr = usedMinutes > 0 ? usedMinutes + ' min remaining' : 'depleted';
295
417
  sseLine(res, {
296
418
  id: baseId + '-11',
297
419
  object: 'chat.completion.chunk',
@@ -300,19 +422,8 @@ async function streamPaymentPrompt(res, walletHash, isRenewal = false, tokensUse
300
422
  sseLine(res, {
301
423
  id: baseId + '-12',
302
424
  object: 'chat.completion.chunk',
303
- choices: [{ index: 0, delta: { content: 'Unused Tokens Carried Over: +' + unusedTokens.toLocaleString() + '\\n' }, finish_reason: null }],
425
+ choices: [{ index: 0, delta: { content: ' Time Credits: ' + remainingAttr + '\\n' }, finish_reason: null }],
304
426
  });
305
- if (carryoverDeadline) {
306
- const minutesLeft = Math.max(0, Math.floor((new Date(carryoverDeadline) - Date.now()) / 60000));
307
- const timeStr = minutesLeft > 0
308
- ? minutesLeft + ' min' + (minutesLeft !== 1 ? 's' : '') + ' remaining'
309
- : 'expired — renew now to keep them';
310
- sseLine(res, {
311
- id: baseId + '-12b',
312
- object: 'chat.completion.chunk',
313
- choices: [{ index: 0, delta: { content: '⏰ Carryover expires in ' + timeStr + '\\n' }, finish_reason: null }],
314
- });
315
- }
316
427
  }
317
428
 
318
429
  sseLine(res, {
@@ -476,7 +587,7 @@ function forceNonStreaming(body) {
476
587
  // Convert a chat.completion JSON object to SSE format
477
588
  function jsonToSse(res, chatCompletion) {
478
589
  const content = chatCompletion.choices?.[0]?.message?.content || '';
479
- const model = chatCompletion.model || 'deepseek-ai/DeepSeek-V4-Flash';
590
+ const model = chatCompletion.model || chatCompletion.model_id || 'deepseek/deepseek-v4-flash';
480
591
  const created = chatCompletion.created || Math.floor(Date.now() / 1000);
481
592
 
482
593
 
@@ -542,7 +653,7 @@ function jsonToSse(res, chatCompletion) {
542
653
  }
543
654
 
544
655
  // Run paytaca pay internally and return the response
545
- function runPaytacaPay(djangoUrl, body, walletHash, callback) {
656
+ function runPaytacaPay(djangoUrl, body, walletHash, extraHeaders, callback) {
546
657
  const url = djangoUrl + '/chat/completions?wallet_hash=' + encodeURIComponent(walletHash || '');
547
658
  const payBody = forceNonStreaming(body);
548
659
 
@@ -560,7 +671,7 @@ function runPaytacaPay(djangoUrl, body, walletHash, callback) {
560
671
  const config = {
561
672
  url,
562
673
  method: 'POST',
563
- headers: { 'Content-Type': 'application/json' },
674
+ headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders || {}),
564
675
  bodyFile,
565
676
  confirmed: true,
566
677
  };
@@ -652,7 +763,7 @@ const server = http.createServer(async (req, res) => {
652
763
  // Enable CORS
653
764
  res.setHeader('Access-Control-Allow-Origin', '*');
654
765
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
655
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Wallet-Hash, Authorization');
766
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Wallet-Hash, X-Model-Id, X-Duration-Minutes, Payment-Signature, Authorization');
656
767
 
657
768
  if (req.method === 'OPTIONS') {
658
769
  res.writeHead(200);
@@ -681,11 +792,22 @@ const server = http.createServer(async (req, res) => {
681
792
  res.end(JSON.stringify({
682
793
  proxy_url: 'http://localhost:' + PROXY_PORT + '/v1',
683
794
  django_url: BACKEND_URL + '/v1',
684
- cost_sats: 6000,
685
- cost_bch: '0.00006',
686
795
  payment_address: '',
687
- session_duration_minutes: 5,
688
- token_limit: 50000,
796
+ default_model: 'deepseek/deepseek-v4-flash',
797
+ default_duration_minutes: 30,
798
+ models: [
799
+ {
800
+ id: 'deepseek/deepseek-v4-flash',
801
+ object: 'model',
802
+ display_name: 'DeepSeek V4 Flash',
803
+ provider: 'openrouter',
804
+ price_tiers: [
805
+ { minutes: 10, price_php: 5.0, price_sats: 45000 },
806
+ { minutes: 30, price_php: 12.0, price_sats: 108000 },
807
+ { minutes: 60, price_php: 20.0, price_sats: 180000 },
808
+ ],
809
+ },
810
+ ],
689
811
  context_retention_hours: 2,
690
812
  }));
691
813
  return;
@@ -715,13 +837,131 @@ const server = http.createServer(async (req, res) => {
715
837
 
716
838
 
717
839
  if (pendingPayload) {
718
- // User responded to a payment prompt
840
+ // Check for tier selection first
841
+ if (pendingPayload.step === 'tier_select' && pendingPayload.tiers && pendingPayload.tiers.length > 0) {
842
+ const userInput = lastContent.trim();
843
+ let selectedIndex = -1;
844
+
845
+ // Try to parse user input as a number (1-based)
846
+ const num = parseInt(userInput, 10);
847
+ if (!isNaN(num) && num >= 1 && num <= pendingPayload.tiers.length) {
848
+ selectedIndex = num - 1;
849
+ } else {
850
+ // Try to match by duration minutes
851
+ for (let i = 0; i < pendingPayload.tiers.length; i++) {
852
+ if (userInput === String(pendingPayload.tiers[i].minutes) ||
853
+ userInput === pendingPayload.tiers[i].minutes + ' minutes' ||
854
+ userInput === pendingPayload.tiers[i].minutes + ' min') {
855
+ selectedIndex = i;
856
+ break;
857
+ }
858
+ }
859
+ }
860
+
861
+ if (selectedIndex >= 0) {
862
+ const selectedTier = pendingPayload.tiers[selectedIndex];
863
+ pendingPayload.durationMinutes = selectedTier.minutes;
864
+ pendingPayload.step = 'processing';
865
+
866
+ log('Tier selected: ' + selectedTier.minutes + ' min for wallet ' + walletHash?.substring(0, 16) + '...');
867
+
868
+ // Build extra headers for payment wrapper
869
+ const extraHeaders = {};
870
+ if (pendingPayload.modelId) {
871
+ extraHeaders['X-Model-Id'] = pendingPayload.modelId;
872
+ }
873
+ extraHeaders['X-Duration-Minutes'] = String(selectedTier.minutes);
874
+
875
+ // Check wallet balance before attempting payment
876
+ const currentBalanceSats = await getWalletBalance();
877
+ if (currentBalanceSats !== null && selectedTier.price_sats && currentBalanceSats < selectedTier.price_sats) {
878
+ log('Insufficient balance for wallet ' + walletHash?.substring(0, 16) + '...: ' + currentBalanceSats + ' sats < ' + selectedTier.price_sats + ' sats needed');
879
+ pendingPayments.delete(walletHash);
880
+ const addr = await getReceivingAddress();
881
+ const neededBch = (selectedTier.price_sats - currentBalanceSats) / 100000000;
882
+ const neededLine = addr ? '\\n\\n📥 **Fund your wallet:** \\\`' + addr + '\\\`\\nOr run: paytaca receive (in another terminal) for QR code' : '';
883
+ sseLine(res, {
884
+ id: 'balance-err',
885
+ object: 'chat.completion.chunk',
886
+ choices: [{ index: 0, delta: { content: '\\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' }],
887
+ });
888
+ sseLine(res, {
889
+ id: 'balance-err-done',
890
+ object: 'chat.completion.chunk',
891
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
892
+ });
893
+ sseDone(res);
894
+ res.end();
895
+ return;
896
+ }
897
+
898
+ runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, (err, responseJson) => {
899
+ pendingPayments.delete(walletHash);
900
+
901
+ if (err) {
902
+ log('paytaca pay failed: ' + err.message);
903
+ res.writeHead(500, { 'Content-Type': 'application/json' });
904
+ res.end(JSON.stringify({
905
+ error: 'Payment failed',
906
+ message: err.message,
907
+ details: 'Please check your wallet balance and try again.'
908
+ }));
909
+ return;
910
+ }
911
+
912
+ if (!responseJson.success) {
913
+ res.writeHead(responseJson.status || 500, { 'Content-Type': 'application/json' });
914
+ res.end(JSON.stringify({
915
+ error: 'Payment failed',
916
+ message: responseJson.error,
917
+ details: 'Payment was not successful. Please check your balance and try again.'
918
+ }));
919
+ return;
920
+ }
921
+
922
+ const chatCompletion = responseJson?.data || responseJson;
923
+
924
+ let wasStreaming = false;
925
+ try {
926
+ wasStreaming = JSON.parse(pendingPayload.body).stream === true;
927
+ } catch {}
928
+
929
+ log('paytaca pay succeeded. Returning chat response.');
930
+
931
+ if (wasStreaming) {
932
+ try {
933
+ jsonToSse(res, chatCompletion);
934
+ } catch (e) {}
935
+ } else {
936
+ try {
937
+ res.writeHead(200, { 'Content-Type': 'application/json' });
938
+ res.end(JSON.stringify(chatCompletion));
939
+ } catch (e) {}
940
+ }
941
+ });
942
+ return;
943
+ } else {
944
+ // Invalid selection — reshow the prompt
945
+ log('Invalid tier selection for wallet ' + walletHash?.substring(0, 16) + '...');
946
+ await streamTierSelectionPrompt(res, walletHash, pendingPayload.displayName || pendingPayload.modelId || 'AI Model', pendingPayload.tiers);
947
+ return;
948
+ }
949
+ }
950
+
951
+ // Old flow: user responded to a yes/no payment prompt
719
952
  if (lastContent === 'yes') {
720
- // User approved — run paytaca pay with the stored original payload
721
953
  log('Payment approved by wallet ' + walletHash?.substring(0, 16) + '...');
722
954
  pendingPayments.delete(walletHash);
723
955
 
724
- runPaytacaPay(BACKEND_URL + '/v1', pendingPayload, walletHash, (err, responseJson) => {
956
+ const extraHeaders = {};
957
+ if (pendingPayload.modelId) {
958
+ extraHeaders['X-Model-Id'] = pendingPayload.modelId;
959
+ }
960
+ if (pendingPayload.durationMinutes) {
961
+ extraHeaders['X-Duration-Minutes'] = String(pendingPayload.durationMinutes);
962
+ }
963
+
964
+ runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, (err, responseJson) => {
725
965
  if (err) {
726
966
  log('paytaca pay failed: ' + err.message);
727
967
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -733,8 +973,6 @@ const server = http.createServer(async (req, res) => {
733
973
  return;
734
974
  }
735
975
 
736
-
737
- // Check if the response indicates success
738
976
  if (!responseJson.success) {
739
977
  res.writeHead(responseJson.status || 500, { 'Content-Type': 'application/json' });
740
978
  res.end(JSON.stringify({
@@ -746,12 +984,10 @@ const server = http.createServer(async (req, res) => {
746
984
  }
747
985
 
748
986
  const chatCompletion = responseJson?.data || responseJson;
749
- if (chatCompletion.choices) {
750
- }
751
987
 
752
988
  let wasStreaming = false;
753
989
  try {
754
- wasStreaming = JSON.parse(pendingPayload).stream === true;
990
+ wasStreaming = JSON.parse(pendingPayload.body).stream === true;
755
991
  } catch {}
756
992
 
757
993
  log('paytaca pay succeeded. Returning chat response.');
@@ -759,20 +995,17 @@ const server = http.createServer(async (req, res) => {
759
995
  if (wasStreaming) {
760
996
  try {
761
997
  jsonToSse(res, chatCompletion);
762
- } catch (e) {
763
- }
998
+ } catch (e) {}
764
999
  } else {
765
1000
  try {
766
1001
  res.writeHead(200, { 'Content-Type': 'application/json' });
767
1002
  res.end(JSON.stringify(chatCompletion));
768
- } catch (e) {
769
- }
1003
+ } catch (e) {}
770
1004
  }
771
1005
  });
772
1006
  return;
773
1007
 
774
1008
  } else if (lastContent === 'no') {
775
- // User declined
776
1009
  log('Payment declined by wallet ' + walletHash?.substring(0, 16) + '...');
777
1010
  pendingPayments.delete(walletHash);
778
1011
 
@@ -785,7 +1018,7 @@ const server = http.createServer(async (req, res) => {
785
1018
  id: 'payment-declined',
786
1019
  object: 'chat.completion',
787
1020
  created: Math.floor(Date.now() / 1000),
788
- model: 'deepseek-ai/DeepSeek-V4-Flash',
1021
+ model: pendingPayload.modelId || 'deepseek/deepseek-v4-flash',
789
1022
  choices: [{
790
1023
  index: 0,
791
1024
  message: {
@@ -804,6 +1037,65 @@ const server = http.createServer(async (req, res) => {
804
1037
  }
805
1038
  }
806
1039
 
1040
+ // Handle time/credits command — show remaining time credits
1041
+ const cmd = lastContent?.trim().toLowerCase();
1042
+ if (cmd === 'time' || cmd === 'credit' || cmd === 'credits') {
1043
+ log('Time command for wallet ' + walletHash?.substring(0, 16) + '...');
1044
+ const statusUrl = BACKEND_URL + '/v1/wallet/status';
1045
+ const statusRes = await fetch(statusUrl, {
1046
+ headers: { 'X-Wallet-Hash': walletHash }
1047
+ });
1048
+ let content;
1049
+ if (statusRes.ok) {
1050
+ const statusData = await statusRes.json();
1051
+ const sessions = statusData.sessions || [];
1052
+ const activeSessions = sessions.filter(s => s.time_remaining_seconds > 0 && s.model_active);
1053
+ const inactiveSessions = sessions.filter(s => s.time_remaining_seconds > 0 && !s.model_active);
1054
+ const parts = [];
1055
+ if (activeSessions.length > 0) {
1056
+ parts.push('**⏱️ Active Time Credits:**');
1057
+ activeSessions.forEach(s => {
1058
+ const total = formatDuration(s.time_credits_seconds);
1059
+ const remaining = formatDuration(s.time_remaining_seconds);
1060
+ const used = formatDuration(s.time_used_seconds);
1061
+ parts.push(' - **' + (s.display_name || s.ai_model) + '** — ' + remaining + ' remaining of ' + total + ' (' + used + ' used)');
1062
+ });
1063
+ }
1064
+ if (inactiveSessions.length > 0) {
1065
+ parts.push('\\n**⚠️ Inactive Model:**');
1066
+ inactiveSessions.forEach(s => {
1067
+ const remaining = formatDuration(s.time_remaining_seconds);
1068
+ parts.push(' - **' + (s.display_name || s.ai_model) + ' (Inactive)** — ' + remaining + ' remaining');
1069
+ });
1070
+ }
1071
+ content = parts.length > 0 ? parts.join('\\n') : '⏱️ No active time credits.';
1072
+ } else {
1073
+ content = '⏱️ Unable to check time credits.';
1074
+ }
1075
+
1076
+ sseLine(res, {
1077
+ id: 'time-1',
1078
+ object: 'chat.completion.chunk',
1079
+ created: Math.floor(Date.now() / 1000),
1080
+ model: 'deepseek/deepseek-v4-flash',
1081
+ choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],
1082
+ });
1083
+ sseLine(res, {
1084
+ id: 'time-2',
1085
+ object: 'chat.completion.chunk',
1086
+ choices: [{ index: 0, delta: { content: content + '\\n' }, finish_reason: 'stop' }],
1087
+ });
1088
+ sseLine(res, {
1089
+ id: 'time-3',
1090
+ object: 'chat.completion.chunk',
1091
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
1092
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
1093
+ });
1094
+ sseDone(res);
1095
+ res.end();
1096
+ return;
1097
+ }
1098
+
807
1099
  let isStreaming = true;
808
1100
  try { isStreaming = JSON.parse(body).stream !== false; } catch {}
809
1101
 
@@ -819,20 +1111,56 @@ const server = http.createServer(async (req, res) => {
819
1111
 
820
1112
  if (statusCode === 402) {
821
1113
  log('402 intercepted for wallet ' + walletHash?.substring(0, 16) + '...');
822
- pendingPayments.set(walletHash, body);
1114
+
1115
+ // Parse 402 response for model_id and price_tiers
1116
+ let modelId = null;
1117
+ let displayName = null;
1118
+ let tiers = null;
1119
+ try {
1120
+ const parsed = JSON.parse(responseBody);
1121
+ modelId = parsed.model_id || null;
1122
+ displayName = parsed.display_name || null;
1123
+ tiers = parsed.price_tiers || null;
1124
+ } catch (e) {
1125
+ log('Could not parse 402 body: ' + e.message);
1126
+ }
1127
+
1128
+ pendingPayments.set(walletHash, {
1129
+ body: body,
1130
+ modelId: modelId,
1131
+ durationMinutes: null,
1132
+ tiers: tiers,
1133
+ step: tiers ? 'tier_select' : 'approval'
1134
+ });
1135
+
1136
+ if (tiers && tiers.length > 0) {
1137
+ // New flow: show tier selection prompt
1138
+ await streamTierSelectionPrompt(res, walletHash, displayName || modelId || 'AI Model', tiers);
1139
+ return;
1140
+ }
823
1141
 
824
1142
  // Check session status to determine if this is a renewal
825
1143
  let isRenewal = false;
826
1144
  let tokensUsed = 0;
827
1145
  let tokenLimit = 50000;
828
- let carryoverDeadline = null;
1146
+ let timeRemainingSeconds = 0;
829
1147
 
1148
+ let statusModelId = modelId;
830
1149
  try {
1150
+ // Extract model from the original request body if not in 402
1151
+ if (!statusModelId) {
1152
+ try {
1153
+ const bodyParsed = JSON.parse(body);
1154
+ statusModelId = bodyParsed.model || null;
1155
+ } catch (e) {}
1156
+ }
1157
+
1158
+ const statusPath = '/v1/wallet/status' + (statusModelId ? '?model_id=' + encodeURIComponent(statusModelId) : '');
831
1159
  const statusResponse = await new Promise((resolve, reject) => {
832
1160
  const statusReq = REQUester.get({
833
1161
  hostname: DJANGO_HOST,
834
1162
  port: DJANGO_PORT,
835
- path: '/v1/wallet/status',
1163
+ path: statusPath,
836
1164
  headers: { 'X-Wallet-Hash': walletHash }
837
1165
  }, (res) => {
838
1166
  let data = '';
@@ -852,20 +1180,17 @@ const server = http.createServer(async (req, res) => {
852
1180
  if (statusResponse) {
853
1181
  tokensUsed = statusResponse.tokens_used || 0;
854
1182
  tokenLimit = statusResponse.token_limit || 50000;
855
- carryoverDeadline = statusResponse.carryover_deadline || null;
856
-
857
- const hasExpiredSession = !statusResponse.session_active && tokensUsed > 0;
858
- const carryoverStillValid = (statusResponse.carryover_remaining_minutes || 0) > 0;
1183
+ timeRemainingSeconds = statusResponse.time_remaining_seconds || 0;
859
1184
 
860
- // Renewal if: (1) session active but tokens exhausted, OR (2) session expired with valid carryover
861
- isRenewal = (statusResponse.session_active && tokensUsed >= tokenLimit) ||
862
- (hasExpiredSession && carryoverStillValid);
1185
+ // Renewal if session has been used (tokens > 0 or time > 0) but is now exhausted
1186
+ isRenewal = (tokensUsed > 0 || statusResponse.time_used_seconds > 0) &&
1187
+ (!statusResponse.session_active || timeRemainingSeconds <= 0);
863
1188
  }
864
1189
  } catch (err) {
865
1190
  log('Failed to check session status: ' + err.message);
866
1191
  }
867
1192
 
868
- await streamPaymentPrompt(res, walletHash, isRenewal, tokensUsed, tokenLimit, carryoverDeadline);
1193
+ await streamPaymentPrompt(res, walletHash, isRenewal, tokensUsed, tokenLimit, timeRemainingSeconds);
869
1194
  } else {
870
1195
  if (res.headersSent) {
871
1196
  log('Streaming response completed and already sent');
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAq6BnC,CAAC"}
1
+ {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0uCnC,CAAC"}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAGjC,eAAO,MAAM,mBAAmB,2BAA2B,CAAC;AAC5D,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAGvC,wBAAgB,YAAY,IAAI,MAAM,CAGrC;AAED,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAIvD;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAiCpD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAIlE;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE1D;AAGD,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAQvD;AAGD,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,OAAO,CAOhE"}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,iBAAe,cAAc,CAAC,MAAM,CAAC,EAAE,GAAG,EAAE,QAAQ,CAAC,EAAE,GAAG;;;;kBA6FlC,GAAG;6BA8CQ,GAAG,UAAU,GAAG;GAMlD;;;;;AAED,kBAAoE"}