@routstr/sdk 0.1.0 → 0.1.2
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/dist/client/index.d.mts +293 -0
- package/dist/client/index.d.ts +293 -0
- package/dist/client/index.js +2663 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/index.mjs +2659 -0
- package/dist/client/index.mjs.map +1 -0
- package/dist/discovery/index.d.mts +186 -0
- package/dist/discovery/index.d.ts +186 -0
- package/dist/discovery/index.js +581 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/index.mjs +578 -0
- package/dist/discovery/index.mjs.map +1 -0
- package/dist/index.d.mts +28 -4777
- package/dist/index.d.ts +28 -4777
- package/dist/index.js +1450 -681
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1447 -682
- package/dist/index.mjs.map +1 -1
- package/dist/interfaces-B85Wx7ni.d.mts +171 -0
- package/dist/interfaces-BVNyAmKu.d.ts +171 -0
- package/dist/interfaces-Dnrvxr6N.d.ts +102 -0
- package/dist/interfaces-nanJOqdW.d.mts +102 -0
- package/dist/storage/index.d.mts +134 -0
- package/dist/storage/index.d.ts +134 -0
- package/dist/storage/index.js +861 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/index.mjs +846 -0
- package/dist/storage/index.mjs.map +1 -0
- package/dist/types-BlHjmWRK.d.mts +222 -0
- package/dist/types-BlHjmWRK.d.ts +222 -0
- package/dist/wallet/index.d.mts +218 -0
- package/dist/wallet/index.d.ts +218 -0
- package/dist/wallet/index.js +1204 -0
- package/dist/wallet/index.js.map +1 -0
- package/dist/wallet/index.mjs +1201 -0
- package/dist/wallet/index.mjs.map +1 -0
- package/package.json +9 -9
|
@@ -0,0 +1,2659 @@
|
|
|
1
|
+
// core/errors.ts
|
|
2
|
+
var InsufficientBalanceError = class extends Error {
|
|
3
|
+
constructor(required, available, maxMintBalance = 0, maxMintUrl = "") {
|
|
4
|
+
super(
|
|
5
|
+
`Insufficient balance: need ${required} sats, have ${available} sats available. ` + (maxMintBalance > 0 ? `Largest mint balance: ${maxMintBalance} sats from ${maxMintUrl}` : "")
|
|
6
|
+
);
|
|
7
|
+
this.required = required;
|
|
8
|
+
this.available = available;
|
|
9
|
+
this.maxMintBalance = maxMintBalance;
|
|
10
|
+
this.maxMintUrl = maxMintUrl;
|
|
11
|
+
this.name = "InsufficientBalanceError";
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
var ProviderError = class extends Error {
|
|
15
|
+
constructor(baseUrl, statusCode, message, requestId) {
|
|
16
|
+
super(
|
|
17
|
+
`Provider ${baseUrl} returned ${statusCode}: ${message}` + (requestId ? ` (Request ID: ${requestId})` : "")
|
|
18
|
+
);
|
|
19
|
+
this.baseUrl = baseUrl;
|
|
20
|
+
this.statusCode = statusCode;
|
|
21
|
+
this.requestId = requestId;
|
|
22
|
+
this.name = "ProviderError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var FailoverError = class extends Error {
|
|
26
|
+
constructor(originalProvider, failedProviders, message) {
|
|
27
|
+
super(
|
|
28
|
+
message || `All providers failed. Original: ${originalProvider}, Failed: ${failedProviders.join(", ")}`
|
|
29
|
+
);
|
|
30
|
+
this.originalProvider = originalProvider;
|
|
31
|
+
this.failedProviders = failedProviders;
|
|
32
|
+
this.name = "FailoverError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// wallet/AuditLogger.ts
|
|
37
|
+
var AuditLogger = class _AuditLogger {
|
|
38
|
+
static instance = null;
|
|
39
|
+
static getInstance() {
|
|
40
|
+
if (!_AuditLogger.instance) {
|
|
41
|
+
_AuditLogger.instance = new _AuditLogger();
|
|
42
|
+
}
|
|
43
|
+
return _AuditLogger.instance;
|
|
44
|
+
}
|
|
45
|
+
async log(entry) {
|
|
46
|
+
const fullEntry = {
|
|
47
|
+
...entry,
|
|
48
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
49
|
+
};
|
|
50
|
+
const logLine = JSON.stringify(fullEntry) + "\n";
|
|
51
|
+
if (typeof window === "undefined") {
|
|
52
|
+
try {
|
|
53
|
+
const fs = await import('fs');
|
|
54
|
+
const path = await import('path');
|
|
55
|
+
const logPath = path.join(process.cwd(), "audit.log");
|
|
56
|
+
fs.appendFileSync(logPath, logLine);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error("[AuditLogger] Failed to write to file:", error);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
console.log("[AUDIT]", logLine.trim());
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async logBalanceSnapshot(action, amounts, options) {
|
|
65
|
+
await this.log({
|
|
66
|
+
action,
|
|
67
|
+
totalBalance: amounts.totalBalance,
|
|
68
|
+
providerBalances: amounts.providerBalances,
|
|
69
|
+
mintBalances: amounts.mintBalances,
|
|
70
|
+
amount: options?.amount,
|
|
71
|
+
mintUrl: options?.mintUrl,
|
|
72
|
+
baseUrl: options?.baseUrl,
|
|
73
|
+
status: options?.status ?? "success",
|
|
74
|
+
details: options?.details
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
var auditLogger = AuditLogger.getInstance();
|
|
79
|
+
|
|
80
|
+
// wallet/tokenUtils.ts
|
|
81
|
+
function isNetworkErrorMessage(message) {
|
|
82
|
+
return message.includes("NetworkError when attempting to fetch resource") || message.includes("Failed to fetch") || message.includes("Load failed");
|
|
83
|
+
}
|
|
84
|
+
function getBalanceInSats(balance, unit) {
|
|
85
|
+
return unit === "msat" ? balance / 1e3 : balance;
|
|
86
|
+
}
|
|
87
|
+
function selectMintWithBalance(balances, units, amount, excludeMints = []) {
|
|
88
|
+
for (const mintUrl in balances) {
|
|
89
|
+
if (excludeMints.includes(mintUrl)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const balanceInSats = getBalanceInSats(balances[mintUrl], units[mintUrl]);
|
|
93
|
+
if (balanceInSats >= amount) {
|
|
94
|
+
return { selectedMintUrl: mintUrl, selectedMintBalance: balanceInSats };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { selectedMintUrl: null, selectedMintBalance: 0 };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// wallet/CashuSpender.ts
|
|
101
|
+
var CashuSpender = class {
|
|
102
|
+
constructor(walletAdapter, storageAdapter, _providerRegistry, balanceManager) {
|
|
103
|
+
this.walletAdapter = walletAdapter;
|
|
104
|
+
this.storageAdapter = storageAdapter;
|
|
105
|
+
this._providerRegistry = _providerRegistry;
|
|
106
|
+
this.balanceManager = balanceManager;
|
|
107
|
+
}
|
|
108
|
+
_isBusy = false;
|
|
109
|
+
debugLevel = "WARN";
|
|
110
|
+
async receiveToken(token) {
|
|
111
|
+
const result = await this.walletAdapter.receiveToken(token);
|
|
112
|
+
if (!result.success && result.message?.includes("Failed to fetch mint")) {
|
|
113
|
+
const cachedTokens = this.storageAdapter.getCachedReceiveTokens();
|
|
114
|
+
const existingIndex = cachedTokens.findIndex((t) => t.token === token);
|
|
115
|
+
if (existingIndex === -1) {
|
|
116
|
+
this.storageAdapter.setCachedReceiveTokens([
|
|
117
|
+
...cachedTokens,
|
|
118
|
+
{
|
|
119
|
+
token,
|
|
120
|
+
amount: result.amount,
|
|
121
|
+
unit: result.unit,
|
|
122
|
+
createdAt: Date.now()
|
|
123
|
+
}
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
async _getBalanceState() {
|
|
130
|
+
const mintBalances = await this.walletAdapter.getBalances();
|
|
131
|
+
const units = this.walletAdapter.getMintUnits();
|
|
132
|
+
let totalMintBalance = 0;
|
|
133
|
+
const normalizedMintBalances = {};
|
|
134
|
+
for (const url in mintBalances) {
|
|
135
|
+
const balance = mintBalances[url];
|
|
136
|
+
const unit = units[url];
|
|
137
|
+
const balanceInSats = getBalanceInSats(balance, unit);
|
|
138
|
+
normalizedMintBalances[url] = balanceInSats;
|
|
139
|
+
totalMintBalance += balanceInSats;
|
|
140
|
+
}
|
|
141
|
+
const pendingDistribution = this.storageAdapter.getCachedTokenDistribution();
|
|
142
|
+
const providerBalances = {};
|
|
143
|
+
let totalProviderBalance = 0;
|
|
144
|
+
for (const pending of pendingDistribution) {
|
|
145
|
+
providerBalances[pending.baseUrl] = pending.amount;
|
|
146
|
+
totalProviderBalance += pending.amount;
|
|
147
|
+
}
|
|
148
|
+
const apiKeys = this.storageAdapter.getAllApiKeys();
|
|
149
|
+
for (const apiKey of apiKeys) {
|
|
150
|
+
if (!providerBalances[apiKey.baseUrl]) {
|
|
151
|
+
providerBalances[apiKey.baseUrl] = apiKey.balance;
|
|
152
|
+
totalProviderBalance += apiKey.balance;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
totalBalance: totalMintBalance + totalProviderBalance,
|
|
157
|
+
providerBalances,
|
|
158
|
+
mintBalances: normalizedMintBalances
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
async _logTransaction(action, options) {
|
|
162
|
+
const balanceState = await this._getBalanceState();
|
|
163
|
+
await auditLogger.logBalanceSnapshot(action, balanceState, options);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Check if the spender is currently in a critical operation
|
|
167
|
+
*/
|
|
168
|
+
get isBusy() {
|
|
169
|
+
return this._isBusy;
|
|
170
|
+
}
|
|
171
|
+
getDebugLevel() {
|
|
172
|
+
return this.debugLevel;
|
|
173
|
+
}
|
|
174
|
+
setDebugLevel(level) {
|
|
175
|
+
this.debugLevel = level;
|
|
176
|
+
}
|
|
177
|
+
_log(level, ...args) {
|
|
178
|
+
const levelPriority = {
|
|
179
|
+
DEBUG: 0,
|
|
180
|
+
WARN: 1,
|
|
181
|
+
ERROR: 2
|
|
182
|
+
};
|
|
183
|
+
if (levelPriority[level] >= levelPriority[this.debugLevel]) {
|
|
184
|
+
switch (level) {
|
|
185
|
+
case "DEBUG":
|
|
186
|
+
console.log(...args);
|
|
187
|
+
break;
|
|
188
|
+
case "WARN":
|
|
189
|
+
console.warn(...args);
|
|
190
|
+
break;
|
|
191
|
+
case "ERROR":
|
|
192
|
+
console.error(...args);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Spend Cashu tokens with automatic mint selection and retry logic
|
|
199
|
+
* Throws errors on failure instead of returning failed SpendResult
|
|
200
|
+
*/
|
|
201
|
+
async spend(options) {
|
|
202
|
+
const {
|
|
203
|
+
mintUrl,
|
|
204
|
+
amount,
|
|
205
|
+
baseUrl,
|
|
206
|
+
reuseToken = false,
|
|
207
|
+
p2pkPubkey,
|
|
208
|
+
excludeMints = [],
|
|
209
|
+
retryCount = 0
|
|
210
|
+
} = options;
|
|
211
|
+
this._isBusy = true;
|
|
212
|
+
try {
|
|
213
|
+
const result = await this._spendInternal({
|
|
214
|
+
mintUrl,
|
|
215
|
+
amount,
|
|
216
|
+
baseUrl,
|
|
217
|
+
reuseToken,
|
|
218
|
+
p2pkPubkey,
|
|
219
|
+
excludeMints,
|
|
220
|
+
retryCount
|
|
221
|
+
});
|
|
222
|
+
if (result.status === "failed" || !result.token) {
|
|
223
|
+
const errorMsg = result.error || `Insufficient balance. Need ${amount} sats.`;
|
|
224
|
+
if (this._isNetworkError(errorMsg)) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`Your mint ${mintUrl} is unreachable or is blocking your IP. Please try again later or switch mints.`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (result.errorDetails) {
|
|
230
|
+
throw new InsufficientBalanceError(
|
|
231
|
+
result.errorDetails.required,
|
|
232
|
+
result.errorDetails.available,
|
|
233
|
+
result.errorDetails.maxMintBalance,
|
|
234
|
+
result.errorDetails.maxMintUrl
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
throw new Error(errorMsg);
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
} finally {
|
|
241
|
+
this._isBusy = false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Check if error message indicates a network error
|
|
246
|
+
*/
|
|
247
|
+
_isNetworkError(message) {
|
|
248
|
+
return isNetworkErrorMessage(message) || message.includes("Your mint") && message.includes("unreachable");
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Internal spending logic
|
|
252
|
+
*/
|
|
253
|
+
async _spendInternal(options) {
|
|
254
|
+
let {
|
|
255
|
+
mintUrl,
|
|
256
|
+
amount,
|
|
257
|
+
baseUrl,
|
|
258
|
+
reuseToken,
|
|
259
|
+
p2pkPubkey,
|
|
260
|
+
excludeMints,
|
|
261
|
+
retryCount
|
|
262
|
+
} = options;
|
|
263
|
+
this._log(
|
|
264
|
+
"DEBUG",
|
|
265
|
+
`[CashuSpender] _spendInternal: amount=${amount}, mintUrl=${mintUrl}, baseUrl=${baseUrl}, reuseToken=${reuseToken}`
|
|
266
|
+
);
|
|
267
|
+
let adjustedAmount = Math.ceil(amount);
|
|
268
|
+
if (!adjustedAmount || isNaN(adjustedAmount)) {
|
|
269
|
+
this._log(
|
|
270
|
+
"ERROR",
|
|
271
|
+
`[CashuSpender] _spendInternal: Invalid amount: ${amount}`
|
|
272
|
+
);
|
|
273
|
+
return {
|
|
274
|
+
token: null,
|
|
275
|
+
status: "failed",
|
|
276
|
+
balance: 0,
|
|
277
|
+
error: "Please enter a valid amount"
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (reuseToken && baseUrl) {
|
|
281
|
+
this._log(
|
|
282
|
+
"DEBUG",
|
|
283
|
+
`[CashuSpender] _spendInternal: Attempting to reuse token for ${baseUrl}`
|
|
284
|
+
);
|
|
285
|
+
const existingResult = await this._tryReuseToken(
|
|
286
|
+
baseUrl,
|
|
287
|
+
adjustedAmount,
|
|
288
|
+
mintUrl
|
|
289
|
+
);
|
|
290
|
+
if (existingResult) {
|
|
291
|
+
this._log(
|
|
292
|
+
"DEBUG",
|
|
293
|
+
`[CashuSpender] _spendInternal: Successfully reused token, balance: ${existingResult.balance}`
|
|
294
|
+
);
|
|
295
|
+
return existingResult;
|
|
296
|
+
}
|
|
297
|
+
this._log(
|
|
298
|
+
"DEBUG",
|
|
299
|
+
`[CashuSpender] _spendInternal: Could not reuse token, will create new token`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const balances = await this.walletAdapter.getBalances();
|
|
303
|
+
const units = this.walletAdapter.getMintUnits();
|
|
304
|
+
let totalBalance = 0;
|
|
305
|
+
for (const url in balances) {
|
|
306
|
+
const balance = balances[url];
|
|
307
|
+
const unit = units[url];
|
|
308
|
+
const balanceInSats = getBalanceInSats(balance, unit);
|
|
309
|
+
totalBalance += balanceInSats;
|
|
310
|
+
}
|
|
311
|
+
const pendingDistribution = this.storageAdapter.getCachedTokenDistribution();
|
|
312
|
+
const totalPending = pendingDistribution.reduce(
|
|
313
|
+
(sum, item) => sum + item.amount,
|
|
314
|
+
0
|
|
315
|
+
);
|
|
316
|
+
this._log(
|
|
317
|
+
"DEBUG",
|
|
318
|
+
`[CashuSpender] _spendInternal: totalBalance=${totalBalance}, totalPending=${totalPending}, adjustedAmount=${adjustedAmount}`
|
|
319
|
+
);
|
|
320
|
+
const totalAvailableBalance = totalBalance + totalPending;
|
|
321
|
+
if (totalAvailableBalance < adjustedAmount) {
|
|
322
|
+
this._log(
|
|
323
|
+
"ERROR",
|
|
324
|
+
`[CashuSpender] _spendInternal: Insufficient balance, have=${totalAvailableBalance}, need=${adjustedAmount}`
|
|
325
|
+
);
|
|
326
|
+
return this._createInsufficientBalanceError(
|
|
327
|
+
adjustedAmount,
|
|
328
|
+
balances,
|
|
329
|
+
units,
|
|
330
|
+
totalAvailableBalance
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
let token = null;
|
|
334
|
+
let selectedMintUrl;
|
|
335
|
+
let spentAmount = adjustedAmount;
|
|
336
|
+
if (this.balanceManager) {
|
|
337
|
+
const tokenResult = await this.balanceManager.createProviderToken({
|
|
338
|
+
mintUrl,
|
|
339
|
+
baseUrl,
|
|
340
|
+
amount: adjustedAmount,
|
|
341
|
+
p2pkPubkey,
|
|
342
|
+
excludeMints,
|
|
343
|
+
retryCount
|
|
344
|
+
});
|
|
345
|
+
if (!tokenResult.success || !tokenResult.token) {
|
|
346
|
+
if ((tokenResult.error || "").includes("Insufficient balance")) {
|
|
347
|
+
return this._createInsufficientBalanceError(
|
|
348
|
+
adjustedAmount,
|
|
349
|
+
balances,
|
|
350
|
+
units,
|
|
351
|
+
totalAvailableBalance
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
token: null,
|
|
356
|
+
status: "failed",
|
|
357
|
+
balance: 0,
|
|
358
|
+
error: tokenResult.error || "Failed to create token"
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
token = tokenResult.token;
|
|
362
|
+
selectedMintUrl = tokenResult.selectedMintUrl;
|
|
363
|
+
spentAmount = tokenResult.amountSpent || adjustedAmount;
|
|
364
|
+
} else {
|
|
365
|
+
try {
|
|
366
|
+
token = await this.walletAdapter.sendToken(
|
|
367
|
+
mintUrl,
|
|
368
|
+
adjustedAmount,
|
|
369
|
+
p2pkPubkey
|
|
370
|
+
);
|
|
371
|
+
selectedMintUrl = mintUrl;
|
|
372
|
+
} catch (error) {
|
|
373
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
374
|
+
return {
|
|
375
|
+
token: null,
|
|
376
|
+
status: "failed",
|
|
377
|
+
balance: 0,
|
|
378
|
+
error: `Error generating token: ${errorMsg}`
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (token && baseUrl) {
|
|
383
|
+
this.storageAdapter.setToken(baseUrl, token);
|
|
384
|
+
}
|
|
385
|
+
this._logTransaction("spend", {
|
|
386
|
+
amount: spentAmount,
|
|
387
|
+
mintUrl: selectedMintUrl || mintUrl,
|
|
388
|
+
baseUrl,
|
|
389
|
+
status: "success"
|
|
390
|
+
});
|
|
391
|
+
this._log(
|
|
392
|
+
"DEBUG",
|
|
393
|
+
`[CashuSpender] _spendInternal: Successfully spent ${spentAmount}, returning token with balance=${spentAmount}`
|
|
394
|
+
);
|
|
395
|
+
return {
|
|
396
|
+
token,
|
|
397
|
+
status: "success",
|
|
398
|
+
balance: spentAmount,
|
|
399
|
+
unit: (selectedMintUrl ? units[selectedMintUrl] : units[mintUrl]) || "sat"
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Try to reuse an existing token
|
|
404
|
+
*/
|
|
405
|
+
async _tryReuseToken(baseUrl, amount, mintUrl) {
|
|
406
|
+
const storedToken = this.storageAdapter.getToken(baseUrl);
|
|
407
|
+
if (!storedToken) return null;
|
|
408
|
+
const pendingDistribution = this.storageAdapter.getCachedTokenDistribution();
|
|
409
|
+
const balanceForBaseUrl = pendingDistribution.find((b) => b.baseUrl === baseUrl)?.amount || 0;
|
|
410
|
+
this._log("DEBUG", "RESUINGDSR GSODGNSD", balanceForBaseUrl, amount);
|
|
411
|
+
if (balanceForBaseUrl > amount) {
|
|
412
|
+
const units = this.walletAdapter.getMintUnits();
|
|
413
|
+
const unit = units[mintUrl] || "sat";
|
|
414
|
+
return {
|
|
415
|
+
token: storedToken,
|
|
416
|
+
status: "success",
|
|
417
|
+
balance: balanceForBaseUrl,
|
|
418
|
+
unit
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
if (this.balanceManager) {
|
|
422
|
+
const topUpAmount = Math.ceil(amount * 1.2 - balanceForBaseUrl);
|
|
423
|
+
const topUpResult = await this.balanceManager.topUp({
|
|
424
|
+
mintUrl,
|
|
425
|
+
baseUrl,
|
|
426
|
+
amount: topUpAmount
|
|
427
|
+
});
|
|
428
|
+
this._log("DEBUG", "TOPUP ", topUpResult);
|
|
429
|
+
if (topUpResult.success && topUpResult.toppedUpAmount) {
|
|
430
|
+
const newBalance = balanceForBaseUrl + topUpResult.toppedUpAmount;
|
|
431
|
+
const units = this.walletAdapter.getMintUnits();
|
|
432
|
+
const unit = units[mintUrl] || "sat";
|
|
433
|
+
this._logTransaction("topup", {
|
|
434
|
+
amount: topUpResult.toppedUpAmount,
|
|
435
|
+
mintUrl,
|
|
436
|
+
baseUrl,
|
|
437
|
+
status: "success"
|
|
438
|
+
});
|
|
439
|
+
return {
|
|
440
|
+
token: storedToken,
|
|
441
|
+
status: "success",
|
|
442
|
+
balance: newBalance,
|
|
443
|
+
unit
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
const providerBalance = await this._getProviderTokenBalance(
|
|
447
|
+
baseUrl,
|
|
448
|
+
storedToken
|
|
449
|
+
);
|
|
450
|
+
this._log("DEBUG", providerBalance);
|
|
451
|
+
if (providerBalance <= 0) {
|
|
452
|
+
this.storageAdapter.removeToken(baseUrl);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Refund specific providers without retrying spend
|
|
459
|
+
*/
|
|
460
|
+
async refundProviders(baseUrls, mintUrl, refundApiKeys = false) {
|
|
461
|
+
const results = [];
|
|
462
|
+
const pendingDistribution = this.storageAdapter.getCachedTokenDistribution();
|
|
463
|
+
const toRefund = pendingDistribution.filter(
|
|
464
|
+
(p) => baseUrls.includes(p.baseUrl)
|
|
465
|
+
);
|
|
466
|
+
const refundResults = await Promise.allSettled(
|
|
467
|
+
toRefund.map(async (pending) => {
|
|
468
|
+
const token = this.storageAdapter.getToken(pending.baseUrl);
|
|
469
|
+
this._log("DEBUG", token, this.balanceManager);
|
|
470
|
+
if (!token || !this.balanceManager) {
|
|
471
|
+
return { baseUrl: pending.baseUrl, success: false };
|
|
472
|
+
}
|
|
473
|
+
const tokenBalance = await this.balanceManager.getTokenBalance(
|
|
474
|
+
token,
|
|
475
|
+
pending.baseUrl
|
|
476
|
+
);
|
|
477
|
+
if (tokenBalance.reserved > 0) {
|
|
478
|
+
return { baseUrl: pending.baseUrl, success: false };
|
|
479
|
+
}
|
|
480
|
+
const result = await this.balanceManager.refund({
|
|
481
|
+
mintUrl,
|
|
482
|
+
baseUrl: pending.baseUrl,
|
|
483
|
+
token
|
|
484
|
+
});
|
|
485
|
+
this._log("DEBUG", result);
|
|
486
|
+
if (result.success) {
|
|
487
|
+
this.storageAdapter.removeToken(pending.baseUrl);
|
|
488
|
+
}
|
|
489
|
+
return { baseUrl: pending.baseUrl, success: result.success };
|
|
490
|
+
})
|
|
491
|
+
);
|
|
492
|
+
results.push(
|
|
493
|
+
...refundResults.map(
|
|
494
|
+
(r) => r.status === "fulfilled" ? r.value : { baseUrl: "", success: false }
|
|
495
|
+
)
|
|
496
|
+
);
|
|
497
|
+
if (refundApiKeys) {
|
|
498
|
+
const apiKeyDistribution = this.storageAdapter.getApiKeyDistribution();
|
|
499
|
+
const apiKeysToRefund = apiKeyDistribution.filter(
|
|
500
|
+
(p) => baseUrls.includes(p.baseUrl)
|
|
501
|
+
);
|
|
502
|
+
for (const apiKeyEntry of apiKeysToRefund) {
|
|
503
|
+
const apiKeyEntryFull = this.storageAdapter.getApiKey(
|
|
504
|
+
apiKeyEntry.baseUrl
|
|
505
|
+
);
|
|
506
|
+
if (apiKeyEntryFull && this.balanceManager) {
|
|
507
|
+
const refundResult = await this.balanceManager.refundApiKey({
|
|
508
|
+
mintUrl,
|
|
509
|
+
baseUrl: apiKeyEntry.baseUrl,
|
|
510
|
+
apiKey: apiKeyEntryFull.key
|
|
511
|
+
});
|
|
512
|
+
if (refundResult.success) {
|
|
513
|
+
this.storageAdapter.updateApiKeyBalance(apiKeyEntry.baseUrl, 0);
|
|
514
|
+
}
|
|
515
|
+
results.push({
|
|
516
|
+
baseUrl: apiKeyEntry.baseUrl,
|
|
517
|
+
success: refundResult.success
|
|
518
|
+
});
|
|
519
|
+
} else {
|
|
520
|
+
results.push({
|
|
521
|
+
baseUrl: apiKeyEntry.baseUrl,
|
|
522
|
+
success: false
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return results;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Create an insufficient balance error result
|
|
531
|
+
*/
|
|
532
|
+
_createInsufficientBalanceError(required, balances, units, availableBalance) {
|
|
533
|
+
let maxBalance = 0;
|
|
534
|
+
let maxMintUrl = "";
|
|
535
|
+
for (const mintUrl in balances) {
|
|
536
|
+
const balance = balances[mintUrl];
|
|
537
|
+
const unit = units[mintUrl];
|
|
538
|
+
const balanceInSats = getBalanceInSats(balance, unit);
|
|
539
|
+
if (balanceInSats > maxBalance) {
|
|
540
|
+
maxBalance = balanceInSats;
|
|
541
|
+
maxMintUrl = mintUrl;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const error = new InsufficientBalanceError(
|
|
545
|
+
required,
|
|
546
|
+
availableBalance ?? maxBalance,
|
|
547
|
+
maxBalance,
|
|
548
|
+
maxMintUrl
|
|
549
|
+
);
|
|
550
|
+
return {
|
|
551
|
+
token: null,
|
|
552
|
+
status: "failed",
|
|
553
|
+
balance: 0,
|
|
554
|
+
error: error.message,
|
|
555
|
+
errorDetails: {
|
|
556
|
+
required,
|
|
557
|
+
available: availableBalance ?? maxBalance,
|
|
558
|
+
maxMintBalance: maxBalance,
|
|
559
|
+
maxMintUrl
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
async _getProviderTokenBalance(baseUrl, token) {
|
|
564
|
+
try {
|
|
565
|
+
const response = await fetch(`${baseUrl}v1/wallet/info`, {
|
|
566
|
+
headers: {
|
|
567
|
+
Authorization: `Bearer ${token}`
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
if (response.ok) {
|
|
571
|
+
const data = await response.json();
|
|
572
|
+
return data.balance / 1e3;
|
|
573
|
+
}
|
|
574
|
+
} catch {
|
|
575
|
+
return 0;
|
|
576
|
+
}
|
|
577
|
+
return 0;
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// wallet/BalanceManager.ts
|
|
582
|
+
var BalanceManager = class {
|
|
583
|
+
constructor(walletAdapter, storageAdapter, providerRegistry, cashuSpender) {
|
|
584
|
+
this.walletAdapter = walletAdapter;
|
|
585
|
+
this.storageAdapter = storageAdapter;
|
|
586
|
+
this.providerRegistry = providerRegistry;
|
|
587
|
+
if (cashuSpender) {
|
|
588
|
+
this.cashuSpender = cashuSpender;
|
|
589
|
+
} else {
|
|
590
|
+
this.cashuSpender = new CashuSpender(
|
|
591
|
+
walletAdapter,
|
|
592
|
+
storageAdapter,
|
|
593
|
+
providerRegistry,
|
|
594
|
+
this
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
cashuSpender;
|
|
599
|
+
/**
|
|
600
|
+
* Unified refund - handles both NIP-60 and legacy wallet refunds
|
|
601
|
+
*/
|
|
602
|
+
async refund(options) {
|
|
603
|
+
const { mintUrl, baseUrl, token: providedToken } = options;
|
|
604
|
+
const storedToken = providedToken || this.storageAdapter.getToken(baseUrl);
|
|
605
|
+
if (!storedToken) {
|
|
606
|
+
console.log("[BalanceManager] No token to refund, returning early");
|
|
607
|
+
return { success: true, message: "No API key to refund" };
|
|
608
|
+
}
|
|
609
|
+
let fetchResult;
|
|
610
|
+
try {
|
|
611
|
+
fetchResult = await this._fetchRefundToken(baseUrl, storedToken);
|
|
612
|
+
if (!fetchResult.success) {
|
|
613
|
+
return {
|
|
614
|
+
success: false,
|
|
615
|
+
message: fetchResult.error || "Refund failed",
|
|
616
|
+
requestId: fetchResult.requestId
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
if (!fetchResult.token) {
|
|
620
|
+
return {
|
|
621
|
+
success: false,
|
|
622
|
+
message: "No token received from refund",
|
|
623
|
+
requestId: fetchResult.requestId
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (fetchResult.error === "No balance to refund") {
|
|
627
|
+
console.log(
|
|
628
|
+
"[BalanceManager] No balance to refund, removing stored token"
|
|
629
|
+
);
|
|
630
|
+
this.storageAdapter.removeToken(baseUrl);
|
|
631
|
+
return { success: true, message: "No balance to refund" };
|
|
632
|
+
}
|
|
633
|
+
const receiveResult = await this.cashuSpender.receiveToken(
|
|
634
|
+
fetchResult.token
|
|
635
|
+
);
|
|
636
|
+
const totalAmountMsat = receiveResult.unit === "msat" ? receiveResult.amount : receiveResult.amount * 1e3;
|
|
637
|
+
if (!providedToken) {
|
|
638
|
+
this.storageAdapter.removeToken(baseUrl);
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
success: receiveResult.success,
|
|
642
|
+
refundedAmount: totalAmountMsat,
|
|
643
|
+
requestId: fetchResult.requestId
|
|
644
|
+
};
|
|
645
|
+
} catch (error) {
|
|
646
|
+
console.error("[BalanceManager] Refund error", error);
|
|
647
|
+
return this._handleRefundError(error, mintUrl, fetchResult?.requestId);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Refund API key balance - convert remaining API key balance to cashu token
|
|
652
|
+
*/
|
|
653
|
+
async refundApiKey(options) {
|
|
654
|
+
const { mintUrl, baseUrl, apiKey } = options;
|
|
655
|
+
if (!apiKey) {
|
|
656
|
+
return { success: false, message: "No API key to refund" };
|
|
657
|
+
}
|
|
658
|
+
let fetchResult;
|
|
659
|
+
try {
|
|
660
|
+
fetchResult = await this._fetchRefundTokenWithApiKey(baseUrl, apiKey);
|
|
661
|
+
if (!fetchResult.success) {
|
|
662
|
+
return {
|
|
663
|
+
success: false,
|
|
664
|
+
message: fetchResult.error || "API key refund failed",
|
|
665
|
+
requestId: fetchResult.requestId
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
if (!fetchResult.token) {
|
|
669
|
+
return {
|
|
670
|
+
success: false,
|
|
671
|
+
message: "No token received from API key refund",
|
|
672
|
+
requestId: fetchResult.requestId
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
if (fetchResult.error === "No balance to refund") {
|
|
676
|
+
return { success: false, message: "No balance to refund" };
|
|
677
|
+
}
|
|
678
|
+
const receiveResult = await this.cashuSpender.receiveToken(
|
|
679
|
+
fetchResult.token
|
|
680
|
+
);
|
|
681
|
+
const totalAmountMsat = receiveResult.unit === "msat" ? receiveResult.amount : receiveResult.amount * 1e3;
|
|
682
|
+
return {
|
|
683
|
+
success: receiveResult.success,
|
|
684
|
+
refundedAmount: totalAmountMsat,
|
|
685
|
+
requestId: fetchResult.requestId
|
|
686
|
+
};
|
|
687
|
+
} catch (error) {
|
|
688
|
+
console.error("[BalanceManager] API key refund error", error);
|
|
689
|
+
return this._handleRefundError(error, mintUrl, fetchResult?.requestId);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Fetch refund token from provider API using API key authentication
|
|
694
|
+
*/
|
|
695
|
+
async _fetchRefundTokenWithApiKey(baseUrl, apiKey) {
|
|
696
|
+
if (!baseUrl) {
|
|
697
|
+
return {
|
|
698
|
+
success: false,
|
|
699
|
+
error: "No base URL configured"
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
703
|
+
const url = `${normalizedBaseUrl}v1/wallet/refund`;
|
|
704
|
+
const controller = new AbortController();
|
|
705
|
+
const timeoutId = setTimeout(() => {
|
|
706
|
+
controller.abort();
|
|
707
|
+
}, 6e4);
|
|
708
|
+
try {
|
|
709
|
+
const response = await fetch(url, {
|
|
710
|
+
method: "POST",
|
|
711
|
+
headers: {
|
|
712
|
+
Authorization: `Bearer ${apiKey}`,
|
|
713
|
+
"Content-Type": "application/json"
|
|
714
|
+
},
|
|
715
|
+
signal: controller.signal
|
|
716
|
+
});
|
|
717
|
+
clearTimeout(timeoutId);
|
|
718
|
+
const requestId = response.headers.get("x-routstr-request-id") || void 0;
|
|
719
|
+
if (!response.ok) {
|
|
720
|
+
const errorData = await response.json().catch(() => ({}));
|
|
721
|
+
return {
|
|
722
|
+
success: false,
|
|
723
|
+
requestId,
|
|
724
|
+
error: `API key refund failed: ${errorData?.detail || response.statusText}`
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
const data = await response.json();
|
|
728
|
+
return {
|
|
729
|
+
success: true,
|
|
730
|
+
token: data.token,
|
|
731
|
+
requestId
|
|
732
|
+
};
|
|
733
|
+
} catch (error) {
|
|
734
|
+
clearTimeout(timeoutId);
|
|
735
|
+
console.error(
|
|
736
|
+
"[BalanceManager._fetchRefundTokenWithApiKey] Fetch error",
|
|
737
|
+
error
|
|
738
|
+
);
|
|
739
|
+
if (error instanceof Error) {
|
|
740
|
+
if (error.name === "AbortError") {
|
|
741
|
+
return {
|
|
742
|
+
success: false,
|
|
743
|
+
error: "Request timed out after 1 minute"
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
success: false,
|
|
748
|
+
error: error.message
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
success: false,
|
|
753
|
+
error: "Unknown error occurred during API key refund request"
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Top up API key balance with a cashu token
|
|
759
|
+
*/
|
|
760
|
+
async topUp(options) {
|
|
761
|
+
const { mintUrl, baseUrl, amount, token: providedToken } = options;
|
|
762
|
+
if (!amount || amount <= 0) {
|
|
763
|
+
return { success: false, message: "Invalid top up amount" };
|
|
764
|
+
}
|
|
765
|
+
const storedToken = providedToken || this.storageAdapter.getToken(baseUrl);
|
|
766
|
+
if (!storedToken) {
|
|
767
|
+
return { success: false, message: "No API key available for top up" };
|
|
768
|
+
}
|
|
769
|
+
let cashuToken = null;
|
|
770
|
+
let requestId;
|
|
771
|
+
try {
|
|
772
|
+
const tokenResult = await this.createProviderToken({
|
|
773
|
+
mintUrl,
|
|
774
|
+
baseUrl,
|
|
775
|
+
amount
|
|
776
|
+
});
|
|
777
|
+
if (!tokenResult.success || !tokenResult.token) {
|
|
778
|
+
return {
|
|
779
|
+
success: false,
|
|
780
|
+
message: tokenResult.error || "Unable to create top up token"
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
cashuToken = tokenResult.token;
|
|
784
|
+
const topUpResult = await this._postTopUp(
|
|
785
|
+
baseUrl,
|
|
786
|
+
storedToken,
|
|
787
|
+
cashuToken
|
|
788
|
+
);
|
|
789
|
+
requestId = topUpResult.requestId;
|
|
790
|
+
console.log(topUpResult);
|
|
791
|
+
if (!topUpResult.success) {
|
|
792
|
+
await this._recoverFailedTopUp(cashuToken);
|
|
793
|
+
return {
|
|
794
|
+
success: false,
|
|
795
|
+
message: topUpResult.error || "Top up failed",
|
|
796
|
+
requestId,
|
|
797
|
+
recoveredToken: true
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
success: true,
|
|
802
|
+
toppedUpAmount: amount,
|
|
803
|
+
requestId
|
|
804
|
+
};
|
|
805
|
+
} catch (error) {
|
|
806
|
+
if (cashuToken) {
|
|
807
|
+
await this._recoverFailedTopUp(cashuToken);
|
|
808
|
+
}
|
|
809
|
+
return this._handleTopUpError(error, mintUrl, requestId);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
async createProviderToken(options) {
|
|
813
|
+
const {
|
|
814
|
+
mintUrl,
|
|
815
|
+
baseUrl,
|
|
816
|
+
amount,
|
|
817
|
+
retryCount = 0,
|
|
818
|
+
excludeMints = [],
|
|
819
|
+
p2pkPubkey
|
|
820
|
+
} = options;
|
|
821
|
+
const adjustedAmount = Math.ceil(amount);
|
|
822
|
+
if (!adjustedAmount || isNaN(adjustedAmount)) {
|
|
823
|
+
return { success: false, error: "Invalid top up amount" };
|
|
824
|
+
}
|
|
825
|
+
const balances = await this.walletAdapter.getBalances();
|
|
826
|
+
const units = this.walletAdapter.getMintUnits();
|
|
827
|
+
let totalMintBalance = 0;
|
|
828
|
+
for (const url in balances) {
|
|
829
|
+
const unit = units[url];
|
|
830
|
+
const balanceInSats = getBalanceInSats(balances[url], unit);
|
|
831
|
+
totalMintBalance += balanceInSats;
|
|
832
|
+
}
|
|
833
|
+
const pendingDistribution = this.storageAdapter.getCachedTokenDistribution();
|
|
834
|
+
const refundablePending = pendingDistribution.filter((entry) => entry.baseUrl !== baseUrl).reduce((sum, entry) => sum + entry.amount, 0);
|
|
835
|
+
if (totalMintBalance < adjustedAmount && totalMintBalance + refundablePending >= adjustedAmount && retryCount < 1) {
|
|
836
|
+
await this._refundOtherProvidersForTopUp(baseUrl, mintUrl);
|
|
837
|
+
return this.createProviderToken({
|
|
838
|
+
...options,
|
|
839
|
+
retryCount: retryCount + 1
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
const providerMints = baseUrl && this.providerRegistry ? this.providerRegistry.getProviderMints(baseUrl) : [];
|
|
843
|
+
let requiredAmount = adjustedAmount;
|
|
844
|
+
const supportedMintsOnly = providerMints.length > 0;
|
|
845
|
+
let candidates = this._selectCandidateMints({
|
|
846
|
+
balances,
|
|
847
|
+
units,
|
|
848
|
+
amount: requiredAmount,
|
|
849
|
+
preferredMintUrl: mintUrl,
|
|
850
|
+
excludeMints,
|
|
851
|
+
allowedMints: supportedMintsOnly ? providerMints : void 0
|
|
852
|
+
});
|
|
853
|
+
if (candidates.length === 0 && supportedMintsOnly) {
|
|
854
|
+
requiredAmount += 2;
|
|
855
|
+
candidates = this._selectCandidateMints({
|
|
856
|
+
balances,
|
|
857
|
+
units,
|
|
858
|
+
amount: requiredAmount,
|
|
859
|
+
preferredMintUrl: mintUrl,
|
|
860
|
+
excludeMints
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
if (candidates.length === 0) {
|
|
864
|
+
let maxBalance = 0;
|
|
865
|
+
let maxMintUrl = "";
|
|
866
|
+
for (const mintUrl2 in balances) {
|
|
867
|
+
const balance = balances[mintUrl2];
|
|
868
|
+
const unit = units[mintUrl2];
|
|
869
|
+
const balanceInSats = getBalanceInSats(balance, unit);
|
|
870
|
+
if (balanceInSats > maxBalance) {
|
|
871
|
+
maxBalance = balanceInSats;
|
|
872
|
+
maxMintUrl = mintUrl2;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const error = new InsufficientBalanceError(
|
|
876
|
+
adjustedAmount,
|
|
877
|
+
totalMintBalance,
|
|
878
|
+
maxBalance,
|
|
879
|
+
maxMintUrl
|
|
880
|
+
);
|
|
881
|
+
return { success: false, error: error.message };
|
|
882
|
+
}
|
|
883
|
+
let lastError;
|
|
884
|
+
for (const candidateMint of candidates) {
|
|
885
|
+
try {
|
|
886
|
+
const token = await this.walletAdapter.sendToken(
|
|
887
|
+
candidateMint,
|
|
888
|
+
requiredAmount,
|
|
889
|
+
p2pkPubkey
|
|
890
|
+
);
|
|
891
|
+
return {
|
|
892
|
+
success: true,
|
|
893
|
+
token,
|
|
894
|
+
selectedMintUrl: candidateMint,
|
|
895
|
+
amountSpent: requiredAmount
|
|
896
|
+
};
|
|
897
|
+
} catch (error) {
|
|
898
|
+
if (error instanceof Error) {
|
|
899
|
+
lastError = error.message;
|
|
900
|
+
if (isNetworkErrorMessage(error.message)) {
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return {
|
|
905
|
+
success: false,
|
|
906
|
+
error: lastError || "Failed to create top up token"
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
success: false,
|
|
912
|
+
error: lastError || "All candidate mints failed while creating top up token"
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
_selectCandidateMints(options) {
|
|
916
|
+
const {
|
|
917
|
+
balances,
|
|
918
|
+
units,
|
|
919
|
+
amount,
|
|
920
|
+
preferredMintUrl,
|
|
921
|
+
excludeMints,
|
|
922
|
+
allowedMints
|
|
923
|
+
} = options;
|
|
924
|
+
const candidates = [];
|
|
925
|
+
const { selectedMintUrl: firstMint } = selectMintWithBalance(
|
|
926
|
+
balances,
|
|
927
|
+
units,
|
|
928
|
+
amount,
|
|
929
|
+
excludeMints
|
|
930
|
+
);
|
|
931
|
+
if (firstMint && (!allowedMints || allowedMints.length === 0 || allowedMints.includes(firstMint))) {
|
|
932
|
+
candidates.push(firstMint);
|
|
933
|
+
}
|
|
934
|
+
const canUseMint = (mint) => {
|
|
935
|
+
if (excludeMints.includes(mint)) return false;
|
|
936
|
+
if (allowedMints && allowedMints.length > 0 && !allowedMints.includes(mint)) {
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
const rawBalance = balances[mint] || 0;
|
|
940
|
+
const unit = units[mint];
|
|
941
|
+
const balanceInSats = getBalanceInSats(rawBalance, unit);
|
|
942
|
+
return balanceInSats >= amount;
|
|
943
|
+
};
|
|
944
|
+
if (preferredMintUrl && canUseMint(preferredMintUrl) && !candidates.includes(preferredMintUrl)) {
|
|
945
|
+
candidates.push(preferredMintUrl);
|
|
946
|
+
}
|
|
947
|
+
for (const mint in balances) {
|
|
948
|
+
if (mint === preferredMintUrl || candidates.includes(mint)) continue;
|
|
949
|
+
if (canUseMint(mint)) {
|
|
950
|
+
candidates.push(mint);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return candidates;
|
|
954
|
+
}
|
|
955
|
+
async _refundOtherProvidersForTopUp(baseUrl, mintUrl) {
|
|
956
|
+
const pendingDistribution = this.storageAdapter.getCachedTokenDistribution();
|
|
957
|
+
const toRefund = pendingDistribution.filter(
|
|
958
|
+
(pending) => pending.baseUrl !== baseUrl
|
|
959
|
+
);
|
|
960
|
+
const refundResults = await Promise.allSettled(
|
|
961
|
+
toRefund.map(async (pending) => {
|
|
962
|
+
const token = this.storageAdapter.getToken(pending.baseUrl);
|
|
963
|
+
if (!token) {
|
|
964
|
+
return { baseUrl: pending.baseUrl, success: false };
|
|
965
|
+
}
|
|
966
|
+
const tokenBalance = await this.getTokenBalance(token, pending.baseUrl);
|
|
967
|
+
if (tokenBalance.reserved > 0) {
|
|
968
|
+
return { baseUrl: pending.baseUrl, success: false };
|
|
969
|
+
}
|
|
970
|
+
const result = await this.refund({
|
|
971
|
+
mintUrl,
|
|
972
|
+
baseUrl: pending.baseUrl,
|
|
973
|
+
token
|
|
974
|
+
});
|
|
975
|
+
return { baseUrl: pending.baseUrl, success: result.success };
|
|
976
|
+
})
|
|
977
|
+
);
|
|
978
|
+
for (const result of refundResults) {
|
|
979
|
+
if (result.status === "fulfilled" && result.value.success) {
|
|
980
|
+
this.storageAdapter.removeToken(result.value.baseUrl);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Fetch refund token from provider API
|
|
986
|
+
*/
|
|
987
|
+
async _fetchRefundToken(baseUrl, storedToken) {
|
|
988
|
+
if (!baseUrl) {
|
|
989
|
+
return {
|
|
990
|
+
success: false,
|
|
991
|
+
error: "No base URL configured"
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
995
|
+
const url = `${normalizedBaseUrl}v1/wallet/refund`;
|
|
996
|
+
const controller = new AbortController();
|
|
997
|
+
const timeoutId = setTimeout(() => {
|
|
998
|
+
controller.abort();
|
|
999
|
+
}, 6e4);
|
|
1000
|
+
try {
|
|
1001
|
+
const response = await fetch(url, {
|
|
1002
|
+
method: "POST",
|
|
1003
|
+
headers: {
|
|
1004
|
+
Authorization: `Bearer ${storedToken}`,
|
|
1005
|
+
"Content-Type": "application/json"
|
|
1006
|
+
},
|
|
1007
|
+
signal: controller.signal
|
|
1008
|
+
});
|
|
1009
|
+
clearTimeout(timeoutId);
|
|
1010
|
+
const requestId = response.headers.get("x-routstr-request-id") || void 0;
|
|
1011
|
+
if (!response.ok) {
|
|
1012
|
+
const errorData = await response.json().catch(() => ({}));
|
|
1013
|
+
if (response.status === 400 && errorData?.detail === "No balance to refund") {
|
|
1014
|
+
this.storageAdapter.removeToken(baseUrl);
|
|
1015
|
+
return {
|
|
1016
|
+
success: false,
|
|
1017
|
+
requestId,
|
|
1018
|
+
error: "No balance to refund"
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
return {
|
|
1022
|
+
success: false,
|
|
1023
|
+
requestId,
|
|
1024
|
+
error: `Refund request failed with status ${response.status}: ${errorData?.detail || response.statusText}`
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
const data = await response.json();
|
|
1028
|
+
console.log("refund rsule", data);
|
|
1029
|
+
return {
|
|
1030
|
+
success: true,
|
|
1031
|
+
token: data.token,
|
|
1032
|
+
requestId
|
|
1033
|
+
};
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
clearTimeout(timeoutId);
|
|
1036
|
+
console.error("[BalanceManager._fetchRefundToken] Fetch error", error);
|
|
1037
|
+
if (error instanceof Error) {
|
|
1038
|
+
if (error.name === "AbortError") {
|
|
1039
|
+
return {
|
|
1040
|
+
success: false,
|
|
1041
|
+
error: "Request timed out after 1 minute"
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
return {
|
|
1045
|
+
success: false,
|
|
1046
|
+
error: error.message
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
return {
|
|
1050
|
+
success: false,
|
|
1051
|
+
error: "Unknown error occurred during refund request"
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Post topup request to provider API
|
|
1057
|
+
*/
|
|
1058
|
+
async _postTopUp(baseUrl, storedToken, cashuToken) {
|
|
1059
|
+
if (!baseUrl) {
|
|
1060
|
+
return {
|
|
1061
|
+
success: false,
|
|
1062
|
+
error: "No base URL configured"
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
1066
|
+
const url = `${normalizedBaseUrl}v1/wallet/topup?cashu_token=${encodeURIComponent(
|
|
1067
|
+
cashuToken
|
|
1068
|
+
)}`;
|
|
1069
|
+
const controller = new AbortController();
|
|
1070
|
+
const timeoutId = setTimeout(() => {
|
|
1071
|
+
controller.abort();
|
|
1072
|
+
}, 6e4);
|
|
1073
|
+
try {
|
|
1074
|
+
const response = await fetch(url, {
|
|
1075
|
+
method: "POST",
|
|
1076
|
+
headers: {
|
|
1077
|
+
Authorization: `Bearer ${storedToken}`,
|
|
1078
|
+
"Content-Type": "application/json"
|
|
1079
|
+
},
|
|
1080
|
+
signal: controller.signal
|
|
1081
|
+
});
|
|
1082
|
+
clearTimeout(timeoutId);
|
|
1083
|
+
const requestId = response.headers.get("x-routstr-request-id") || void 0;
|
|
1084
|
+
if (!response.ok) {
|
|
1085
|
+
const errorData = await response.json().catch(() => ({}));
|
|
1086
|
+
return {
|
|
1087
|
+
success: false,
|
|
1088
|
+
requestId,
|
|
1089
|
+
error: errorData?.detail || `Top up failed with status ${response.status}`
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
return { success: true, requestId };
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
clearTimeout(timeoutId);
|
|
1095
|
+
console.error("[BalanceManager._postTopUp] Fetch error", error);
|
|
1096
|
+
if (error instanceof Error) {
|
|
1097
|
+
if (error.name === "AbortError") {
|
|
1098
|
+
return {
|
|
1099
|
+
success: false,
|
|
1100
|
+
error: "Request timed out after 1 minute"
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
return {
|
|
1104
|
+
success: false,
|
|
1105
|
+
error: error.message
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
return {
|
|
1109
|
+
success: false,
|
|
1110
|
+
error: "Unknown error occurred during top up request"
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Attempt to receive token back after failed top up
|
|
1116
|
+
*/
|
|
1117
|
+
async _recoverFailedTopUp(cashuToken) {
|
|
1118
|
+
try {
|
|
1119
|
+
await this.cashuSpender.receiveToken(cashuToken);
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
console.error(
|
|
1122
|
+
"[BalanceManager._recoverFailedTopUp] Failed to recover token",
|
|
1123
|
+
error
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Handle refund errors with specific error types
|
|
1129
|
+
*/
|
|
1130
|
+
_handleRefundError(error, mintUrl, requestId) {
|
|
1131
|
+
if (error instanceof Error) {
|
|
1132
|
+
if (isNetworkErrorMessage(error.message)) {
|
|
1133
|
+
return {
|
|
1134
|
+
success: false,
|
|
1135
|
+
message: `Failed to connect to the mint: ${mintUrl}`,
|
|
1136
|
+
requestId
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
if (error.message.includes("Wallet not found")) {
|
|
1140
|
+
return {
|
|
1141
|
+
success: false,
|
|
1142
|
+
message: `Wallet couldn't be loaded. Please save this refunded cashu token manually.`,
|
|
1143
|
+
requestId
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
return {
|
|
1147
|
+
success: false,
|
|
1148
|
+
message: error.message,
|
|
1149
|
+
requestId
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
return {
|
|
1153
|
+
success: false,
|
|
1154
|
+
message: "Refund failed",
|
|
1155
|
+
requestId
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Get token balance from provider
|
|
1160
|
+
*/
|
|
1161
|
+
async getTokenBalance(token, baseUrl) {
|
|
1162
|
+
try {
|
|
1163
|
+
const response = await fetch(`${baseUrl}v1/wallet/info`, {
|
|
1164
|
+
headers: {
|
|
1165
|
+
Authorization: `Bearer ${token}`
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
if (response.ok) {
|
|
1169
|
+
const data = await response.json();
|
|
1170
|
+
console.log("TOKENA FASJDFAS", data);
|
|
1171
|
+
return {
|
|
1172
|
+
amount: data.balance,
|
|
1173
|
+
reserved: data.reserved ?? 0,
|
|
1174
|
+
unit: "msat",
|
|
1175
|
+
apiKey: data.api_key
|
|
1176
|
+
};
|
|
1177
|
+
} else {
|
|
1178
|
+
console.log(response.status);
|
|
1179
|
+
const data = await response.json();
|
|
1180
|
+
console.log("FAILED ", data);
|
|
1181
|
+
}
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
console.error("ERRORR IN RESTPONSE", error);
|
|
1184
|
+
}
|
|
1185
|
+
return { amount: -1, reserved: 0, unit: "sat", apiKey: "" };
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Handle topup errors with specific error types
|
|
1189
|
+
*/
|
|
1190
|
+
_handleTopUpError(error, mintUrl, requestId) {
|
|
1191
|
+
if (error instanceof Error) {
|
|
1192
|
+
if (isNetworkErrorMessage(error.message)) {
|
|
1193
|
+
return {
|
|
1194
|
+
success: false,
|
|
1195
|
+
message: `Failed to connect to the mint: ${mintUrl}`,
|
|
1196
|
+
requestId
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
if (error.message.includes("Wallet not found")) {
|
|
1200
|
+
return {
|
|
1201
|
+
success: false,
|
|
1202
|
+
message: "Wallet couldn't be loaded. The cashu token was recovered locally.",
|
|
1203
|
+
requestId
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
return {
|
|
1207
|
+
success: false,
|
|
1208
|
+
message: error.message,
|
|
1209
|
+
requestId
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
return {
|
|
1213
|
+
success: false,
|
|
1214
|
+
message: "Top up failed",
|
|
1215
|
+
requestId
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
// client/StreamProcessor.ts
|
|
1221
|
+
var StreamProcessor = class {
|
|
1222
|
+
accumulatedContent = "";
|
|
1223
|
+
accumulatedThinking = "";
|
|
1224
|
+
accumulatedImages = [];
|
|
1225
|
+
isInThinking = false;
|
|
1226
|
+
isInContent = false;
|
|
1227
|
+
/**
|
|
1228
|
+
* Process a streaming response
|
|
1229
|
+
*/
|
|
1230
|
+
async process(response, callbacks, modelId) {
|
|
1231
|
+
if (!response.body) {
|
|
1232
|
+
throw new Error("Response body is not available");
|
|
1233
|
+
}
|
|
1234
|
+
const reader = response.body.getReader();
|
|
1235
|
+
const decoder = new TextDecoder("utf-8");
|
|
1236
|
+
let buffer = "";
|
|
1237
|
+
this.accumulatedContent = "";
|
|
1238
|
+
this.accumulatedThinking = "";
|
|
1239
|
+
this.accumulatedImages = [];
|
|
1240
|
+
this.isInThinking = false;
|
|
1241
|
+
this.isInContent = false;
|
|
1242
|
+
let usage;
|
|
1243
|
+
let model;
|
|
1244
|
+
let finish_reason;
|
|
1245
|
+
let citations;
|
|
1246
|
+
let annotations;
|
|
1247
|
+
try {
|
|
1248
|
+
while (true) {
|
|
1249
|
+
const { done, value } = await reader.read();
|
|
1250
|
+
if (done) {
|
|
1251
|
+
break;
|
|
1252
|
+
}
|
|
1253
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
1254
|
+
buffer += chunk;
|
|
1255
|
+
const lines = buffer.split("\n");
|
|
1256
|
+
buffer = lines.pop() || "";
|
|
1257
|
+
for (const line of lines) {
|
|
1258
|
+
const parsed = this._parseLine(line);
|
|
1259
|
+
if (!parsed) continue;
|
|
1260
|
+
if (parsed.content) {
|
|
1261
|
+
this._handleContent(parsed.content, callbacks, modelId);
|
|
1262
|
+
}
|
|
1263
|
+
if (parsed.reasoning) {
|
|
1264
|
+
this._handleThinking(parsed.reasoning, callbacks);
|
|
1265
|
+
}
|
|
1266
|
+
if (parsed.usage) {
|
|
1267
|
+
usage = parsed.usage;
|
|
1268
|
+
}
|
|
1269
|
+
if (parsed.model) {
|
|
1270
|
+
model = parsed.model;
|
|
1271
|
+
}
|
|
1272
|
+
if (parsed.finish_reason) {
|
|
1273
|
+
finish_reason = parsed.finish_reason;
|
|
1274
|
+
}
|
|
1275
|
+
if (parsed.citations) {
|
|
1276
|
+
citations = parsed.citations;
|
|
1277
|
+
}
|
|
1278
|
+
if (parsed.annotations) {
|
|
1279
|
+
annotations = parsed.annotations;
|
|
1280
|
+
}
|
|
1281
|
+
if (parsed.images) {
|
|
1282
|
+
this._mergeImages(parsed.images);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
} finally {
|
|
1287
|
+
reader.releaseLock();
|
|
1288
|
+
}
|
|
1289
|
+
return {
|
|
1290
|
+
content: this.accumulatedContent,
|
|
1291
|
+
thinking: this.accumulatedThinking || void 0,
|
|
1292
|
+
images: this.accumulatedImages.length > 0 ? this.accumulatedImages : void 0,
|
|
1293
|
+
usage,
|
|
1294
|
+
model,
|
|
1295
|
+
finish_reason,
|
|
1296
|
+
citations,
|
|
1297
|
+
annotations
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Parse a single SSE line
|
|
1302
|
+
*/
|
|
1303
|
+
_parseLine(line) {
|
|
1304
|
+
if (!line.trim()) return null;
|
|
1305
|
+
if (!line.startsWith("data: ")) {
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
const jsonData = line.slice(6);
|
|
1309
|
+
if (jsonData === "[DONE]") {
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
try {
|
|
1313
|
+
const parsed = JSON.parse(jsonData);
|
|
1314
|
+
const result = {};
|
|
1315
|
+
if (parsed.choices?.[0]?.delta?.content) {
|
|
1316
|
+
result.content = parsed.choices[0].delta.content;
|
|
1317
|
+
}
|
|
1318
|
+
if (parsed.choices?.[0]?.delta?.reasoning) {
|
|
1319
|
+
result.reasoning = parsed.choices[0].delta.reasoning;
|
|
1320
|
+
}
|
|
1321
|
+
if (parsed.usage) {
|
|
1322
|
+
result.usage = {
|
|
1323
|
+
total_tokens: parsed.usage.total_tokens,
|
|
1324
|
+
prompt_tokens: parsed.usage.prompt_tokens,
|
|
1325
|
+
completion_tokens: parsed.usage.completion_tokens
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
if (parsed.model) {
|
|
1329
|
+
result.model = parsed.model;
|
|
1330
|
+
}
|
|
1331
|
+
if (parsed.citations) {
|
|
1332
|
+
result.citations = parsed.citations;
|
|
1333
|
+
}
|
|
1334
|
+
if (parsed.annotations) {
|
|
1335
|
+
result.annotations = parsed.annotations;
|
|
1336
|
+
}
|
|
1337
|
+
if (parsed.choices?.[0]?.finish_reason) {
|
|
1338
|
+
result.finish_reason = parsed.choices[0].finish_reason;
|
|
1339
|
+
}
|
|
1340
|
+
const images = parsed.choices?.[0]?.message?.images || parsed.choices?.[0]?.delta?.images;
|
|
1341
|
+
if (images && Array.isArray(images)) {
|
|
1342
|
+
result.images = images;
|
|
1343
|
+
}
|
|
1344
|
+
return result;
|
|
1345
|
+
} catch {
|
|
1346
|
+
return null;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Handle content delta with thinking support
|
|
1351
|
+
*/
|
|
1352
|
+
_handleContent(content, callbacks, modelId) {
|
|
1353
|
+
if (this.isInThinking && !this.isInContent) {
|
|
1354
|
+
this.accumulatedThinking += "</thinking>";
|
|
1355
|
+
callbacks.onThinking(this.accumulatedThinking);
|
|
1356
|
+
this.isInThinking = false;
|
|
1357
|
+
this.isInContent = true;
|
|
1358
|
+
}
|
|
1359
|
+
if (modelId) {
|
|
1360
|
+
this._extractThinkingFromContent(content, callbacks);
|
|
1361
|
+
} else {
|
|
1362
|
+
this.accumulatedContent += content;
|
|
1363
|
+
}
|
|
1364
|
+
callbacks.onContent(this.accumulatedContent);
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Handle thinking/reasoning content
|
|
1368
|
+
*/
|
|
1369
|
+
_handleThinking(reasoning, callbacks) {
|
|
1370
|
+
if (!this.isInThinking) {
|
|
1371
|
+
this.accumulatedThinking += "<thinking> ";
|
|
1372
|
+
this.isInThinking = true;
|
|
1373
|
+
}
|
|
1374
|
+
this.accumulatedThinking += reasoning;
|
|
1375
|
+
callbacks.onThinking(this.accumulatedThinking);
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* Extract thinking blocks from content (for models with inline thinking)
|
|
1379
|
+
*/
|
|
1380
|
+
_extractThinkingFromContent(content, callbacks) {
|
|
1381
|
+
const parts = content.split(/(<thinking>|<\/thinking>)/);
|
|
1382
|
+
for (const part of parts) {
|
|
1383
|
+
if (part === "<thinking>") {
|
|
1384
|
+
this.isInThinking = true;
|
|
1385
|
+
if (!this.accumulatedThinking.includes("<thinking>")) {
|
|
1386
|
+
this.accumulatedThinking += "<thinking> ";
|
|
1387
|
+
}
|
|
1388
|
+
} else if (part === "</thinking>") {
|
|
1389
|
+
this.isInThinking = false;
|
|
1390
|
+
this.accumulatedThinking += "</thinking>";
|
|
1391
|
+
} else if (this.isInThinking) {
|
|
1392
|
+
this.accumulatedThinking += part;
|
|
1393
|
+
} else {
|
|
1394
|
+
this.accumulatedContent += part;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Merge images into accumulated array, avoiding duplicates
|
|
1400
|
+
*/
|
|
1401
|
+
_mergeImages(newImages) {
|
|
1402
|
+
for (const img of newImages) {
|
|
1403
|
+
const newUrl = img.image_url?.url;
|
|
1404
|
+
const existingIndex = this.accumulatedImages.findIndex((existing) => {
|
|
1405
|
+
const existingUrl = existing.image_url?.url;
|
|
1406
|
+
if (newUrl && existingUrl) {
|
|
1407
|
+
return existingUrl === newUrl;
|
|
1408
|
+
}
|
|
1409
|
+
if (img.index !== void 0 && existing.index !== void 0) {
|
|
1410
|
+
return existing.index === img.index;
|
|
1411
|
+
}
|
|
1412
|
+
return false;
|
|
1413
|
+
});
|
|
1414
|
+
if (existingIndex === -1) {
|
|
1415
|
+
this.accumulatedImages.push(img);
|
|
1416
|
+
} else {
|
|
1417
|
+
this.accumulatedImages[existingIndex] = img;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
// utils/torUtils.ts
|
|
1424
|
+
var TOR_ONION_SUFFIX = ".onion";
|
|
1425
|
+
var isTorContext = () => {
|
|
1426
|
+
if (typeof window === "undefined") return false;
|
|
1427
|
+
const hostname = window.location.hostname.toLowerCase();
|
|
1428
|
+
return hostname.endsWith(TOR_ONION_SUFFIX);
|
|
1429
|
+
};
|
|
1430
|
+
var isOnionUrl = (url) => {
|
|
1431
|
+
if (!url) return false;
|
|
1432
|
+
const trimmed = url.trim().toLowerCase();
|
|
1433
|
+
if (!trimmed) return false;
|
|
1434
|
+
try {
|
|
1435
|
+
const candidate = trimmed.startsWith("http") ? trimmed : `http://${trimmed}`;
|
|
1436
|
+
return new URL(candidate).hostname.endsWith(TOR_ONION_SUFFIX);
|
|
1437
|
+
} catch {
|
|
1438
|
+
return trimmed.includes(TOR_ONION_SUFFIX);
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
// client/ProviderManager.ts
|
|
1443
|
+
function getImageResolutionFromDataUrl(dataUrl) {
|
|
1444
|
+
try {
|
|
1445
|
+
if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:"))
|
|
1446
|
+
return null;
|
|
1447
|
+
const commaIdx = dataUrl.indexOf(",");
|
|
1448
|
+
if (commaIdx === -1) return null;
|
|
1449
|
+
const meta = dataUrl.slice(5, commaIdx);
|
|
1450
|
+
const base64 = dataUrl.slice(commaIdx + 1);
|
|
1451
|
+
const binary = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("binary");
|
|
1452
|
+
const len = binary.length;
|
|
1453
|
+
const bytes = new Uint8Array(len);
|
|
1454
|
+
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
|
|
1455
|
+
const isPNG = meta.includes("image/png");
|
|
1456
|
+
const isJPEG = meta.includes("image/jpeg") || meta.includes("image/jpg");
|
|
1457
|
+
if (isPNG) {
|
|
1458
|
+
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
1459
|
+
for (let i = 0; i < sig.length; i++) {
|
|
1460
|
+
if (bytes[i] !== sig[i]) return null;
|
|
1461
|
+
}
|
|
1462
|
+
const view = new DataView(
|
|
1463
|
+
bytes.buffer,
|
|
1464
|
+
bytes.byteOffset,
|
|
1465
|
+
bytes.byteLength
|
|
1466
|
+
);
|
|
1467
|
+
const width = view.getUint32(16, false);
|
|
1468
|
+
const height = view.getUint32(20, false);
|
|
1469
|
+
if (width > 0 && height > 0) return { width, height };
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
if (isJPEG) {
|
|
1473
|
+
let offset = 0;
|
|
1474
|
+
if (bytes[offset++] !== 255 || bytes[offset++] !== 216) return null;
|
|
1475
|
+
while (offset < bytes.length) {
|
|
1476
|
+
while (offset < bytes.length && bytes[offset] !== 255) offset++;
|
|
1477
|
+
if (offset + 1 >= bytes.length) break;
|
|
1478
|
+
while (bytes[offset] === 255) offset++;
|
|
1479
|
+
const marker = bytes[offset++];
|
|
1480
|
+
if (marker === 216 || marker === 217) continue;
|
|
1481
|
+
if (offset + 1 >= bytes.length) break;
|
|
1482
|
+
const length = bytes[offset] << 8 | bytes[offset + 1];
|
|
1483
|
+
offset += 2;
|
|
1484
|
+
if (marker === 192 || marker === 194) {
|
|
1485
|
+
if (length < 7 || offset + length - 2 > bytes.length) return null;
|
|
1486
|
+
const precision = bytes[offset];
|
|
1487
|
+
const height = bytes[offset + 1] << 8 | bytes[offset + 2];
|
|
1488
|
+
const width = bytes[offset + 3] << 8 | bytes[offset + 4];
|
|
1489
|
+
if (precision > 0 && width > 0 && height > 0)
|
|
1490
|
+
return { width, height };
|
|
1491
|
+
return null;
|
|
1492
|
+
} else {
|
|
1493
|
+
offset += length - 2;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return null;
|
|
1497
|
+
}
|
|
1498
|
+
return null;
|
|
1499
|
+
} catch {
|
|
1500
|
+
return null;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
function calculateImageTokens(width, height, detail = "auto") {
|
|
1504
|
+
if (detail === "low") return 85;
|
|
1505
|
+
let w = width;
|
|
1506
|
+
let h = height;
|
|
1507
|
+
if (w > 2048 || h > 2048) {
|
|
1508
|
+
const aspectRatio = w / h;
|
|
1509
|
+
if (w > h) {
|
|
1510
|
+
w = 2048;
|
|
1511
|
+
h = Math.floor(w / aspectRatio);
|
|
1512
|
+
} else {
|
|
1513
|
+
h = 2048;
|
|
1514
|
+
w = Math.floor(h * aspectRatio);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
if (w > 768 || h > 768) {
|
|
1518
|
+
const aspectRatio = w / h;
|
|
1519
|
+
if (w > h) {
|
|
1520
|
+
w = 768;
|
|
1521
|
+
h = Math.floor(w / aspectRatio);
|
|
1522
|
+
} else {
|
|
1523
|
+
h = 768;
|
|
1524
|
+
w = Math.floor(h * aspectRatio);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
const tilesWidth = Math.floor((w + 511) / 512);
|
|
1528
|
+
const tilesHeight = Math.floor((h + 511) / 512);
|
|
1529
|
+
const numTiles = tilesWidth * tilesHeight;
|
|
1530
|
+
return 85 + 170 * numTiles;
|
|
1531
|
+
}
|
|
1532
|
+
function isInsecureHttpUrl(url) {
|
|
1533
|
+
return url.startsWith("http://");
|
|
1534
|
+
}
|
|
1535
|
+
var ProviderManager = class {
|
|
1536
|
+
constructor(providerRegistry) {
|
|
1537
|
+
this.providerRegistry = providerRegistry;
|
|
1538
|
+
}
|
|
1539
|
+
failedProviders = /* @__PURE__ */ new Set();
|
|
1540
|
+
/**
|
|
1541
|
+
* Reset the failed providers list
|
|
1542
|
+
*/
|
|
1543
|
+
resetFailedProviders() {
|
|
1544
|
+
this.failedProviders.clear();
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Mark a provider as failed
|
|
1548
|
+
*/
|
|
1549
|
+
markFailed(baseUrl) {
|
|
1550
|
+
this.failedProviders.add(baseUrl);
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Check if a provider has failed
|
|
1554
|
+
*/
|
|
1555
|
+
hasFailed(baseUrl) {
|
|
1556
|
+
return this.failedProviders.has(baseUrl);
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Find the next best provider for a model
|
|
1560
|
+
* @param modelId The model ID to find a provider for
|
|
1561
|
+
* @param currentBaseUrl The current provider to exclude
|
|
1562
|
+
* @returns The best provider URL or null if none available
|
|
1563
|
+
*/
|
|
1564
|
+
findNextBestProvider(modelId, currentBaseUrl) {
|
|
1565
|
+
try {
|
|
1566
|
+
const torMode = isTorContext();
|
|
1567
|
+
const disabledProviders = new Set(
|
|
1568
|
+
this.providerRegistry.getDisabledProviders()
|
|
1569
|
+
);
|
|
1570
|
+
const allProviders = this.providerRegistry.getAllProvidersModels();
|
|
1571
|
+
const candidates = [];
|
|
1572
|
+
for (const [baseUrl, models] of Object.entries(allProviders)) {
|
|
1573
|
+
if (baseUrl === currentBaseUrl || this.failedProviders.has(baseUrl) || disabledProviders.has(baseUrl)) {
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
if (!torMode && (isOnionUrl(baseUrl) || isInsecureHttpUrl(baseUrl))) {
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
const model = models.find((m) => m.id === modelId);
|
|
1580
|
+
if (!model) continue;
|
|
1581
|
+
const cost = model.sats_pricing?.completion ?? 0;
|
|
1582
|
+
candidates.push({ baseUrl, model, cost });
|
|
1583
|
+
}
|
|
1584
|
+
candidates.sort((a, b) => a.cost - b.cost);
|
|
1585
|
+
return candidates.length > 0 ? candidates[0].baseUrl : null;
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
console.error("Error finding next best provider:", error);
|
|
1588
|
+
return null;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Find the best model for a provider
|
|
1593
|
+
* Useful when switching providers and need to find equivalent model
|
|
1594
|
+
*/
|
|
1595
|
+
async getModelForProvider(baseUrl, modelId) {
|
|
1596
|
+
const models = this.providerRegistry.getModelsForProvider(baseUrl);
|
|
1597
|
+
const exactMatch = models.find((m) => m.id === modelId);
|
|
1598
|
+
if (exactMatch) return exactMatch;
|
|
1599
|
+
const providerInfo = await this.providerRegistry.getProviderInfo(baseUrl);
|
|
1600
|
+
if (providerInfo?.version && /^0\.1\./.test(providerInfo.version)) {
|
|
1601
|
+
const suffix = modelId.split("/").pop();
|
|
1602
|
+
const suffixMatch = models.find((m) => m.id === suffix);
|
|
1603
|
+
if (suffixMatch) return suffixMatch;
|
|
1604
|
+
}
|
|
1605
|
+
return null;
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Get all available providers for a model
|
|
1609
|
+
* Returns sorted list by price
|
|
1610
|
+
*/
|
|
1611
|
+
getAllProvidersForModel(modelId) {
|
|
1612
|
+
const candidates = [];
|
|
1613
|
+
const allProviders = this.providerRegistry.getAllProvidersModels();
|
|
1614
|
+
const disabledProviders = new Set(
|
|
1615
|
+
this.providerRegistry.getDisabledProviders()
|
|
1616
|
+
);
|
|
1617
|
+
const torMode = isTorContext();
|
|
1618
|
+
for (const [baseUrl, models] of Object.entries(allProviders)) {
|
|
1619
|
+
if (disabledProviders.has(baseUrl)) continue;
|
|
1620
|
+
if (!torMode && (isOnionUrl(baseUrl) || isInsecureHttpUrl(baseUrl)))
|
|
1621
|
+
continue;
|
|
1622
|
+
const model = models.find((m) => m.id === modelId);
|
|
1623
|
+
if (!model) continue;
|
|
1624
|
+
const cost = model.sats_pricing?.completion ?? 0;
|
|
1625
|
+
candidates.push({ baseUrl, model, cost });
|
|
1626
|
+
}
|
|
1627
|
+
return candidates.sort((a, b) => a.cost - b.cost);
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Get providers for a model sorted by prompt+completion pricing
|
|
1631
|
+
*/
|
|
1632
|
+
getProviderPriceRankingForModel(modelId, options = {}) {
|
|
1633
|
+
const normalizedId = this.normalizeModelId(modelId);
|
|
1634
|
+
const includeDisabled = options.includeDisabled ?? false;
|
|
1635
|
+
const torMode = options.torMode ?? false;
|
|
1636
|
+
const disabledProviders = new Set(
|
|
1637
|
+
this.providerRegistry.getDisabledProviders()
|
|
1638
|
+
);
|
|
1639
|
+
const allModels = this.providerRegistry.getAllProvidersModels();
|
|
1640
|
+
const results = [];
|
|
1641
|
+
for (const [baseUrl, models] of Object.entries(allModels)) {
|
|
1642
|
+
if (!includeDisabled && disabledProviders.has(baseUrl)) continue;
|
|
1643
|
+
if (torMode && !baseUrl.includes(".onion")) continue;
|
|
1644
|
+
if (!torMode && (baseUrl.includes(".onion") || isInsecureHttpUrl(baseUrl)))
|
|
1645
|
+
continue;
|
|
1646
|
+
const match = models.find(
|
|
1647
|
+
(model) => this.normalizeModelId(model.id) === normalizedId
|
|
1648
|
+
);
|
|
1649
|
+
if (!match?.sats_pricing) continue;
|
|
1650
|
+
const prompt = match.sats_pricing.prompt;
|
|
1651
|
+
const completion = match.sats_pricing.completion;
|
|
1652
|
+
if (typeof prompt !== "number" || typeof completion !== "number") {
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
const promptPerMillion = prompt * 1e6;
|
|
1656
|
+
const completionPerMillion = completion * 1e6;
|
|
1657
|
+
const totalPerMillion = promptPerMillion + completionPerMillion;
|
|
1658
|
+
results.push({
|
|
1659
|
+
baseUrl,
|
|
1660
|
+
model: match,
|
|
1661
|
+
promptPerMillion,
|
|
1662
|
+
completionPerMillion,
|
|
1663
|
+
totalPerMillion
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
return results.sort((a, b) => {
|
|
1667
|
+
if (a.totalPerMillion !== b.totalPerMillion) {
|
|
1668
|
+
return a.totalPerMillion - b.totalPerMillion;
|
|
1669
|
+
}
|
|
1670
|
+
return a.baseUrl.localeCompare(b.baseUrl);
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Get best-priced provider for a specific model
|
|
1675
|
+
*/
|
|
1676
|
+
getBestProviderForModel(modelId, options = {}) {
|
|
1677
|
+
const ranking = this.getProviderPriceRankingForModel(modelId, options);
|
|
1678
|
+
return ranking[0]?.baseUrl ?? null;
|
|
1679
|
+
}
|
|
1680
|
+
normalizeModelId(modelId) {
|
|
1681
|
+
return modelId.includes("/") ? modelId.split("/").pop() || modelId : modelId;
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Check if a provider accepts a specific mint
|
|
1685
|
+
*/
|
|
1686
|
+
providerAcceptsMint(baseUrl, mintUrl) {
|
|
1687
|
+
const providerMints = this.providerRegistry.getProviderMints(baseUrl);
|
|
1688
|
+
if (providerMints.length === 0) {
|
|
1689
|
+
return true;
|
|
1690
|
+
}
|
|
1691
|
+
return providerMints.includes(mintUrl);
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Get required sats for a model based on message history
|
|
1695
|
+
* Simple estimation based on typical usage
|
|
1696
|
+
*/
|
|
1697
|
+
getRequiredSatsForModel(model, apiMessages, maxTokens) {
|
|
1698
|
+
try {
|
|
1699
|
+
let imageTokens = 0;
|
|
1700
|
+
if (apiMessages) {
|
|
1701
|
+
for (const msg of apiMessages) {
|
|
1702
|
+
const content = msg?.content;
|
|
1703
|
+
if (Array.isArray(content)) {
|
|
1704
|
+
for (const part of content) {
|
|
1705
|
+
const isImage = part && typeof part === "object" && part.type === "image_url";
|
|
1706
|
+
const url = isImage ? typeof part.image_url === "string" ? part.image_url : part.image_url?.url : void 0;
|
|
1707
|
+
if (url && typeof url === "string" && url.startsWith("data:")) {
|
|
1708
|
+
const res = getImageResolutionFromDataUrl(url);
|
|
1709
|
+
if (res) {
|
|
1710
|
+
const tokensFromImage = calculateImageTokens(
|
|
1711
|
+
res.width,
|
|
1712
|
+
res.height
|
|
1713
|
+
);
|
|
1714
|
+
imageTokens += tokensFromImage;
|
|
1715
|
+
console.log("IMAGE INPUT RESOLUTION", {
|
|
1716
|
+
width: res.width,
|
|
1717
|
+
height: res.height,
|
|
1718
|
+
tokensFromImage
|
|
1719
|
+
});
|
|
1720
|
+
} else {
|
|
1721
|
+
console.log(
|
|
1722
|
+
"IMAGE INPUT RESOLUTION",
|
|
1723
|
+
"unknown (unsupported format or parse failure)"
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
const apiMessagesNoImages = apiMessages ? apiMessages.map((m) => {
|
|
1732
|
+
if (Array.isArray(m?.content)) {
|
|
1733
|
+
const filtered = m.content.filter(
|
|
1734
|
+
(p) => !(p && typeof p === "object" && p.type === "image_url")
|
|
1735
|
+
);
|
|
1736
|
+
return { ...m, content: filtered };
|
|
1737
|
+
}
|
|
1738
|
+
return m;
|
|
1739
|
+
}) : void 0;
|
|
1740
|
+
const approximateTokens = apiMessagesNoImages ? Math.ceil(JSON.stringify(apiMessagesNoImages, null, 2).length / 2.84) : 1e4;
|
|
1741
|
+
const totalInputTokens = approximateTokens + imageTokens;
|
|
1742
|
+
const sp = model?.sats_pricing;
|
|
1743
|
+
if (!sp) return 0;
|
|
1744
|
+
if (!sp.max_completion_cost) {
|
|
1745
|
+
return sp.max_cost ?? 50;
|
|
1746
|
+
}
|
|
1747
|
+
const promptCosts = (sp.prompt || 0) * totalInputTokens;
|
|
1748
|
+
let completionCost = sp.max_completion_cost;
|
|
1749
|
+
if (maxTokens !== void 0 && sp.completion) {
|
|
1750
|
+
completionCost = sp.completion * maxTokens;
|
|
1751
|
+
}
|
|
1752
|
+
const totalEstimatedCosts = (promptCosts + completionCost) * 1.05;
|
|
1753
|
+
return totalEstimatedCosts;
|
|
1754
|
+
} catch (e) {
|
|
1755
|
+
console.error(e);
|
|
1756
|
+
return 0;
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1760
|
+
|
|
1761
|
+
// client/RoutstrClient.ts
|
|
1762
|
+
var TOPUP_MARGIN = 0.7;
|
|
1763
|
+
var RoutstrClient = class {
|
|
1764
|
+
constructor(walletAdapter, storageAdapter, providerRegistry, alertLevel, mode = "xcashu") {
|
|
1765
|
+
this.walletAdapter = walletAdapter;
|
|
1766
|
+
this.storageAdapter = storageAdapter;
|
|
1767
|
+
this.providerRegistry = providerRegistry;
|
|
1768
|
+
this.balanceManager = new BalanceManager(
|
|
1769
|
+
walletAdapter,
|
|
1770
|
+
storageAdapter,
|
|
1771
|
+
providerRegistry
|
|
1772
|
+
);
|
|
1773
|
+
this.cashuSpender = new CashuSpender(
|
|
1774
|
+
walletAdapter,
|
|
1775
|
+
storageAdapter,
|
|
1776
|
+
providerRegistry,
|
|
1777
|
+
this.balanceManager
|
|
1778
|
+
);
|
|
1779
|
+
this.streamProcessor = new StreamProcessor();
|
|
1780
|
+
this.providerManager = new ProviderManager(providerRegistry);
|
|
1781
|
+
this.alertLevel = alertLevel;
|
|
1782
|
+
this.mode = mode;
|
|
1783
|
+
}
|
|
1784
|
+
cashuSpender;
|
|
1785
|
+
balanceManager;
|
|
1786
|
+
streamProcessor;
|
|
1787
|
+
providerManager;
|
|
1788
|
+
alertLevel;
|
|
1789
|
+
mode;
|
|
1790
|
+
debugLevel = "WARN";
|
|
1791
|
+
/**
|
|
1792
|
+
* Get the current client mode
|
|
1793
|
+
*/
|
|
1794
|
+
getMode() {
|
|
1795
|
+
return this.mode;
|
|
1796
|
+
}
|
|
1797
|
+
getDebugLevel() {
|
|
1798
|
+
return this.debugLevel;
|
|
1799
|
+
}
|
|
1800
|
+
setDebugLevel(level) {
|
|
1801
|
+
this.debugLevel = level;
|
|
1802
|
+
}
|
|
1803
|
+
_log(level, ...args) {
|
|
1804
|
+
const levelPriority = {
|
|
1805
|
+
DEBUG: 0,
|
|
1806
|
+
WARN: 1,
|
|
1807
|
+
ERROR: 2
|
|
1808
|
+
};
|
|
1809
|
+
if (levelPriority[level] >= levelPriority[this.debugLevel]) {
|
|
1810
|
+
switch (level) {
|
|
1811
|
+
case "DEBUG":
|
|
1812
|
+
console.log(...args);
|
|
1813
|
+
break;
|
|
1814
|
+
case "WARN":
|
|
1815
|
+
console.warn(...args);
|
|
1816
|
+
break;
|
|
1817
|
+
case "ERROR":
|
|
1818
|
+
console.error(...args);
|
|
1819
|
+
break;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* Get the CashuSpender instance
|
|
1825
|
+
*/
|
|
1826
|
+
getCashuSpender() {
|
|
1827
|
+
return this.cashuSpender;
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Get the BalanceManager instance
|
|
1831
|
+
*/
|
|
1832
|
+
getBalanceManager() {
|
|
1833
|
+
return this.balanceManager;
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Get the ProviderManager instance
|
|
1837
|
+
*/
|
|
1838
|
+
getProviderManager() {
|
|
1839
|
+
return this.providerManager;
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Check if the client is currently busy (in critical section)
|
|
1843
|
+
*/
|
|
1844
|
+
get isBusy() {
|
|
1845
|
+
return this.cashuSpender.isBusy;
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Route an API request to the upstream provider
|
|
1849
|
+
*
|
|
1850
|
+
* This is a simpler alternative to fetchAIResponse that just proxies
|
|
1851
|
+
* the request upstream without the streaming callback machinery.
|
|
1852
|
+
* Useful for daemon-style routing where you just need to forward
|
|
1853
|
+
* requests and get responses back.
|
|
1854
|
+
*/
|
|
1855
|
+
async routeRequest(params) {
|
|
1856
|
+
const {
|
|
1857
|
+
path,
|
|
1858
|
+
method,
|
|
1859
|
+
body,
|
|
1860
|
+
headers = {},
|
|
1861
|
+
baseUrl,
|
|
1862
|
+
mintUrl,
|
|
1863
|
+
modelId
|
|
1864
|
+
} = params;
|
|
1865
|
+
await this._checkBalance();
|
|
1866
|
+
let requiredSats = 1;
|
|
1867
|
+
let selectedModel;
|
|
1868
|
+
if (modelId) {
|
|
1869
|
+
const providerModel = await this.providerManager.getModelForProvider(
|
|
1870
|
+
baseUrl,
|
|
1871
|
+
modelId
|
|
1872
|
+
);
|
|
1873
|
+
selectedModel = providerModel ?? void 0;
|
|
1874
|
+
if (selectedModel) {
|
|
1875
|
+
requiredSats = this.providerManager.getRequiredSatsForModel(
|
|
1876
|
+
selectedModel,
|
|
1877
|
+
[]
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
const { token, tokenBalance, tokenBalanceUnit } = await this._spendToken({
|
|
1882
|
+
mintUrl,
|
|
1883
|
+
amount: requiredSats,
|
|
1884
|
+
baseUrl
|
|
1885
|
+
});
|
|
1886
|
+
this._log("DEBUG", token, baseUrl);
|
|
1887
|
+
let requestBody = body;
|
|
1888
|
+
if (body && typeof body === "object") {
|
|
1889
|
+
const bodyObj = body;
|
|
1890
|
+
if (!bodyObj.stream) {
|
|
1891
|
+
requestBody = { ...bodyObj, stream: false };
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
const baseHeaders = this._buildBaseHeaders(headers);
|
|
1895
|
+
const requestHeaders = this._withAuthHeader(baseHeaders, token);
|
|
1896
|
+
const response = await this._makeRequest({
|
|
1897
|
+
path,
|
|
1898
|
+
method,
|
|
1899
|
+
body: method === "GET" ? void 0 : requestBody,
|
|
1900
|
+
baseUrl,
|
|
1901
|
+
mintUrl,
|
|
1902
|
+
token,
|
|
1903
|
+
requiredSats,
|
|
1904
|
+
headers: requestHeaders,
|
|
1905
|
+
baseHeaders,
|
|
1906
|
+
selectedModel
|
|
1907
|
+
});
|
|
1908
|
+
const tokenBalanceInSats = tokenBalanceUnit === "msat" ? tokenBalance / 1e3 : tokenBalance;
|
|
1909
|
+
const baseUrlUsed = response.baseUrl || baseUrl;
|
|
1910
|
+
const tokenUsed = response.token || token;
|
|
1911
|
+
await this._handlePostResponseBalanceUpdate({
|
|
1912
|
+
token: tokenUsed,
|
|
1913
|
+
baseUrl: baseUrlUsed,
|
|
1914
|
+
initialTokenBalance: tokenBalanceInSats,
|
|
1915
|
+
response
|
|
1916
|
+
});
|
|
1917
|
+
return response;
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Fetch AI response with streaming
|
|
1921
|
+
*/
|
|
1922
|
+
async fetchAIResponse(options, callbacks) {
|
|
1923
|
+
const {
|
|
1924
|
+
messageHistory,
|
|
1925
|
+
selectedModel,
|
|
1926
|
+
baseUrl,
|
|
1927
|
+
mintUrl,
|
|
1928
|
+
balance,
|
|
1929
|
+
transactionHistory,
|
|
1930
|
+
maxTokens,
|
|
1931
|
+
headers
|
|
1932
|
+
} = options;
|
|
1933
|
+
const apiMessages = await this._convertMessages(messageHistory);
|
|
1934
|
+
const requiredSats = this.providerManager.getRequiredSatsForModel(
|
|
1935
|
+
selectedModel,
|
|
1936
|
+
apiMessages,
|
|
1937
|
+
maxTokens
|
|
1938
|
+
);
|
|
1939
|
+
try {
|
|
1940
|
+
await this._checkBalance();
|
|
1941
|
+
callbacks.onPaymentProcessing?.(true);
|
|
1942
|
+
const spendResult = await this._spendToken({
|
|
1943
|
+
mintUrl,
|
|
1944
|
+
amount: requiredSats,
|
|
1945
|
+
baseUrl
|
|
1946
|
+
});
|
|
1947
|
+
let token = spendResult.token;
|
|
1948
|
+
let tokenBalance = spendResult.tokenBalance;
|
|
1949
|
+
let tokenBalanceUnit = spendResult.tokenBalanceUnit;
|
|
1950
|
+
const tokenBalanceInSats = tokenBalanceUnit === "msat" ? tokenBalance / 1e3 : tokenBalance;
|
|
1951
|
+
callbacks.onTokenCreated?.(this._getPendingCashuTokenAmount());
|
|
1952
|
+
const baseHeaders = this._buildBaseHeaders(headers);
|
|
1953
|
+
const requestHeaders = this._withAuthHeader(baseHeaders, token);
|
|
1954
|
+
this.providerManager.resetFailedProviders();
|
|
1955
|
+
const providerInfo = await this.providerRegistry.getProviderInfo(baseUrl);
|
|
1956
|
+
const providerVersion = providerInfo?.version ?? "";
|
|
1957
|
+
let modelIdForRequest = selectedModel.id;
|
|
1958
|
+
if (/^0\.1\./.test(providerVersion)) {
|
|
1959
|
+
const newModel = await this.providerManager.getModelForProvider(
|
|
1960
|
+
baseUrl,
|
|
1961
|
+
selectedModel.id
|
|
1962
|
+
);
|
|
1963
|
+
modelIdForRequest = newModel?.id ?? selectedModel.id;
|
|
1964
|
+
}
|
|
1965
|
+
const body = {
|
|
1966
|
+
model: modelIdForRequest,
|
|
1967
|
+
messages: apiMessages,
|
|
1968
|
+
stream: true
|
|
1969
|
+
};
|
|
1970
|
+
if (maxTokens !== void 0) {
|
|
1971
|
+
body.max_tokens = maxTokens;
|
|
1972
|
+
}
|
|
1973
|
+
if (selectedModel?.name?.startsWith("OpenAI:")) {
|
|
1974
|
+
body.tools = [{ type: "web_search" }];
|
|
1975
|
+
}
|
|
1976
|
+
const response = await this._makeRequest({
|
|
1977
|
+
path: "/v1/chat/completions",
|
|
1978
|
+
method: "POST",
|
|
1979
|
+
body,
|
|
1980
|
+
selectedModel,
|
|
1981
|
+
baseUrl,
|
|
1982
|
+
mintUrl,
|
|
1983
|
+
token,
|
|
1984
|
+
requiredSats,
|
|
1985
|
+
maxTokens,
|
|
1986
|
+
headers: requestHeaders,
|
|
1987
|
+
baseHeaders
|
|
1988
|
+
});
|
|
1989
|
+
if (!response.body) {
|
|
1990
|
+
throw new Error("Response body is not available");
|
|
1991
|
+
}
|
|
1992
|
+
if (response.status === 200) {
|
|
1993
|
+
const baseUrlUsed = response.baseUrl || baseUrl;
|
|
1994
|
+
const streamingResult = await this.streamProcessor.process(
|
|
1995
|
+
response,
|
|
1996
|
+
{
|
|
1997
|
+
onContent: callbacks.onStreamingUpdate,
|
|
1998
|
+
onThinking: callbacks.onThinkingUpdate
|
|
1999
|
+
},
|
|
2000
|
+
selectedModel.id
|
|
2001
|
+
);
|
|
2002
|
+
if (streamingResult.finish_reason === "content_filter") {
|
|
2003
|
+
callbacks.onMessageAppend({
|
|
2004
|
+
role: "assistant",
|
|
2005
|
+
content: "Your request was denied due to content filtering."
|
|
2006
|
+
});
|
|
2007
|
+
} else if (streamingResult.content || streamingResult.images && streamingResult.images.length > 0) {
|
|
2008
|
+
const message = await this._createAssistantMessage(streamingResult);
|
|
2009
|
+
callbacks.onMessageAppend(message);
|
|
2010
|
+
} else {
|
|
2011
|
+
callbacks.onMessageAppend({
|
|
2012
|
+
role: "system",
|
|
2013
|
+
content: "The provider did not respond to this request."
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
callbacks.onStreamingUpdate("");
|
|
2017
|
+
callbacks.onThinkingUpdate("");
|
|
2018
|
+
const isApikeysEstimate = this.mode === "apikeys";
|
|
2019
|
+
let satsSpent = await this._handlePostResponseBalanceUpdate({
|
|
2020
|
+
token,
|
|
2021
|
+
baseUrl: baseUrlUsed,
|
|
2022
|
+
initialTokenBalance: tokenBalanceInSats,
|
|
2023
|
+
fallbackSatsSpent: isApikeysEstimate ? this._getEstimatedCosts(selectedModel, streamingResult) : void 0,
|
|
2024
|
+
response
|
|
2025
|
+
});
|
|
2026
|
+
const estimatedCosts = this._getEstimatedCosts(
|
|
2027
|
+
selectedModel,
|
|
2028
|
+
streamingResult
|
|
2029
|
+
);
|
|
2030
|
+
const onLastMessageSatsUpdate = callbacks.onLastMessageSatsUpdate;
|
|
2031
|
+
onLastMessageSatsUpdate?.(satsSpent, estimatedCosts);
|
|
2032
|
+
} else {
|
|
2033
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
2034
|
+
}
|
|
2035
|
+
} catch (error) {
|
|
2036
|
+
this._handleError(error, callbacks);
|
|
2037
|
+
} finally {
|
|
2038
|
+
callbacks.onPaymentProcessing?.(false);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Make the API request with failover support
|
|
2043
|
+
*/
|
|
2044
|
+
async _makeRequest(params) {
|
|
2045
|
+
const { path, method, body, baseUrl, token, headers } = params;
|
|
2046
|
+
try {
|
|
2047
|
+
const url = `${baseUrl.replace(/\/$/, "")}${path}`;
|
|
2048
|
+
if (this.mode === "xcashu") this._log("DEBUG", "HEADERS,", headers);
|
|
2049
|
+
const response = await fetch(url, {
|
|
2050
|
+
method,
|
|
2051
|
+
headers,
|
|
2052
|
+
body: body === void 0 || method === "GET" ? void 0 : JSON.stringify(body)
|
|
2053
|
+
});
|
|
2054
|
+
if (this.mode === "xcashu") this._log("DEBUG", "response,", response);
|
|
2055
|
+
response.baseUrl = baseUrl;
|
|
2056
|
+
response.token = token;
|
|
2057
|
+
if (!response.ok) {
|
|
2058
|
+
const requestId = response.headers.get("x-routstr-request-id") || void 0;
|
|
2059
|
+
return await this._handleErrorResponse(
|
|
2060
|
+
params,
|
|
2061
|
+
token,
|
|
2062
|
+
response.status,
|
|
2063
|
+
requestId,
|
|
2064
|
+
this.mode === "xcashu" ? response.headers.get("x-cashu") ?? void 0 : void 0
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
return response;
|
|
2068
|
+
} catch (error) {
|
|
2069
|
+
if (this._isNetworkError(error?.message || "")) {
|
|
2070
|
+
return await this._handleErrorResponse(
|
|
2071
|
+
params,
|
|
2072
|
+
token,
|
|
2073
|
+
-1
|
|
2074
|
+
// just for Network Error to skip all statuses
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
throw error;
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Handle error responses with failover
|
|
2082
|
+
*/
|
|
2083
|
+
async _handleErrorResponse(params, token, status, requestId, xCashuRefundToken) {
|
|
2084
|
+
const { path, method, body, selectedModel, baseUrl, mintUrl } = params;
|
|
2085
|
+
let tryNextProvider = false;
|
|
2086
|
+
this._log(
|
|
2087
|
+
"DEBUG",
|
|
2088
|
+
`[RoutstrClient] _handleErrorResponse: status=${status}, baseUrl=${baseUrl}, mode=${this.mode}, token preview=${token}, requestId=${requestId}`
|
|
2089
|
+
);
|
|
2090
|
+
this._log(
|
|
2091
|
+
"DEBUG",
|
|
2092
|
+
`[RoutstrClient] _handleErrorResponse: Attempting to receive/restore token for ${baseUrl}`
|
|
2093
|
+
);
|
|
2094
|
+
if (params.token.startsWith("cashu")) {
|
|
2095
|
+
const tryReceiveTokenResult = await this.cashuSpender.receiveToken(
|
|
2096
|
+
params.token
|
|
2097
|
+
);
|
|
2098
|
+
if (tryReceiveTokenResult.success) {
|
|
2099
|
+
this._log(
|
|
2100
|
+
"DEBUG",
|
|
2101
|
+
`[RoutstrClient] _handleErrorResponse: Token restored successfully, amount=${tryReceiveTokenResult.amount}`
|
|
2102
|
+
);
|
|
2103
|
+
tryNextProvider = true;
|
|
2104
|
+
if (this.mode === "lazyrefund")
|
|
2105
|
+
this.storageAdapter.removeToken(baseUrl);
|
|
2106
|
+
} else {
|
|
2107
|
+
this._log(
|
|
2108
|
+
"DEBUG",
|
|
2109
|
+
`[RoutstrClient] _handleErrorResponse: Failed to receive token. `
|
|
2110
|
+
);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
if (this.mode === "xcashu") {
|
|
2114
|
+
if (xCashuRefundToken) {
|
|
2115
|
+
this._log(
|
|
2116
|
+
"DEBUG",
|
|
2117
|
+
`[RoutstrClient] _handleErrorResponse: Attempting to receive xcashu refund token, preview=${xCashuRefundToken.substring(0, 20)}...`
|
|
2118
|
+
);
|
|
2119
|
+
try {
|
|
2120
|
+
const receiveResult = await this.cashuSpender.receiveToken(xCashuRefundToken);
|
|
2121
|
+
if (receiveResult.success) {
|
|
2122
|
+
this._log(
|
|
2123
|
+
"DEBUG",
|
|
2124
|
+
`[RoutstrClient] _handleErrorResponse: xcashu refund received, amount=${receiveResult.amount}`
|
|
2125
|
+
);
|
|
2126
|
+
tryNextProvider = true;
|
|
2127
|
+
} else
|
|
2128
|
+
throw new ProviderError(
|
|
2129
|
+
baseUrl,
|
|
2130
|
+
status,
|
|
2131
|
+
"xcashu refund failed",
|
|
2132
|
+
requestId
|
|
2133
|
+
);
|
|
2134
|
+
} catch (error) {
|
|
2135
|
+
this._log("ERROR", "[xcashu] Failed to receive refund token:", error);
|
|
2136
|
+
throw new ProviderError(
|
|
2137
|
+
baseUrl,
|
|
2138
|
+
status,
|
|
2139
|
+
"[xcashu] Failed to receive refund token",
|
|
2140
|
+
requestId
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
} else {
|
|
2144
|
+
if (!tryNextProvider)
|
|
2145
|
+
throw new ProviderError(
|
|
2146
|
+
baseUrl,
|
|
2147
|
+
status,
|
|
2148
|
+
"[xcashu] Failed to receive refund token",
|
|
2149
|
+
requestId
|
|
2150
|
+
);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
if (status === 402 && !tryNextProvider && (this.mode === "apikeys" || this.mode === "lazyrefund")) {
|
|
2154
|
+
const topupResult = await this.balanceManager.topUp({
|
|
2155
|
+
mintUrl,
|
|
2156
|
+
baseUrl,
|
|
2157
|
+
amount: params.requiredSats * TOPUP_MARGIN,
|
|
2158
|
+
token: params.token
|
|
2159
|
+
});
|
|
2160
|
+
this._log(
|
|
2161
|
+
"DEBUG",
|
|
2162
|
+
`[RoutstrClient] _handleErrorResponse: Topup result for ${baseUrl}: success=${topupResult.success}, message=${topupResult.message}`
|
|
2163
|
+
);
|
|
2164
|
+
if (!topupResult.success) {
|
|
2165
|
+
const message = topupResult.message || "";
|
|
2166
|
+
if (message.includes("Insufficient balance")) {
|
|
2167
|
+
const needMatch = message.match(/need (\d+)/);
|
|
2168
|
+
const haveMatch = message.match(/have (\d+)/);
|
|
2169
|
+
const required = needMatch ? parseInt(needMatch[1], 10) : params.requiredSats;
|
|
2170
|
+
const available = haveMatch ? parseInt(haveMatch[1], 10) : 0;
|
|
2171
|
+
this._log(
|
|
2172
|
+
"DEBUG",
|
|
2173
|
+
`[RoutstrClient] _handleErrorResponse: Insufficient balance, need=${required}, have=${available}`
|
|
2174
|
+
);
|
|
2175
|
+
throw new InsufficientBalanceError(required, available);
|
|
2176
|
+
} else {
|
|
2177
|
+
this._log(
|
|
2178
|
+
"DEBUG",
|
|
2179
|
+
`[RoutstrClient] _handleErrorResponse: Topup failed with non-insufficient-balance error, will try next provider`
|
|
2180
|
+
);
|
|
2181
|
+
tryNextProvider = true;
|
|
2182
|
+
}
|
|
2183
|
+
} else {
|
|
2184
|
+
this._log(
|
|
2185
|
+
"DEBUG",
|
|
2186
|
+
`[RoutstrClient] _handleErrorResponse: Topup successful, will retry with new token`
|
|
2187
|
+
);
|
|
2188
|
+
}
|
|
2189
|
+
if (!tryNextProvider)
|
|
2190
|
+
return this._makeRequest({
|
|
2191
|
+
...params,
|
|
2192
|
+
token: params.token,
|
|
2193
|
+
headers: this._withAuthHeader(params.baseHeaders, params.token)
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
if ((status === 401 || status === 403 || status === 413 || status === 400 || status === 500 || status === 502 || status === 503 || status === 504 || status === 521) && !tryNextProvider) {
|
|
2197
|
+
this._log(
|
|
2198
|
+
"DEBUG",
|
|
2199
|
+
`[RoutstrClient] _handleErrorResponse: Status ${status} (auth/server error), attempting refund for ${baseUrl}, mode=${this.mode}`
|
|
2200
|
+
);
|
|
2201
|
+
if (this.mode === "lazyrefund") {
|
|
2202
|
+
try {
|
|
2203
|
+
const refundResult = await this.balanceManager.refund({
|
|
2204
|
+
mintUrl,
|
|
2205
|
+
baseUrl,
|
|
2206
|
+
token: params.token
|
|
2207
|
+
});
|
|
2208
|
+
this._log(
|
|
2209
|
+
"DEBUG",
|
|
2210
|
+
`[RoutstrClient] _handleErrorResponse: Lazyrefund result: success=${refundResult.success}`
|
|
2211
|
+
);
|
|
2212
|
+
if (refundResult.success) this.storageAdapter.removeToken(baseUrl);
|
|
2213
|
+
else
|
|
2214
|
+
throw new ProviderError(
|
|
2215
|
+
baseUrl,
|
|
2216
|
+
status,
|
|
2217
|
+
"refund failed",
|
|
2218
|
+
requestId
|
|
2219
|
+
);
|
|
2220
|
+
} catch (error) {
|
|
2221
|
+
throw new ProviderError(
|
|
2222
|
+
baseUrl,
|
|
2223
|
+
status,
|
|
2224
|
+
"Failed to refund token",
|
|
2225
|
+
requestId
|
|
2226
|
+
);
|
|
2227
|
+
}
|
|
2228
|
+
} else if (this.mode === "apikeys") {
|
|
2229
|
+
this._log(
|
|
2230
|
+
"DEBUG",
|
|
2231
|
+
`[RoutstrClient] _handleErrorResponse: Attempting API key refund for ${baseUrl}, key preview=${token}`
|
|
2232
|
+
);
|
|
2233
|
+
const initialBalance = await this.balanceManager.getTokenBalance(
|
|
2234
|
+
token,
|
|
2235
|
+
baseUrl
|
|
2236
|
+
);
|
|
2237
|
+
this._log(
|
|
2238
|
+
"DEBUG",
|
|
2239
|
+
`[RoutstrClient] _handleErrorResponse: Initial API key balance: ${initialBalance.amount}`
|
|
2240
|
+
);
|
|
2241
|
+
const refundResult = await this.balanceManager.refundApiKey({
|
|
2242
|
+
mintUrl,
|
|
2243
|
+
baseUrl,
|
|
2244
|
+
apiKey: token
|
|
2245
|
+
});
|
|
2246
|
+
this._log(
|
|
2247
|
+
"DEBUG",
|
|
2248
|
+
`[RoutstrClient] _handleErrorResponse: API key refund result: success=${refundResult.success}, message=${refundResult.message}`
|
|
2249
|
+
);
|
|
2250
|
+
if (!refundResult.success && initialBalance.amount > 0) {
|
|
2251
|
+
throw new ProviderError(
|
|
2252
|
+
baseUrl,
|
|
2253
|
+
status,
|
|
2254
|
+
refundResult.message ?? "Unknown error"
|
|
2255
|
+
);
|
|
2256
|
+
} else {
|
|
2257
|
+
this.storageAdapter.removeApiKey(baseUrl);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
this.providerManager.markFailed(baseUrl);
|
|
2262
|
+
this._log(
|
|
2263
|
+
"DEBUG",
|
|
2264
|
+
`[RoutstrClient] _handleErrorResponse: Marked provider ${baseUrl} as failed`
|
|
2265
|
+
);
|
|
2266
|
+
if (!selectedModel) {
|
|
2267
|
+
throw new ProviderError(
|
|
2268
|
+
baseUrl,
|
|
2269
|
+
status,
|
|
2270
|
+
"Funny, no selected model. HMM. "
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2273
|
+
const nextProvider = this.providerManager.findNextBestProvider(
|
|
2274
|
+
selectedModel.id,
|
|
2275
|
+
baseUrl
|
|
2276
|
+
);
|
|
2277
|
+
if (nextProvider) {
|
|
2278
|
+
this._log(
|
|
2279
|
+
"DEBUG",
|
|
2280
|
+
`[RoutstrClient] _handleErrorResponse: Failing over to next provider: ${nextProvider}, model: ${selectedModel.id}`
|
|
2281
|
+
);
|
|
2282
|
+
const newModel = await this.providerManager.getModelForProvider(
|
|
2283
|
+
nextProvider,
|
|
2284
|
+
selectedModel.id
|
|
2285
|
+
) ?? selectedModel;
|
|
2286
|
+
const messagesForPricing = Array.isArray(
|
|
2287
|
+
body?.messages
|
|
2288
|
+
) ? body.messages : [];
|
|
2289
|
+
const newRequiredSats = this.providerManager.getRequiredSatsForModel(
|
|
2290
|
+
newModel,
|
|
2291
|
+
messagesForPricing,
|
|
2292
|
+
params.maxTokens
|
|
2293
|
+
);
|
|
2294
|
+
this._log(
|
|
2295
|
+
"DEBUG",
|
|
2296
|
+
`[RoutstrClient] _handleErrorResponse: Creating new token for failover provider ${nextProvider}, required sats: ${newRequiredSats}`
|
|
2297
|
+
);
|
|
2298
|
+
const spendResult = await this._spendToken({
|
|
2299
|
+
mintUrl,
|
|
2300
|
+
amount: newRequiredSats,
|
|
2301
|
+
baseUrl: nextProvider
|
|
2302
|
+
});
|
|
2303
|
+
return this._makeRequest({
|
|
2304
|
+
...params,
|
|
2305
|
+
path,
|
|
2306
|
+
method,
|
|
2307
|
+
body,
|
|
2308
|
+
baseUrl: nextProvider,
|
|
2309
|
+
selectedModel: newModel,
|
|
2310
|
+
token: spendResult.token,
|
|
2311
|
+
requiredSats: newRequiredSats,
|
|
2312
|
+
headers: this._withAuthHeader(params.baseHeaders, spendResult.token)
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
throw new FailoverError(baseUrl, Array.from(this.providerManager));
|
|
2316
|
+
}
|
|
2317
|
+
/**
|
|
2318
|
+
* Handle post-response balance update for all modes
|
|
2319
|
+
*/
|
|
2320
|
+
async _handlePostResponseBalanceUpdate(params) {
|
|
2321
|
+
const { token, baseUrl, initialTokenBalance, fallbackSatsSpent, response } = params;
|
|
2322
|
+
let satsSpent = initialTokenBalance;
|
|
2323
|
+
if (this.mode === "xcashu" && response) {
|
|
2324
|
+
const refundToken = response.headers.get("x-cashu") ?? void 0;
|
|
2325
|
+
if (refundToken) {
|
|
2326
|
+
try {
|
|
2327
|
+
const receiveResult = await this.cashuSpender.receiveToken(refundToken);
|
|
2328
|
+
satsSpent = initialTokenBalance - receiveResult.amount * (receiveResult.unit == "sat" ? 1 : 1e3);
|
|
2329
|
+
} catch (error) {
|
|
2330
|
+
this._log("ERROR", "[xcashu] Failed to receive refund token:", error);
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
} else if (this.mode === "lazyrefund") {
|
|
2334
|
+
const latestBalanceInfo = await this.balanceManager.getTokenBalance(
|
|
2335
|
+
token,
|
|
2336
|
+
baseUrl
|
|
2337
|
+
);
|
|
2338
|
+
const latestTokenBalance = latestBalanceInfo.unit === "msat" ? latestBalanceInfo.amount / 1e3 : latestBalanceInfo.amount;
|
|
2339
|
+
this.storageAdapter.updateTokenBalance(baseUrl, latestTokenBalance);
|
|
2340
|
+
satsSpent = initialTokenBalance - latestTokenBalance;
|
|
2341
|
+
} else if (this.mode === "apikeys") {
|
|
2342
|
+
try {
|
|
2343
|
+
const latestBalanceInfo = await this.balanceManager.getTokenBalance(
|
|
2344
|
+
token,
|
|
2345
|
+
baseUrl
|
|
2346
|
+
);
|
|
2347
|
+
this._log(
|
|
2348
|
+
"DEBUG",
|
|
2349
|
+
"LATEST Balance",
|
|
2350
|
+
latestBalanceInfo.amount,
|
|
2351
|
+
latestBalanceInfo.reserved,
|
|
2352
|
+
latestBalanceInfo.apiKey,
|
|
2353
|
+
baseUrl
|
|
2354
|
+
);
|
|
2355
|
+
const latestTokenBalance = latestBalanceInfo.unit === "msat" ? latestBalanceInfo.amount / 1e3 : latestBalanceInfo.amount;
|
|
2356
|
+
this.storageAdapter.updateChildKeyBalance(baseUrl, latestTokenBalance);
|
|
2357
|
+
this.storageAdapter.updateApiKeyBalance(baseUrl, latestTokenBalance);
|
|
2358
|
+
satsSpent = initialTokenBalance - latestTokenBalance;
|
|
2359
|
+
} catch (e) {
|
|
2360
|
+
this._log("WARN", "Could not get updated API key balance:", e);
|
|
2361
|
+
satsSpent = fallbackSatsSpent ?? initialTokenBalance;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
return satsSpent;
|
|
2365
|
+
}
|
|
2366
|
+
/**
|
|
2367
|
+
* Convert messages for API format
|
|
2368
|
+
*/
|
|
2369
|
+
async _convertMessages(messages) {
|
|
2370
|
+
return Promise.all(
|
|
2371
|
+
messages.filter((m) => m.role !== "system").map(async (m) => ({
|
|
2372
|
+
role: m.role,
|
|
2373
|
+
content: typeof m.content === "string" ? m.content : m.content
|
|
2374
|
+
}))
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* Create assistant message from streaming result
|
|
2379
|
+
*/
|
|
2380
|
+
async _createAssistantMessage(result) {
|
|
2381
|
+
if (result.images && result.images.length > 0) {
|
|
2382
|
+
const content = [];
|
|
2383
|
+
if (result.content) {
|
|
2384
|
+
content.push({
|
|
2385
|
+
type: "text",
|
|
2386
|
+
text: result.content,
|
|
2387
|
+
thinking: result.thinking,
|
|
2388
|
+
citations: result.citations,
|
|
2389
|
+
annotations: result.annotations
|
|
2390
|
+
});
|
|
2391
|
+
}
|
|
2392
|
+
for (const img of result.images) {
|
|
2393
|
+
content.push({
|
|
2394
|
+
type: "image_url",
|
|
2395
|
+
image_url: {
|
|
2396
|
+
url: img.image_url.url
|
|
2397
|
+
}
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
return {
|
|
2401
|
+
role: "assistant",
|
|
2402
|
+
content
|
|
2403
|
+
};
|
|
2404
|
+
}
|
|
2405
|
+
return {
|
|
2406
|
+
role: "assistant",
|
|
2407
|
+
content: result.content || ""
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* Create a child key for a parent API key via the provider's API
|
|
2412
|
+
* POST /v1/balance/child-key
|
|
2413
|
+
*/
|
|
2414
|
+
async _createChildKey(baseUrl, parentApiKey, options) {
|
|
2415
|
+
const response = await fetch(`${baseUrl}v1/balance/child-key`, {
|
|
2416
|
+
method: "POST",
|
|
2417
|
+
headers: {
|
|
2418
|
+
"Content-Type": "application/json",
|
|
2419
|
+
Authorization: `Bearer ${parentApiKey}`
|
|
2420
|
+
},
|
|
2421
|
+
body: JSON.stringify({
|
|
2422
|
+
count: options?.count ?? 1,
|
|
2423
|
+
balance_limit: options?.balanceLimit,
|
|
2424
|
+
balance_limit_reset: options?.balanceLimitReset,
|
|
2425
|
+
validity_date: options?.validityDate
|
|
2426
|
+
})
|
|
2427
|
+
});
|
|
2428
|
+
if (!response.ok) {
|
|
2429
|
+
throw new Error(
|
|
2430
|
+
`Failed to create child key: ${response.status} ${await response.text()}`
|
|
2431
|
+
);
|
|
2432
|
+
}
|
|
2433
|
+
const data = await response.json();
|
|
2434
|
+
return {
|
|
2435
|
+
childKey: data.api_keys?.[0],
|
|
2436
|
+
balance: data.balance ?? 0,
|
|
2437
|
+
balanceLimit: data.balance_limit,
|
|
2438
|
+
validityDate: data.validity_date
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Calculate estimated costs from usage
|
|
2443
|
+
*/
|
|
2444
|
+
_getEstimatedCosts(selectedModel, streamingResult) {
|
|
2445
|
+
let estimatedCosts = 0;
|
|
2446
|
+
if (streamingResult.usage) {
|
|
2447
|
+
const { completion_tokens, prompt_tokens } = streamingResult.usage;
|
|
2448
|
+
if (completion_tokens !== void 0 && prompt_tokens !== void 0) {
|
|
2449
|
+
estimatedCosts = (selectedModel.sats_pricing?.completion ?? 0) * completion_tokens + (selectedModel.sats_pricing?.prompt ?? 0) * prompt_tokens;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
return estimatedCosts;
|
|
2453
|
+
}
|
|
2454
|
+
/**
|
|
2455
|
+
* Get pending cashu token amount
|
|
2456
|
+
*/
|
|
2457
|
+
_getPendingCashuTokenAmount() {
|
|
2458
|
+
const distribution = this.storageAdapter.getCachedTokenDistribution();
|
|
2459
|
+
return distribution.reduce((total, item) => total + item.amount, 0);
|
|
2460
|
+
}
|
|
2461
|
+
/**
|
|
2462
|
+
* Check if error message indicates a network error
|
|
2463
|
+
*/
|
|
2464
|
+
_isNetworkError(message) {
|
|
2465
|
+
return message.includes("NetworkError when attempting to fetch resource") || message.includes("Failed to fetch") || message.includes("Load failed");
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2468
|
+
* Handle errors and notify callbacks
|
|
2469
|
+
*/
|
|
2470
|
+
_handleError(error, callbacks) {
|
|
2471
|
+
this._log("ERROR", "[RoutstrClient] _handleError: Error occurred", error);
|
|
2472
|
+
if (error instanceof Error) {
|
|
2473
|
+
const isStreamError = error.message.includes("Error in input stream") || error.message.includes("Load failed");
|
|
2474
|
+
const modifiedErrorMsg = isStreamError ? "AI stream was cut off, turn on Keep Active or please try again" : error.message;
|
|
2475
|
+
this._log(
|
|
2476
|
+
"ERROR",
|
|
2477
|
+
`[RoutstrClient] _handleError: Error type=${error.constructor.name}, message=${modifiedErrorMsg}, isStreamError=${isStreamError}`
|
|
2478
|
+
);
|
|
2479
|
+
callbacks.onMessageAppend({
|
|
2480
|
+
role: "system",
|
|
2481
|
+
content: "Uncaught Error: " + modifiedErrorMsg + (this.alertLevel === "max" ? " | " + error.stack : "")
|
|
2482
|
+
});
|
|
2483
|
+
} else {
|
|
2484
|
+
callbacks.onMessageAppend({
|
|
2485
|
+
role: "system",
|
|
2486
|
+
content: "Unknown Error: Please tag Routstr on Nostr and/or retry."
|
|
2487
|
+
});
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* Check wallet balance and throw if insufficient
|
|
2492
|
+
*/
|
|
2493
|
+
async _checkBalance() {
|
|
2494
|
+
const balances = await this.walletAdapter.getBalances();
|
|
2495
|
+
const totalBalance = Object.values(balances).reduce((sum, v) => sum + v, 0);
|
|
2496
|
+
if (totalBalance <= 0) {
|
|
2497
|
+
throw new InsufficientBalanceError(1, 0);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
/**
|
|
2501
|
+
* Spend a token using CashuSpender with standardized error handling
|
|
2502
|
+
*/
|
|
2503
|
+
async _spendToken(params) {
|
|
2504
|
+
const { mintUrl, amount, baseUrl } = params;
|
|
2505
|
+
this._log(
|
|
2506
|
+
"DEBUG",
|
|
2507
|
+
`[RoutstrClient] _spendToken: mode=${this.mode}, amount=${amount}, baseUrl=${baseUrl}, mintUrl=${mintUrl}`
|
|
2508
|
+
);
|
|
2509
|
+
if (this.mode === "apikeys") {
|
|
2510
|
+
let parentApiKey = this.storageAdapter.getApiKey(baseUrl);
|
|
2511
|
+
if (!parentApiKey) {
|
|
2512
|
+
this._log(
|
|
2513
|
+
"DEBUG",
|
|
2514
|
+
`[RoutstrClient] _spendToken: No existing API key for ${baseUrl}, creating new one via Cashu`
|
|
2515
|
+
);
|
|
2516
|
+
const spendResult2 = await this.cashuSpender.spend({
|
|
2517
|
+
mintUrl,
|
|
2518
|
+
amount: amount * TOPUP_MARGIN,
|
|
2519
|
+
baseUrl: "",
|
|
2520
|
+
reuseToken: false
|
|
2521
|
+
});
|
|
2522
|
+
if (!spendResult2.token) {
|
|
2523
|
+
this._log(
|
|
2524
|
+
"ERROR",
|
|
2525
|
+
`[RoutstrClient] _spendToken: Failed to create Cashu token for API key creation, error:`,
|
|
2526
|
+
spendResult2.error
|
|
2527
|
+
);
|
|
2528
|
+
throw new Error(
|
|
2529
|
+
`[RoutstrClient] _spendToken: Failed to create Cashu token for API key creation, error: ${spendResult2.error}`
|
|
2530
|
+
);
|
|
2531
|
+
} else {
|
|
2532
|
+
this._log(
|
|
2533
|
+
"DEBUG",
|
|
2534
|
+
`[RoutstrClient] _spendToken: Cashu token created, token preview: ${spendResult2.token}`
|
|
2535
|
+
);
|
|
2536
|
+
}
|
|
2537
|
+
this._log(
|
|
2538
|
+
"DEBUG",
|
|
2539
|
+
`[RoutstrClient] _spendToken: Created API key for ${baseUrl}, key preview: ${spendResult2.token}, balance: ${spendResult2.balance}`
|
|
2540
|
+
);
|
|
2541
|
+
try {
|
|
2542
|
+
this.storageAdapter.setApiKey(baseUrl, spendResult2.token);
|
|
2543
|
+
} catch (error) {
|
|
2544
|
+
if (error instanceof Error && error.message.includes("ApiKey already exists")) {
|
|
2545
|
+
const tryReceiveTokenResult = await this.cashuSpender.receiveToken(
|
|
2546
|
+
spendResult2.token
|
|
2547
|
+
);
|
|
2548
|
+
if (tryReceiveTokenResult.success) {
|
|
2549
|
+
this._log(
|
|
2550
|
+
"DEBUG",
|
|
2551
|
+
`[RoutstrClient] _handleErrorResponse: Token restored successfully, amount=${tryReceiveTokenResult.amount}`
|
|
2552
|
+
);
|
|
2553
|
+
} else {
|
|
2554
|
+
this._log(
|
|
2555
|
+
"DEBUG",
|
|
2556
|
+
`[RoutstrClient] _handleErrorResponse: Token restore failed or not needed`
|
|
2557
|
+
);
|
|
2558
|
+
}
|
|
2559
|
+
this._log(
|
|
2560
|
+
"DEBUG",
|
|
2561
|
+
`[RoutstrClient] _spendToken: API key already exists for ${baseUrl}, using existing key`
|
|
2562
|
+
);
|
|
2563
|
+
} else {
|
|
2564
|
+
throw error;
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
parentApiKey = this.storageAdapter.getApiKey(baseUrl);
|
|
2568
|
+
} else {
|
|
2569
|
+
this._log(
|
|
2570
|
+
"DEBUG",
|
|
2571
|
+
`[RoutstrClient] _spendToken: Using existing API key for ${baseUrl}, key preview: ${parentApiKey.key}`
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
2574
|
+
let tokenBalance = 0;
|
|
2575
|
+
let tokenBalanceUnit = "sat";
|
|
2576
|
+
const apiKeyDistribution = this.storageAdapter.getApiKeyDistribution();
|
|
2577
|
+
const distributionForBaseUrl = apiKeyDistribution.find(
|
|
2578
|
+
(d) => d.baseUrl === baseUrl
|
|
2579
|
+
);
|
|
2580
|
+
if (distributionForBaseUrl) {
|
|
2581
|
+
tokenBalance = distributionForBaseUrl.amount;
|
|
2582
|
+
}
|
|
2583
|
+
if (tokenBalance === 0 && parentApiKey) {
|
|
2584
|
+
try {
|
|
2585
|
+
const balanceInfo = await this.balanceManager.getTokenBalance(
|
|
2586
|
+
parentApiKey.key,
|
|
2587
|
+
baseUrl
|
|
2588
|
+
);
|
|
2589
|
+
tokenBalance = balanceInfo.amount;
|
|
2590
|
+
tokenBalanceUnit = balanceInfo.unit;
|
|
2591
|
+
} catch (e) {
|
|
2592
|
+
this._log("WARN", "Could not get initial API key balance:", e);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
this._log(
|
|
2596
|
+
"DEBUG",
|
|
2597
|
+
`[RoutstrClient] _spendToken: Returning token with balance=${tokenBalance} ${tokenBalanceUnit}`
|
|
2598
|
+
);
|
|
2599
|
+
return {
|
|
2600
|
+
token: parentApiKey?.key ?? "",
|
|
2601
|
+
tokenBalance,
|
|
2602
|
+
tokenBalanceUnit
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
this._log(
|
|
2606
|
+
"DEBUG",
|
|
2607
|
+
`[RoutstrClient] _spendToken: Calling CashuSpender.spend for amount=${amount}, mintUrl=${mintUrl}, mode=${this.mode}`
|
|
2608
|
+
);
|
|
2609
|
+
const spendResult = await this.cashuSpender.spend({
|
|
2610
|
+
mintUrl,
|
|
2611
|
+
amount,
|
|
2612
|
+
baseUrl: this.mode === "lazyrefund" ? baseUrl : "",
|
|
2613
|
+
reuseToken: this.mode === "lazyrefund"
|
|
2614
|
+
});
|
|
2615
|
+
if (!spendResult.token) {
|
|
2616
|
+
this._log(
|
|
2617
|
+
"ERROR",
|
|
2618
|
+
`[RoutstrClient] _spendToken: CashuSpender.spend failed, error:`,
|
|
2619
|
+
spendResult.error
|
|
2620
|
+
);
|
|
2621
|
+
} else {
|
|
2622
|
+
this._log(
|
|
2623
|
+
"DEBUG",
|
|
2624
|
+
`[RoutstrClient] _spendToken: Cashu token created, token preview: ${spendResult.token}, balance: ${spendResult.balance} ${spendResult.unit ?? "sat"}`
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
return {
|
|
2628
|
+
token: spendResult.token,
|
|
2629
|
+
tokenBalance: spendResult.balance,
|
|
2630
|
+
tokenBalanceUnit: spendResult.unit ?? "sat"
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* Build request headers with common defaults and dev mock controls
|
|
2635
|
+
*/
|
|
2636
|
+
_buildBaseHeaders(additionalHeaders = {}, token) {
|
|
2637
|
+
const headers = {
|
|
2638
|
+
...additionalHeaders,
|
|
2639
|
+
"Content-Type": "application/json"
|
|
2640
|
+
};
|
|
2641
|
+
return headers;
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Attach auth headers using the active client mode
|
|
2645
|
+
*/
|
|
2646
|
+
_withAuthHeader(headers, token) {
|
|
2647
|
+
const nextHeaders = { ...headers };
|
|
2648
|
+
if (this.mode === "xcashu") {
|
|
2649
|
+
nextHeaders["X-Cashu"] = token;
|
|
2650
|
+
} else {
|
|
2651
|
+
nextHeaders["Authorization"] = `Bearer ${token}`;
|
|
2652
|
+
}
|
|
2653
|
+
return nextHeaders;
|
|
2654
|
+
}
|
|
2655
|
+
};
|
|
2656
|
+
|
|
2657
|
+
export { ProviderManager, RoutstrClient, StreamProcessor };
|
|
2658
|
+
//# sourceMappingURL=index.mjs.map
|
|
2659
|
+
//# sourceMappingURL=index.mjs.map
|