routstrd 0.4.11 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/SKILL.md +29 -4
- package/dist/daemon/index.js +93713 -72282
- package/dist/index.js +65080 -43216
- package/package.json +2 -2
- package/src/cli.ts +358 -23
- package/src/daemon/http/index.ts +318 -27
- package/src/daemon/http/request-path.ts +39 -0
- package/src/daemon/index.ts +1 -1
- package/src/daemon/models.ts +46 -11
- package/src/daemon/wallet/auto-refill.ts +20 -8
- package/src/daemon/wallet/cleanup.ts +16 -2
- package/src/daemon/wallet/coco-client.ts +921 -110
- package/src/daemon/wallet/index.ts +168 -50
- package/src/daemon/wallet/mint-quote-recovery.ts +119 -0
- package/src/daemon/wallet/recovery-probe.ts +312 -0
- package/src/daemon/wallet/recovery-work.ts +45 -0
- package/src/daemon/wallet/testing/fake-mint.ts +347 -0
- package/src/daemon/wallet/trusted-mints.ts +3 -3
- package/src/daemon/wallet/wallet-client.ts +206 -0
- package/src/integrations/pi.ts +53 -3
- package/src/utils/config.ts +3 -2
- package/src/utils/cooldowns.ts +134 -0
- package/src/utils/daemon-client.ts +53 -9
- package/src/utils/history.ts +9 -0
- package/src/utils/with-timeout.ts +21 -0
- package/src/daemon/wallet/cocod-client.ts +0 -505
package/src/daemon/http/index.ts
CHANGED
|
@@ -13,15 +13,20 @@ import type { HistoryEntry } from "@cashu/coco-core";
|
|
|
13
13
|
import { logger } from "../../utils/logger";
|
|
14
14
|
import { loadDaemonConfig, saveDaemonConfig } from "../config-store";
|
|
15
15
|
import {
|
|
16
|
-
|
|
17
|
-
type
|
|
18
|
-
type
|
|
16
|
+
WalletHttpError,
|
|
17
|
+
type WalletClient,
|
|
18
|
+
type WalletRuntimeState,
|
|
19
19
|
type WalletRecoveryProgress,
|
|
20
|
-
} from "../wallet/
|
|
20
|
+
} from "../wallet/wallet-client";
|
|
21
21
|
import { receiveCashuToken } from "../wallet";
|
|
22
22
|
import { getClientsFromStore } from "../../utils/clients";
|
|
23
23
|
import { getUsageSummary } from "./usage-summary";
|
|
24
24
|
import { applyDefaultOutputTokenLimit } from "./request-body";
|
|
25
|
+
import { collapseDuplicatedV1 } from "./request-path";
|
|
26
|
+
import {
|
|
27
|
+
buildCooldownsOutput,
|
|
28
|
+
type StoredCooldownEntry,
|
|
29
|
+
} from "../../utils/cooldowns";
|
|
25
30
|
|
|
26
31
|
// Hop-by-hop headers describe the *upstream* connection, not this one, and must
|
|
27
32
|
// never be copied onto our response. In particular, copying the upstream's
|
|
@@ -46,7 +51,7 @@ type ClientMode = "xcashu" | "lazyrefund" | "apikeys";
|
|
|
46
51
|
type WalletStatusOutput = {
|
|
47
52
|
daemon: "running";
|
|
48
53
|
wallet: "connected" | "recovering" | "error";
|
|
49
|
-
walletState:
|
|
54
|
+
walletState: WalletRuntimeState;
|
|
50
55
|
balances?: Record<string, number>;
|
|
51
56
|
mode: ClientMode;
|
|
52
57
|
error?: string;
|
|
@@ -57,7 +62,7 @@ type DaemonDeps = {
|
|
|
57
62
|
server: { close(cb?: () => void): void };
|
|
58
63
|
shutdown?: () => void;
|
|
59
64
|
store: any;
|
|
60
|
-
walletClient:
|
|
65
|
+
walletClient: WalletClient;
|
|
61
66
|
walletAdapter: any;
|
|
62
67
|
storageAdapter: any;
|
|
63
68
|
discoveryAdapter: any;
|
|
@@ -128,7 +133,7 @@ async function readJsonBody(
|
|
|
128
133
|
try {
|
|
129
134
|
return JSON.parse(bodyText) as Record<string, unknown>;
|
|
130
135
|
} catch {
|
|
131
|
-
throw new
|
|
136
|
+
throw new WalletHttpError(400, "Invalid JSON body.");
|
|
132
137
|
}
|
|
133
138
|
}
|
|
134
139
|
|
|
@@ -196,7 +201,7 @@ function toErrorMessage(error: unknown): string {
|
|
|
196
201
|
return error instanceof Error ? error.message : String(error);
|
|
197
202
|
}
|
|
198
203
|
|
|
199
|
-
function getWalletStateMessage(state:
|
|
204
|
+
function getWalletStateMessage(state: WalletRuntimeState): string {
|
|
200
205
|
switch (state) {
|
|
201
206
|
case "LOCKED":
|
|
202
207
|
return "Wallet is locked. Unlock it before performing wallet operations.";
|
|
@@ -216,7 +221,7 @@ function respondWithError(
|
|
|
216
221
|
error: unknown,
|
|
217
222
|
fallbackStatus = 500,
|
|
218
223
|
): void {
|
|
219
|
-
if (error instanceof
|
|
224
|
+
if (error instanceof WalletHttpError) {
|
|
220
225
|
sendJson(res, error.status, { error: error.message });
|
|
221
226
|
return;
|
|
222
227
|
}
|
|
@@ -249,7 +254,7 @@ function getRequiredStringField(
|
|
|
249
254
|
): string {
|
|
250
255
|
const value = requireStringField(body, field);
|
|
251
256
|
if (!value) {
|
|
252
|
-
throw new
|
|
257
|
+
throw new WalletHttpError(400, `Missing required '${field}' field.`);
|
|
253
258
|
}
|
|
254
259
|
return value;
|
|
255
260
|
}
|
|
@@ -268,7 +273,7 @@ function getRequiredPositiveNumberField(
|
|
|
268
273
|
return parsed;
|
|
269
274
|
}
|
|
270
275
|
}
|
|
271
|
-
throw new
|
|
276
|
+
throw new WalletHttpError(400, `Missing required '${field}' field.`);
|
|
272
277
|
}
|
|
273
278
|
|
|
274
279
|
function optionalStringField(
|
|
@@ -279,6 +284,21 @@ function optionalStringField(
|
|
|
279
284
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
280
285
|
}
|
|
281
286
|
|
|
287
|
+
function optionalStringArrayField(
|
|
288
|
+
body: Record<string, unknown>,
|
|
289
|
+
field: string,
|
|
290
|
+
): string[] | undefined {
|
|
291
|
+
const value = body[field];
|
|
292
|
+
if (value === undefined) return undefined;
|
|
293
|
+
if (
|
|
294
|
+
!Array.isArray(value) ||
|
|
295
|
+
value.some((item) => typeof item !== "string" || !item.trim())
|
|
296
|
+
) {
|
|
297
|
+
throw new WalletHttpError(400, `'${field}' must be an array of non-empty strings.`);
|
|
298
|
+
}
|
|
299
|
+
return value.map((item: string) => item.trim());
|
|
300
|
+
}
|
|
301
|
+
|
|
282
302
|
function getCurrentMode(deps: DaemonDeps): ClientMode {
|
|
283
303
|
const stateMode = deps.store.getState()?.mode;
|
|
284
304
|
return stateMode || deps.mode || "apikeys";
|
|
@@ -330,7 +350,7 @@ async function buildStatusOutput(
|
|
|
330
350
|
}
|
|
331
351
|
|
|
332
352
|
async function buildWalletDetails(deps: DaemonDeps): Promise<{
|
|
333
|
-
state:
|
|
353
|
+
state: WalletRuntimeState;
|
|
334
354
|
ready: boolean;
|
|
335
355
|
balances?: Record<string, number>;
|
|
336
356
|
unit?: "sat";
|
|
@@ -371,12 +391,52 @@ function makeSdkLogger(...parts: string[]): SdkLogger {
|
|
|
371
391
|
};
|
|
372
392
|
}
|
|
373
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Parse a `type` query parameter into a list of normalized transaction types.
|
|
396
|
+
* Accepts comma-separated values and is case-insensitive.
|
|
397
|
+
*/
|
|
398
|
+
export function parseHistoryTypes(raw: string | null | undefined): string[] {
|
|
399
|
+
if (!raw) return [];
|
|
400
|
+
return raw
|
|
401
|
+
.split(",")
|
|
402
|
+
.map((value) => value.trim().toLowerCase())
|
|
403
|
+
.filter((value) => value.length > 0);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Page size used when scanning history to apply a type filter. */
|
|
407
|
+
const HISTORY_TYPE_SCAN_PAGE_SIZE = 200;
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Return history entries matching `types`, with offset/limit applied after
|
|
411
|
+
* filtering. The coco history repository only paginates by raw position, so we
|
|
412
|
+
* scan pages until enough matches accumulate (or history is exhausted).
|
|
413
|
+
*/
|
|
414
|
+
export async function getHistoryByTypes(
|
|
415
|
+
client: Pick<WalletClient, "getHistory">,
|
|
416
|
+
types: string[],
|
|
417
|
+
offset: number,
|
|
418
|
+
limit: number,
|
|
419
|
+
): Promise<HistoryEntry[]> {
|
|
420
|
+
const wanted = new Set(types);
|
|
421
|
+
const matched: HistoryEntry[] = [];
|
|
422
|
+
let scanOffset = 0;
|
|
423
|
+
while (matched.length < offset + limit) {
|
|
424
|
+
const page = await client.getHistory(scanOffset, HISTORY_TYPE_SCAN_PAGE_SIZE);
|
|
425
|
+
for (const entry of page) {
|
|
426
|
+
if (wanted.has(entry.type)) matched.push(entry);
|
|
427
|
+
}
|
|
428
|
+
if (page.length < HISTORY_TYPE_SCAN_PAGE_SIZE) break;
|
|
429
|
+
scanOffset += HISTORY_TYPE_SCAN_PAGE_SIZE;
|
|
430
|
+
}
|
|
431
|
+
return matched.slice(offset, offset + limit);
|
|
432
|
+
}
|
|
433
|
+
|
|
374
434
|
export function createDaemonRequestHandler(deps: {
|
|
375
435
|
provider: string | null;
|
|
376
436
|
server: { close(cb?: () => void): void };
|
|
377
437
|
shutdown?: () => void;
|
|
378
438
|
store: any;
|
|
379
|
-
walletClient:
|
|
439
|
+
walletClient: WalletClient;
|
|
380
440
|
walletAdapter: any;
|
|
381
441
|
storageAdapter: any;
|
|
382
442
|
discoveryAdapter: any;
|
|
@@ -388,6 +448,8 @@ export function createDaemonRequestHandler(deps: {
|
|
|
388
448
|
mode?: "xcashu" | "apikeys";
|
|
389
449
|
/** Default max_tokens/max_output_tokens to inject when the client omits one. */
|
|
390
450
|
maxTokens: number;
|
|
451
|
+
/** Enable SDK automatic DeepSeek V4.1 Flash model-path selection. */
|
|
452
|
+
autoModelPath?: boolean;
|
|
391
453
|
/** Nostr hex pubkey for routstr review/audit events (kind 38425). */
|
|
392
454
|
routstrPubkey?: string;
|
|
393
455
|
/** Nostr hex pubkey for the routstr-21 model list only (kind 38423). Falls back to routstrPubkey. */
|
|
@@ -401,6 +463,19 @@ export function createDaemonRequestHandler(deps: {
|
|
|
401
463
|
const host = req.headers.host || "localhost";
|
|
402
464
|
const url = new URL(req.url || "/", `http://${host}`);
|
|
403
465
|
|
|
466
|
+
// The node canonicalizes exactly ONE optional "v1/" segment (routstr-core
|
|
467
|
+
// `_canonical_api_path`). A client whose base URL already ends in /v1 while
|
|
468
|
+
// its transport appends /v1 itself — the Anthropic SDKs do, the OpenAI ones
|
|
469
|
+
// do not — would otherwise get a paid upstream 404 from every provider in
|
|
470
|
+
// the pool. Collapse the duplicate before any routing or payment decision.
|
|
471
|
+
const canonicalPath = collapseDuplicatedV1(url.pathname);
|
|
472
|
+
if (canonicalPath !== url.pathname) {
|
|
473
|
+
logger.warn(
|
|
474
|
+
`[daemon] Collapsing duplicated '/v1' in request path: ${url.pathname} -> ${canonicalPath}`,
|
|
475
|
+
);
|
|
476
|
+
url.pathname = canonicalPath;
|
|
477
|
+
}
|
|
478
|
+
|
|
404
479
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
405
480
|
sendJson(res, 200, { ok: true });
|
|
406
481
|
return;
|
|
@@ -453,7 +528,7 @@ export function createDaemonRequestHandler(deps: {
|
|
|
453
528
|
if (req.method === "POST" && url.pathname === "/wallet/cleanup") {
|
|
454
529
|
await respond(res, async () => {
|
|
455
530
|
if (!deps.walletClient.cleanupStuckOperations) {
|
|
456
|
-
throw new
|
|
531
|
+
throw new WalletHttpError(
|
|
457
532
|
501,
|
|
458
533
|
"Wallet cleanup is not supported by this wallet client.",
|
|
459
534
|
);
|
|
@@ -467,12 +542,63 @@ export function createDaemonRequestHandler(deps: {
|
|
|
467
542
|
? body.minAgeMs
|
|
468
543
|
: undefined,
|
|
469
544
|
dryRun: body.dryRun === true,
|
|
545
|
+
force: body.force === true,
|
|
546
|
+
});
|
|
547
|
+
return { output: result };
|
|
548
|
+
});
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (req.method === "POST" && url.pathname === "/wallet/recover") {
|
|
553
|
+
await respond(res, async () => {
|
|
554
|
+
if (!deps.walletClient.recoverMintQuotes) {
|
|
555
|
+
throw new WalletHttpError(
|
|
556
|
+
501,
|
|
557
|
+
"Mint quote recovery is not supported by this wallet client.",
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const body = await readJsonBody(req);
|
|
562
|
+
const operationIds = optionalStringArrayField(body, "operationIds");
|
|
563
|
+
if (body.includeFailed === true && !operationIds?.length) {
|
|
564
|
+
throw new WalletHttpError(
|
|
565
|
+
400,
|
|
566
|
+
"'includeFailed' requires non-empty 'operationIds'.",
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
if (
|
|
570
|
+
body.timeoutMs !== undefined &&
|
|
571
|
+
(typeof body.timeoutMs !== "number" ||
|
|
572
|
+
!Number.isFinite(body.timeoutMs) || body.timeoutMs <= 0)
|
|
573
|
+
) {
|
|
574
|
+
throw new WalletHttpError(
|
|
575
|
+
400,
|
|
576
|
+
"'timeoutMs' must be a positive finite number.",
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
const result = await deps.walletClient.recoverMintQuotes({
|
|
580
|
+
operationIds,
|
|
581
|
+
includeFailed: body.includeFailed === true,
|
|
582
|
+
timeoutMs: body.timeoutMs as number | undefined,
|
|
470
583
|
});
|
|
471
584
|
return { output: result };
|
|
472
585
|
});
|
|
473
586
|
return;
|
|
474
587
|
}
|
|
475
588
|
|
|
589
|
+
if (req.method === "POST" && url.pathname === "/wallet/recover/operations") {
|
|
590
|
+
await respond(res, async () => {
|
|
591
|
+
if (!deps.walletClient.recoverStuckOperations) {
|
|
592
|
+
throw new WalletHttpError(
|
|
593
|
+
501,
|
|
594
|
+
"Stuck operation recovery is not supported by this wallet client.",
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
return { output: await deps.walletClient.recoverStuckOperations() };
|
|
598
|
+
});
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
476
602
|
if (req.method === "POST" && url.pathname === "/wallet/receive/cashu") {
|
|
477
603
|
await respond(res, async () => {
|
|
478
604
|
const body = await readJsonBody(req);
|
|
@@ -503,7 +629,7 @@ export function createDaemonRequestHandler(deps: {
|
|
|
503
629
|
const operationId = decodeURIComponent(mintQuoteMatch[1]);
|
|
504
630
|
await respond(res, async () => {
|
|
505
631
|
const quote = await deps.walletClient.getMintQuote?.(operationId);
|
|
506
|
-
if (!quote) throw new
|
|
632
|
+
if (!quote) throw new WalletHttpError(404, "Mint quote not found");
|
|
507
633
|
return { output: quote };
|
|
508
634
|
});
|
|
509
635
|
return;
|
|
@@ -590,9 +716,25 @@ export function createDaemonRequestHandler(deps: {
|
|
|
590
716
|
await respond(res, async () => {
|
|
591
717
|
const offsetParam = url.searchParams.get("offset");
|
|
592
718
|
const limitParam = url.searchParams.get("limit");
|
|
719
|
+
const idParam = url.searchParams.get("id")?.trim() || "";
|
|
720
|
+
const types = parseHistoryTypes(url.searchParams.get("type"));
|
|
593
721
|
const offset = offsetParam ? parseInt(offsetParam, 10) || 0 : 0;
|
|
594
722
|
const limit = limitParam ? parseInt(limitParam, 10) || 50 : 50;
|
|
595
|
-
|
|
723
|
+
|
|
724
|
+
let entries: HistoryEntry[];
|
|
725
|
+
if (idParam) {
|
|
726
|
+
const entry = await deps.walletClient.getHistoryEntryById(idParam);
|
|
727
|
+
entries = entry ? [entry] : [];
|
|
728
|
+
} else if (types.length > 0) {
|
|
729
|
+
entries = await getHistoryByTypes(
|
|
730
|
+
deps.walletClient,
|
|
731
|
+
types,
|
|
732
|
+
offset,
|
|
733
|
+
limit,
|
|
734
|
+
);
|
|
735
|
+
} else {
|
|
736
|
+
entries = await deps.walletClient.getHistory(offset, limit);
|
|
737
|
+
}
|
|
596
738
|
|
|
597
739
|
const encoded = entries.map((entry: HistoryEntry) => {
|
|
598
740
|
const base = { ...entry } as Record<string, unknown>;
|
|
@@ -631,12 +773,12 @@ export function createDaemonRequestHandler(deps: {
|
|
|
631
773
|
const amount = typeof pr.amount === "number" ? pr.amount : 0;
|
|
632
774
|
const mints = Array.isArray(pr.mints) ? pr.mints.join(", ") : "";
|
|
633
775
|
if (confirm) {
|
|
634
|
-
throw new
|
|
776
|
+
throw new WalletHttpError(
|
|
635
777
|
402,
|
|
636
778
|
`Failed to set username. Required amount: ${amount} SATS. Required mints: ${mints}`,
|
|
637
779
|
);
|
|
638
780
|
}
|
|
639
|
-
throw new
|
|
781
|
+
throw new WalletHttpError(
|
|
640
782
|
402,
|
|
641
783
|
`Payment required to set username: ${amount} SATS. ` +
|
|
642
784
|
`Use 'routstrd wallet npc username ${username} --confirm' to proceed`,
|
|
@@ -1286,6 +1428,124 @@ export function createDaemonRequestHandler(deps: {
|
|
|
1286
1428
|
return;
|
|
1287
1429
|
}
|
|
1288
1430
|
|
|
1431
|
+
if (req.method === "POST" && url.pathname === "/providers/nostr-sync") {
|
|
1432
|
+
try {
|
|
1433
|
+
const bodyText = await readBody(req);
|
|
1434
|
+
const body = bodyText ? JSON.parse(bodyText) : {};
|
|
1435
|
+
const indices = body.indices as number[] | undefined;
|
|
1436
|
+
|
|
1437
|
+
if (!Array.isArray(indices)) {
|
|
1438
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1439
|
+
res.end(
|
|
1440
|
+
JSON.stringify({
|
|
1441
|
+
error: "Missing or invalid 'indices' field (expected number[]).",
|
|
1442
|
+
}),
|
|
1443
|
+
);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const state = deps.store.getState();
|
|
1448
|
+
const baseUrlsList: string[] = state.baseUrlsList || [];
|
|
1449
|
+
|
|
1450
|
+
const validIndices: number[] = [];
|
|
1451
|
+
const skippedIndices: unknown[] = [];
|
|
1452
|
+
for (const idx of indices) {
|
|
1453
|
+
if (
|
|
1454
|
+
typeof idx === "number" &&
|
|
1455
|
+
Number.isInteger(idx) &&
|
|
1456
|
+
idx >= 0 &&
|
|
1457
|
+
idx < baseUrlsList.length
|
|
1458
|
+
) {
|
|
1459
|
+
if (!validIndices.includes(idx)) validIndices.push(idx);
|
|
1460
|
+
} else {
|
|
1461
|
+
skippedIndices.push(idx);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (validIndices.length === 0) {
|
|
1466
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1467
|
+
res.end(
|
|
1468
|
+
JSON.stringify({
|
|
1469
|
+
error: `No valid indices provided (valid range: 0-${Math.max(baseUrlsList.length - 1, 0)}).`,
|
|
1470
|
+
skipped: skippedIndices,
|
|
1471
|
+
}),
|
|
1472
|
+
);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Clearing both manual lists returns the provider to the
|
|
1477
|
+
// review-based state: the effective disabled set is the union of
|
|
1478
|
+
// disabledProviders (kind-38425 review sync) and the manual lists,
|
|
1479
|
+
// so with no manual entries the Nostr review verdict applies again.
|
|
1480
|
+
const manuallyDisabledProviders: string[] = [
|
|
1481
|
+
...(state.manuallyDisabledProviders || []),
|
|
1482
|
+
];
|
|
1483
|
+
const manuallyEnabledProviders: string[] = [
|
|
1484
|
+
...(state.manuallyEnabledProviders || []),
|
|
1485
|
+
];
|
|
1486
|
+
|
|
1487
|
+
const synced: string[] = [];
|
|
1488
|
+
for (const idx of validIndices) {
|
|
1489
|
+
const baseUrl = baseUrlsList[idx]!;
|
|
1490
|
+
const disabledPos = manuallyDisabledProviders.indexOf(baseUrl);
|
|
1491
|
+
if (disabledPos !== -1) {
|
|
1492
|
+
manuallyDisabledProviders.splice(disabledPos, 1);
|
|
1493
|
+
}
|
|
1494
|
+
const enabledPos = manuallyEnabledProviders.indexOf(baseUrl);
|
|
1495
|
+
if (enabledPos !== -1) {
|
|
1496
|
+
manuallyEnabledProviders.splice(enabledPos, 1);
|
|
1497
|
+
}
|
|
1498
|
+
synced.push(baseUrl);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
deps.store.getState().setManuallyDisabledProviders(manuallyDisabledProviders);
|
|
1502
|
+
deps.discoveryAdapter.setManuallyDisabledProviders(manuallyDisabledProviders);
|
|
1503
|
+
deps.store.getState().setManuallyEnabledProviders(manuallyEnabledProviders);
|
|
1504
|
+
deps.discoveryAdapter.setManuallyEnabledProviders(manuallyEnabledProviders);
|
|
1505
|
+
|
|
1506
|
+
// Recompute the review verdict now that the manual overrides are
|
|
1507
|
+
// gone. This is load-bearing, not just cosmetic: the SDK's review
|
|
1508
|
+
// sync skips manually-enabled providers when it rebuilds the
|
|
1509
|
+
// review-disabled set, so a provider that was manually enabled
|
|
1510
|
+
// across a review refresh is absent from that set — reading the
|
|
1511
|
+
// stale set here would fail OPEN for a review-rejected provider.
|
|
1512
|
+
const reviewed =
|
|
1513
|
+
await deps.modelManager.syncReviewedProvidersFromNostr(baseUrlsList);
|
|
1514
|
+
if (reviewed !== null) {
|
|
1515
|
+
deps.store.getState().setDisabledProviders(reviewed);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Report each synced provider's resulting state from the discovery
|
|
1519
|
+
// adapter (the source the router uses). With both manual lists
|
|
1520
|
+
// cleared for these URLs, membership in the adapter's disabled set
|
|
1521
|
+
// is exactly the review verdict.
|
|
1522
|
+
const reviewDisabled = new Set(
|
|
1523
|
+
deps.discoveryAdapter.getDisabledProviders() || [],
|
|
1524
|
+
);
|
|
1525
|
+
const resulting = synced.map((baseUrl) => ({
|
|
1526
|
+
baseUrl,
|
|
1527
|
+
disabled:
|
|
1528
|
+
reviewDisabled.has(baseUrl) ||
|
|
1529
|
+
reviewDisabled.has(normalizeProviderBaseUrl(baseUrl)),
|
|
1530
|
+
}));
|
|
1531
|
+
|
|
1532
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1533
|
+
res.end(
|
|
1534
|
+
JSON.stringify({
|
|
1535
|
+
output: {
|
|
1536
|
+
message: `Reverted ${synced.length} provider(s) to Nostr review-based state`,
|
|
1537
|
+
providers: resulting,
|
|
1538
|
+
...(skippedIndices.length > 0 ? { skipped: skippedIndices } : {}),
|
|
1539
|
+
},
|
|
1540
|
+
}),
|
|
1541
|
+
);
|
|
1542
|
+
} catch (error) {
|
|
1543
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1544
|
+
res.end(JSON.stringify({ error: String(error) }));
|
|
1545
|
+
}
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1289
1549
|
// Client management endpoints
|
|
1290
1550
|
if (req.method === "GET" && url.pathname === "/clients") {
|
|
1291
1551
|
try {
|
|
@@ -1487,20 +1747,33 @@ export function createDaemonRequestHandler(deps: {
|
|
|
1487
1747
|
|
|
1488
1748
|
const state = deps.store.getState();
|
|
1489
1749
|
const baseUrlsList: string[] = state.baseUrlsList || [];
|
|
1490
|
-
|
|
1491
|
-
|
|
1750
|
+
// Read disable/override state from the discovery adapter (the same
|
|
1751
|
+
// source the router/ProviderManager uses), not the mirror in the
|
|
1752
|
+
// SdkStore, which can lag behind the scheduled kind-38425 review
|
|
1753
|
+
// sync — that updates the adapter without touching the store.
|
|
1754
|
+
const manuallyEnabled = new Set<string>(
|
|
1755
|
+
(deps.discoveryAdapter.getManuallyEnabledProviders?.() || []) as string[],
|
|
1756
|
+
);
|
|
1757
|
+
const manuallyDisabled = new Set<string>(
|
|
1758
|
+
(deps.discoveryAdapter.getManuallyDisabledProviders?.() || []) as string[],
|
|
1759
|
+
);
|
|
1760
|
+
const disabledProviders = new Set<string>(
|
|
1761
|
+
[
|
|
1762
|
+
...((deps.discoveryAdapter.getDisabledProviders() || []) as string[]),
|
|
1763
|
+
...manuallyDisabled,
|
|
1764
|
+
].filter((url) => !manuallyEnabled.has(url)),
|
|
1492
1765
|
);
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
]),
|
|
1498
|
-
].filter((url) => !manuallyEnabled.has(url));
|
|
1766
|
+
// Adapter URLs are normalized (trailing slash); the store list may
|
|
1767
|
+
// not be. Compare against both forms.
|
|
1768
|
+
const inSet = (set: Set<string>, baseUrl: string) =>
|
|
1769
|
+
set.has(baseUrl) || set.has(normalizeProviderBaseUrl(baseUrl));
|
|
1499
1770
|
|
|
1500
1771
|
const providers = baseUrlsList.map((baseUrl, index) => ({
|
|
1501
1772
|
index,
|
|
1502
1773
|
baseUrl,
|
|
1503
|
-
disabled: disabledProviders
|
|
1774
|
+
disabled: inSet(disabledProviders, baseUrl),
|
|
1775
|
+
manuallyDisabled: inSet(manuallyDisabled, baseUrl),
|
|
1776
|
+
manuallyEnabled: inSet(manuallyEnabled, baseUrl),
|
|
1504
1777
|
}));
|
|
1505
1778
|
|
|
1506
1779
|
// Only count disabled providers that are actually in the current list
|
|
@@ -1524,6 +1797,23 @@ export function createDaemonRequestHandler(deps: {
|
|
|
1524
1797
|
return;
|
|
1525
1798
|
}
|
|
1526
1799
|
|
|
1800
|
+
// Providers/models the router is currently skipping. Cooldown state lives
|
|
1801
|
+
// in the SdkStore (written by the SDK's ProviderManager); entries that have
|
|
1802
|
+
// already expired are filtered out by buildCooldownsOutput.
|
|
1803
|
+
if (req.method === "GET" && url.pathname === "/cooldowns") {
|
|
1804
|
+
await respond(res, async () => {
|
|
1805
|
+
const state = deps.store.getState();
|
|
1806
|
+
const stored: StoredCooldownEntry[] = state.providersOnCooldown || [];
|
|
1807
|
+
return {
|
|
1808
|
+
output: buildCooldownsOutput(
|
|
1809
|
+
stored,
|
|
1810
|
+
deps.providerManager.getCooldownDurationMs(),
|
|
1811
|
+
),
|
|
1812
|
+
};
|
|
1813
|
+
});
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1527
1817
|
if (req.method === "GET" && url.pathname === "/providers/reviews") {
|
|
1528
1818
|
try {
|
|
1529
1819
|
const state = deps.store.getState();
|
|
@@ -1780,6 +2070,7 @@ export function createDaemonRequestHandler(deps: {
|
|
|
1780
2070
|
requestBody,
|
|
1781
2071
|
path: url.pathname,
|
|
1782
2072
|
forcedProvider,
|
|
2073
|
+
autoModelPath: deps.autoModelPath === true,
|
|
1783
2074
|
headers: incomingHeaders,
|
|
1784
2075
|
walletAdapter: deps.walletAdapter,
|
|
1785
2076
|
storageAdapter: deps.storageAdapter,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-path canonicalization for the proxied path.
|
|
3
|
+
*
|
|
4
|
+
* routstr-core reduces an incoming path to its allowlist key by stripping
|
|
5
|
+
* exactly ONE optional leading `v1/` segment (`_canonical_api_path` in
|
|
6
|
+
* `routstr/proxy.py`), then requires an exact match against its endpoint
|
|
7
|
+
* table. Both `/chat/completions` and `/v1/chat/completions` therefore reach
|
|
8
|
+
* the same handler, and no spelling with the prefix repeated ever does:
|
|
9
|
+
* `/v1/v1/messages` canonicalizes to `v1/messages`, which is not an endpoint,
|
|
10
|
+
* so the node answers `404 Path '/v1/v1/messages' not found`.
|
|
11
|
+
*
|
|
12
|
+
* That second prefix is not hypothetical — it is what an Anthropic-style
|
|
13
|
+
* client produces when its base URL already ends in `/v1`, because the
|
|
14
|
+
* Anthropic SDKs append `/v1/messages` themselves (the OpenAI SDKs append only
|
|
15
|
+
* `/chat/completions` and leave the prefix to the configured base URL). One
|
|
16
|
+
* such client turns every request into a paid upstream 404 on every provider in
|
|
17
|
+
* the pool, so collapse the duplicate here, before any routing or payment
|
|
18
|
+
* decision.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Collapse a repeated leading `/v1` segment to a single one.
|
|
23
|
+
*
|
|
24
|
+
* `/v1/v1/messages` and `/v1/v1/v1/messages` both become `/v1/messages`;
|
|
25
|
+
* `/v1/messages`, `/chat/completions`, and every other spelling are returned
|
|
26
|
+
* unchanged. Only the repeated `v1` segment is touched: the node owns the
|
|
27
|
+
* endpoint allowlist, and rewriting anything else here would make this
|
|
28
|
+
* ingress answer for paths it knows nothing about.
|
|
29
|
+
*
|
|
30
|
+
* Never throws and is idempotent: each pass shortens the path, so a path
|
|
31
|
+
* that does not start with a doubled `/v1` is returned as-is.
|
|
32
|
+
*/
|
|
33
|
+
export function collapseDuplicatedV1(pathname: string): string {
|
|
34
|
+
let path = pathname;
|
|
35
|
+
while (path.startsWith("/v1/v1/") || path === "/v1/v1") {
|
|
36
|
+
path = path.slice(3);
|
|
37
|
+
}
|
|
38
|
+
return path;
|
|
39
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -181,7 +181,6 @@ export async function runDaemon(argv: string[] = process.argv): Promise<void> {
|
|
|
181
181
|
};
|
|
182
182
|
|
|
183
183
|
const walletAdapter = await createWalletAdapter({
|
|
184
|
-
cocodPath: config.cocodPath,
|
|
185
184
|
walletClient,
|
|
186
185
|
getAutoRefillConfig,
|
|
187
186
|
nwcConnectionString: config.nwc?.connectionString,
|
|
@@ -214,6 +213,7 @@ export async function runDaemon(argv: string[] = process.argv): Promise<void> {
|
|
|
214
213
|
getModelProviders,
|
|
215
214
|
refreshProvidersAndModels,
|
|
216
215
|
mode: config.mode || "apikeys",
|
|
216
|
+
autoModelPath: config.autoModelPath === true,
|
|
217
217
|
maxTokens: config.maxTokens ?? 64000,
|
|
218
218
|
routstrPubkey: config.routstrPubkey,
|
|
219
219
|
routstrModelsPubkey: config.routstrModelsPubkey,
|
package/src/daemon/models.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
canonicalIdForModel,
|
|
3
|
+
findModelForId,
|
|
4
|
+
ModelManager,
|
|
5
|
+
ProviderManager,
|
|
6
|
+
type Model,
|
|
7
|
+
type SdkStore,
|
|
8
|
+
} from "@routstr/sdk";
|
|
2
9
|
import type { ExposedModel } from "./types";
|
|
3
10
|
import { logger } from "../utils/logger";
|
|
4
11
|
|
|
@@ -66,13 +73,16 @@ export function createModelService(
|
|
|
66
73
|
|
|
67
74
|
for (const model of models as PricedModel[]) {
|
|
68
75
|
if (!model.sats_pricing) continue;
|
|
69
|
-
|
|
76
|
+
// Group by canonical id so providers serving the same model under a
|
|
77
|
+
// mapped variant id or alias fold into one aggregate entry.
|
|
78
|
+
const canonicalId = canonicalIdForModel(model as Model);
|
|
79
|
+
const existing = bestById.get(canonicalId);
|
|
70
80
|
if (
|
|
71
81
|
!existing ||
|
|
72
82
|
(model.sats_pricing.completion ?? 0) <
|
|
73
83
|
(existing.sats_pricing?.completion ?? 0)
|
|
74
84
|
) {
|
|
75
|
-
bestById.set(
|
|
85
|
+
bestById.set(canonicalId, model);
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
88
|
}
|
|
@@ -155,11 +165,19 @@ export function createModelService(
|
|
|
155
165
|
);
|
|
156
166
|
}
|
|
157
167
|
|
|
158
|
-
|
|
159
|
-
|
|
168
|
+
// Resolve each routstr21 id against the aggregated provider models. The
|
|
169
|
+
// aggregation folds providers' mapped variant ids/aliases into one entry
|
|
170
|
+
// per canonical id, but the surviving entry is the provider's raw model,
|
|
171
|
+
// whose `.id` may be the variant (e.g. z-ai-glm-5-3-flash) — while still
|
|
172
|
+
// carrying the full metadata (context_length, architecture, reasoning).
|
|
173
|
+
// A plain id map would miss and degrade the entry to a bare stub, so
|
|
174
|
+
// resolve through the SDK's model mappings (exact native id wins, same as
|
|
175
|
+
// getModelProviders) and always expose the requested canonical id.
|
|
160
176
|
return routstr21ModelIds.map((modelId) => {
|
|
161
|
-
const model =
|
|
162
|
-
return model
|
|
177
|
+
const model = findModelForId(discoveredModels as Model[], modelId);
|
|
178
|
+
return model
|
|
179
|
+
? { ...model, id: modelId }
|
|
180
|
+
: { id: modelId, name: modelId };
|
|
163
181
|
});
|
|
164
182
|
};
|
|
165
183
|
|
|
@@ -201,18 +219,35 @@ export function createModelService(
|
|
|
201
219
|
return null;
|
|
202
220
|
}
|
|
203
221
|
|
|
204
|
-
// Get model metadata
|
|
222
|
+
// Get model metadata. Prefer a provider that serves the requested
|
|
223
|
+
// canonical id natively (nicest name/description); fall back to the
|
|
224
|
+
// cheapest provider matched through the SDK's model mappings, since it
|
|
225
|
+
// may only know the model under a mapped variant id or alias.
|
|
205
226
|
const cheapest = providers[0]!;
|
|
206
227
|
const allModels = modelManager.getAllCachedModels();
|
|
207
|
-
|
|
208
|
-
|
|
228
|
+
let modelInfo: Model | undefined;
|
|
229
|
+
for (const p of providers) {
|
|
230
|
+
const exact = (allModels[p.baseUrl] as Model[] | undefined)?.find(
|
|
231
|
+
(m) => m.id === modelId,
|
|
232
|
+
);
|
|
233
|
+
if (exact) {
|
|
234
|
+
modelInfo = exact;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!modelInfo) {
|
|
239
|
+
const firstProvider = allModels[cheapest.baseUrl];
|
|
240
|
+
modelInfo = firstProvider
|
|
241
|
+
? findModelForId(firstProvider as Model[], modelId)
|
|
242
|
+
: undefined;
|
|
243
|
+
}
|
|
209
244
|
|
|
210
245
|
if (!modelInfo) {
|
|
211
246
|
return null;
|
|
212
247
|
}
|
|
213
248
|
|
|
214
249
|
return {
|
|
215
|
-
id:
|
|
250
|
+
id: modelId,
|
|
216
251
|
name: modelInfo.name,
|
|
217
252
|
description: modelInfo.description,
|
|
218
253
|
context_length: modelInfo.context_length,
|