routstrd 0.4.10 → 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 +55 -15
- package/SECURITY.md +0 -1
- package/SKILL.md +267 -52
- package/dist/daemon/index.js +93747 -70861
- package/dist/index.js +65958 -42561
- package/package.json +3 -2
- package/src/cli.ts +358 -23
- package/src/daemon/http/index.ts +323 -38
- package/src/daemon/http/request-body.ts +67 -0
- 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 +941 -115
- 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 +87 -0
- package/src/daemon/wallet/wallet-client.ts +206 -0
- package/src/integrations/pi.ts +189 -34
- package/src/integrations/registry.ts +14 -0
- package/src/tui/usage/render.ts +161 -22
- 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/TUI refactor.md +0 -113
- package/src/daemon/wallet/cocod-client.ts +0 -505
package/src/daemon/http/index.ts
CHANGED
|
@@ -13,14 +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
|
+
import { applyDefaultOutputTokenLimit } from "./request-body";
|
|
25
|
+
import { collapseDuplicatedV1 } from "./request-path";
|
|
26
|
+
import {
|
|
27
|
+
buildCooldownsOutput,
|
|
28
|
+
type StoredCooldownEntry,
|
|
29
|
+
} from "../../utils/cooldowns";
|
|
24
30
|
|
|
25
31
|
// Hop-by-hop headers describe the *upstream* connection, not this one, and must
|
|
26
32
|
// never be copied onto our response. In particular, copying the upstream's
|
|
@@ -45,7 +51,7 @@ type ClientMode = "xcashu" | "lazyrefund" | "apikeys";
|
|
|
45
51
|
type WalletStatusOutput = {
|
|
46
52
|
daemon: "running";
|
|
47
53
|
wallet: "connected" | "recovering" | "error";
|
|
48
|
-
walletState:
|
|
54
|
+
walletState: WalletRuntimeState;
|
|
49
55
|
balances?: Record<string, number>;
|
|
50
56
|
mode: ClientMode;
|
|
51
57
|
error?: string;
|
|
@@ -56,7 +62,7 @@ type DaemonDeps = {
|
|
|
56
62
|
server: { close(cb?: () => void): void };
|
|
57
63
|
shutdown?: () => void;
|
|
58
64
|
store: any;
|
|
59
|
-
walletClient:
|
|
65
|
+
walletClient: WalletClient;
|
|
60
66
|
walletAdapter: any;
|
|
61
67
|
storageAdapter: any;
|
|
62
68
|
discoveryAdapter: any;
|
|
@@ -127,7 +133,7 @@ async function readJsonBody(
|
|
|
127
133
|
try {
|
|
128
134
|
return JSON.parse(bodyText) as Record<string, unknown>;
|
|
129
135
|
} catch {
|
|
130
|
-
throw new
|
|
136
|
+
throw new WalletHttpError(400, "Invalid JSON body.");
|
|
131
137
|
}
|
|
132
138
|
}
|
|
133
139
|
|
|
@@ -195,7 +201,7 @@ function toErrorMessage(error: unknown): string {
|
|
|
195
201
|
return error instanceof Error ? error.message : String(error);
|
|
196
202
|
}
|
|
197
203
|
|
|
198
|
-
function getWalletStateMessage(state:
|
|
204
|
+
function getWalletStateMessage(state: WalletRuntimeState): string {
|
|
199
205
|
switch (state) {
|
|
200
206
|
case "LOCKED":
|
|
201
207
|
return "Wallet is locked. Unlock it before performing wallet operations.";
|
|
@@ -215,7 +221,7 @@ function respondWithError(
|
|
|
215
221
|
error: unknown,
|
|
216
222
|
fallbackStatus = 500,
|
|
217
223
|
): void {
|
|
218
|
-
if (error instanceof
|
|
224
|
+
if (error instanceof WalletHttpError) {
|
|
219
225
|
sendJson(res, error.status, { error: error.message });
|
|
220
226
|
return;
|
|
221
227
|
}
|
|
@@ -248,7 +254,7 @@ function getRequiredStringField(
|
|
|
248
254
|
): string {
|
|
249
255
|
const value = requireStringField(body, field);
|
|
250
256
|
if (!value) {
|
|
251
|
-
throw new
|
|
257
|
+
throw new WalletHttpError(400, `Missing required '${field}' field.`);
|
|
252
258
|
}
|
|
253
259
|
return value;
|
|
254
260
|
}
|
|
@@ -267,7 +273,7 @@ function getRequiredPositiveNumberField(
|
|
|
267
273
|
return parsed;
|
|
268
274
|
}
|
|
269
275
|
}
|
|
270
|
-
throw new
|
|
276
|
+
throw new WalletHttpError(400, `Missing required '${field}' field.`);
|
|
271
277
|
}
|
|
272
278
|
|
|
273
279
|
function optionalStringField(
|
|
@@ -278,6 +284,21 @@ function optionalStringField(
|
|
|
278
284
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
279
285
|
}
|
|
280
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
|
+
|
|
281
302
|
function getCurrentMode(deps: DaemonDeps): ClientMode {
|
|
282
303
|
const stateMode = deps.store.getState()?.mode;
|
|
283
304
|
return stateMode || deps.mode || "apikeys";
|
|
@@ -329,7 +350,7 @@ async function buildStatusOutput(
|
|
|
329
350
|
}
|
|
330
351
|
|
|
331
352
|
async function buildWalletDetails(deps: DaemonDeps): Promise<{
|
|
332
|
-
state:
|
|
353
|
+
state: WalletRuntimeState;
|
|
333
354
|
ready: boolean;
|
|
334
355
|
balances?: Record<string, number>;
|
|
335
356
|
unit?: "sat";
|
|
@@ -370,12 +391,52 @@ function makeSdkLogger(...parts: string[]): SdkLogger {
|
|
|
370
391
|
};
|
|
371
392
|
}
|
|
372
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
|
+
|
|
373
434
|
export function createDaemonRequestHandler(deps: {
|
|
374
435
|
provider: string | null;
|
|
375
436
|
server: { close(cb?: () => void): void };
|
|
376
437
|
shutdown?: () => void;
|
|
377
438
|
store: any;
|
|
378
|
-
walletClient:
|
|
439
|
+
walletClient: WalletClient;
|
|
379
440
|
walletAdapter: any;
|
|
380
441
|
storageAdapter: any;
|
|
381
442
|
discoveryAdapter: any;
|
|
@@ -387,6 +448,8 @@ export function createDaemonRequestHandler(deps: {
|
|
|
387
448
|
mode?: "xcashu" | "apikeys";
|
|
388
449
|
/** Default max_tokens/max_output_tokens to inject when the client omits one. */
|
|
389
450
|
maxTokens: number;
|
|
451
|
+
/** Enable SDK automatic DeepSeek V4.1 Flash model-path selection. */
|
|
452
|
+
autoModelPath?: boolean;
|
|
390
453
|
/** Nostr hex pubkey for routstr review/audit events (kind 38425). */
|
|
391
454
|
routstrPubkey?: string;
|
|
392
455
|
/** Nostr hex pubkey for the routstr-21 model list only (kind 38423). Falls back to routstrPubkey. */
|
|
@@ -400,6 +463,19 @@ export function createDaemonRequestHandler(deps: {
|
|
|
400
463
|
const host = req.headers.host || "localhost";
|
|
401
464
|
const url = new URL(req.url || "/", `http://${host}`);
|
|
402
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
|
+
|
|
403
479
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
404
480
|
sendJson(res, 200, { ok: true });
|
|
405
481
|
return;
|
|
@@ -452,7 +528,7 @@ export function createDaemonRequestHandler(deps: {
|
|
|
452
528
|
if (req.method === "POST" && url.pathname === "/wallet/cleanup") {
|
|
453
529
|
await respond(res, async () => {
|
|
454
530
|
if (!deps.walletClient.cleanupStuckOperations) {
|
|
455
|
-
throw new
|
|
531
|
+
throw new WalletHttpError(
|
|
456
532
|
501,
|
|
457
533
|
"Wallet cleanup is not supported by this wallet client.",
|
|
458
534
|
);
|
|
@@ -466,12 +542,63 @@ export function createDaemonRequestHandler(deps: {
|
|
|
466
542
|
? body.minAgeMs
|
|
467
543
|
: undefined,
|
|
468
544
|
dryRun: body.dryRun === true,
|
|
545
|
+
force: body.force === true,
|
|
469
546
|
});
|
|
470
547
|
return { output: result };
|
|
471
548
|
});
|
|
472
549
|
return;
|
|
473
550
|
}
|
|
474
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,
|
|
583
|
+
});
|
|
584
|
+
return { output: result };
|
|
585
|
+
});
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
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
|
+
|
|
475
602
|
if (req.method === "POST" && url.pathname === "/wallet/receive/cashu") {
|
|
476
603
|
await respond(res, async () => {
|
|
477
604
|
const body = await readJsonBody(req);
|
|
@@ -502,7 +629,7 @@ export function createDaemonRequestHandler(deps: {
|
|
|
502
629
|
const operationId = decodeURIComponent(mintQuoteMatch[1]);
|
|
503
630
|
await respond(res, async () => {
|
|
504
631
|
const quote = await deps.walletClient.getMintQuote?.(operationId);
|
|
505
|
-
if (!quote) throw new
|
|
632
|
+
if (!quote) throw new WalletHttpError(404, "Mint quote not found");
|
|
506
633
|
return { output: quote };
|
|
507
634
|
});
|
|
508
635
|
return;
|
|
@@ -589,9 +716,25 @@ export function createDaemonRequestHandler(deps: {
|
|
|
589
716
|
await respond(res, async () => {
|
|
590
717
|
const offsetParam = url.searchParams.get("offset");
|
|
591
718
|
const limitParam = url.searchParams.get("limit");
|
|
719
|
+
const idParam = url.searchParams.get("id")?.trim() || "";
|
|
720
|
+
const types = parseHistoryTypes(url.searchParams.get("type"));
|
|
592
721
|
const offset = offsetParam ? parseInt(offsetParam, 10) || 0 : 0;
|
|
593
722
|
const limit = limitParam ? parseInt(limitParam, 10) || 50 : 50;
|
|
594
|
-
|
|
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
|
+
}
|
|
595
738
|
|
|
596
739
|
const encoded = entries.map((entry: HistoryEntry) => {
|
|
597
740
|
const base = { ...entry } as Record<string, unknown>;
|
|
@@ -630,12 +773,12 @@ export function createDaemonRequestHandler(deps: {
|
|
|
630
773
|
const amount = typeof pr.amount === "number" ? pr.amount : 0;
|
|
631
774
|
const mints = Array.isArray(pr.mints) ? pr.mints.join(", ") : "";
|
|
632
775
|
if (confirm) {
|
|
633
|
-
throw new
|
|
776
|
+
throw new WalletHttpError(
|
|
634
777
|
402,
|
|
635
778
|
`Failed to set username. Required amount: ${amount} SATS. Required mints: ${mints}`,
|
|
636
779
|
);
|
|
637
780
|
}
|
|
638
|
-
throw new
|
|
781
|
+
throw new WalletHttpError(
|
|
639
782
|
402,
|
|
640
783
|
`Payment required to set username: ${amount} SATS. ` +
|
|
641
784
|
`Use 'routstrd wallet npc username ${username} --confirm' to proceed`,
|
|
@@ -1285,6 +1428,124 @@ export function createDaemonRequestHandler(deps: {
|
|
|
1285
1428
|
return;
|
|
1286
1429
|
}
|
|
1287
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
|
+
|
|
1288
1549
|
// Client management endpoints
|
|
1289
1550
|
if (req.method === "GET" && url.pathname === "/clients") {
|
|
1290
1551
|
try {
|
|
@@ -1486,20 +1747,33 @@ export function createDaemonRequestHandler(deps: {
|
|
|
1486
1747
|
|
|
1487
1748
|
const state = deps.store.getState();
|
|
1488
1749
|
const baseUrlsList: string[] = state.baseUrlsList || [];
|
|
1489
|
-
|
|
1490
|
-
|
|
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)),
|
|
1491
1765
|
);
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
]),
|
|
1497
|
-
].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));
|
|
1498
1770
|
|
|
1499
1771
|
const providers = baseUrlsList.map((baseUrl, index) => ({
|
|
1500
1772
|
index,
|
|
1501
1773
|
baseUrl,
|
|
1502
|
-
disabled: disabledProviders
|
|
1774
|
+
disabled: inSet(disabledProviders, baseUrl),
|
|
1775
|
+
manuallyDisabled: inSet(manuallyDisabled, baseUrl),
|
|
1776
|
+
manuallyEnabled: inSet(manuallyEnabled, baseUrl),
|
|
1503
1777
|
}));
|
|
1504
1778
|
|
|
1505
1779
|
// Only count disabled providers that are actually in the current list
|
|
@@ -1523,6 +1797,23 @@ export function createDaemonRequestHandler(deps: {
|
|
|
1523
1797
|
return;
|
|
1524
1798
|
}
|
|
1525
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
|
+
|
|
1526
1817
|
if (req.method === "GET" && url.pathname === "/providers/reviews") {
|
|
1527
1818
|
try {
|
|
1528
1819
|
const state = deps.store.getState();
|
|
@@ -1747,17 +2038,10 @@ export function createDaemonRequestHandler(deps: {
|
|
|
1747
2038
|
// limit. Without this, the SDK prices at the provider's worst-case
|
|
1748
2039
|
// max_completion_cost, which varies widely across providers (2.3× for
|
|
1749
2040
|
// kimi-k3) and balloons during provider failover. Chat/completions use
|
|
1750
|
-
// max_tokens; the OpenAI Responses API uses max_output_tokens.
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
if (typeof bodyObj.max_output_tokens !== "number") {
|
|
1755
|
-
bodyObj.max_output_tokens = deps.maxTokens;
|
|
1756
|
-
}
|
|
1757
|
-
} else if (typeof bodyObj.max_tokens !== "number") {
|
|
1758
|
-
bodyObj.max_tokens = deps.maxTokens;
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
2041
|
+
// max_tokens; the OpenAI Responses API uses max_output_tokens. Endpoints
|
|
2042
|
+
// that do not define those fields (e.g. /v1/systemone) are forwarded
|
|
2043
|
+
// verbatim — injecting one makes a strict upstream answer 400.
|
|
2044
|
+
applyDefaultOutputTokenLimit(url.pathname, bodyObj, deps.maxTokens);
|
|
1761
2045
|
|
|
1762
2046
|
const forcedProvider: string | undefined =
|
|
1763
2047
|
url.searchParams.get("provider") ||
|
|
@@ -1786,6 +2070,7 @@ export function createDaemonRequestHandler(deps: {
|
|
|
1786
2070
|
requestBody,
|
|
1787
2071
|
path: url.pathname,
|
|
1788
2072
|
forcedProvider,
|
|
2073
|
+
autoModelPath: deps.autoModelPath === true,
|
|
1789
2074
|
headers: incomingHeaders,
|
|
1790
2075
|
walletAdapter: deps.walletAdapter,
|
|
1791
2076
|
storageAdapter: deps.storageAdapter,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-body shaping for the proxied path.
|
|
3
|
+
*
|
|
4
|
+
* The ingress forwards the caller's JSON body to the upstream provider. It is
|
|
5
|
+
* allowed to add exactly one thing — a default output-token limit — and only
|
|
6
|
+
* where that field is part of the endpoint's documented vocabulary. Anything
|
|
7
|
+
* else must be forwarded verbatim: several upstreams (notably TypeSafe's
|
|
8
|
+
* `POST /v1/systemone`) validate strictly and reject unknown fields, so an
|
|
9
|
+
* injected chat-completions field turns a valid request into an opaque
|
|
10
|
+
* `400 Invalid request.`
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* OpenAI-compatible endpoints whose bodies define `max_tokens`,
|
|
15
|
+
* `max_output_tokens`, and `stream`.
|
|
16
|
+
*
|
|
17
|
+
* Matched by path suffix so `/v1/chat/completions`, `/chat/completions`, and
|
|
18
|
+
* custom path-prefixed proxies all qualify. Kept in sync with
|
|
19
|
+
* `isOpenAiJsonBodyPath` in `@routstr/sdk` (exported there since 0.4.6); once
|
|
20
|
+
* routstrd depends on that version this local copy can be dropped.
|
|
21
|
+
*/
|
|
22
|
+
export const OPENAI_JSON_BODY_PATH_SUFFIXES = [
|
|
23
|
+
"/chat/completions",
|
|
24
|
+
"/completions",
|
|
25
|
+
"/responses",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when `pathname` addresses an endpoint whose body carries the OpenAI
|
|
30
|
+
* request vocabulary. Ignores a query string and a trailing slash.
|
|
31
|
+
*/
|
|
32
|
+
export function isOpenAiJsonBodyPath(pathname: string): boolean {
|
|
33
|
+
const path = (pathname.split("?")[0] ?? "").replace(/\/+$/, "");
|
|
34
|
+
return OPENAI_JSON_BODY_PATH_SUFFIXES.some((suffix) => path.endsWith(suffix));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cap the completion budget when the client does not set an output token
|
|
39
|
+
* limit. Without this, the SDK prices at the provider's worst-case
|
|
40
|
+
* `max_completion_cost`, which varies widely across providers (2.3× for
|
|
41
|
+
* kimi-k3) and balloons during provider failover. Chat/completions use
|
|
42
|
+
* `max_tokens`; the OpenAI Responses API uses `max_output_tokens`.
|
|
43
|
+
*
|
|
44
|
+
* Non-OpenAI endpoints are left untouched — `/v1/systemone` returns typed
|
|
45
|
+
* judgments rather than generated text, so it has no completion budget, and it
|
|
46
|
+
* rejects the field outright. Mutates `body` in place. Returns true when a
|
|
47
|
+
* field was added.
|
|
48
|
+
*/
|
|
49
|
+
export function applyDefaultOutputTokenLimit(
|
|
50
|
+
pathname: string,
|
|
51
|
+
body: Record<string, unknown>,
|
|
52
|
+
maxTokens: number,
|
|
53
|
+
): boolean {
|
|
54
|
+
if (!(maxTokens > 0) || !isOpenAiJsonBodyPath(pathname)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (pathname.split("?")[0]!.replace(/\/+$/, "").endsWith("/responses")) {
|
|
59
|
+
if (typeof body.max_output_tokens === "number") return false;
|
|
60
|
+
body.max_output_tokens = maxTokens;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof body.max_tokens === "number") return false;
|
|
65
|
+
body.max_tokens = maxTokens;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
@@ -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,
|