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.
@@ -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
- CocodHttpError,
17
- type CocodClient,
18
- type CocodState,
16
+ WalletHttpError,
17
+ type WalletClient,
18
+ type WalletRuntimeState,
19
19
  type WalletRecoveryProgress,
20
- } from "../wallet/cocod-client";
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: CocodState;
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: CocodClient;
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 CocodHttpError(400, "Invalid JSON body.");
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: CocodState): string {
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 CocodHttpError) {
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 CocodHttpError(400, `Missing required '${field}' field.`);
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 CocodHttpError(400, `Missing required '${field}' field.`);
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: CocodState;
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: CocodClient;
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 CocodHttpError(
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 CocodHttpError(404, "Mint quote not found");
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
- const entries = await deps.walletClient.getHistory(offset, limit);
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 CocodHttpError(
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 CocodHttpError(
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
- const manuallyEnabled = new Set(
1490
- state.manuallyEnabledProviders || [],
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
- const disabledProviders: string[] = [
1493
- ...new Set([
1494
- ...(state.disabledProviders || []),
1495
- ...(state.manuallyDisabledProviders || []),
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.includes(baseUrl),
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
- if (deps.maxTokens > 0) {
1752
- const isResponsesPath = url.pathname.includes("/responses");
1753
- if (isResponsesPath) {
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
+ }
@@ -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,