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.
@@ -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
- 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
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: CocodState;
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: CocodClient;
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 CocodHttpError(400, "Invalid JSON body.");
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: CocodState): string {
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 CocodHttpError) {
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 CocodHttpError(400, `Missing required '${field}' field.`);
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 CocodHttpError(400, `Missing required '${field}' field.`);
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: CocodState;
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: CocodClient;
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 CocodHttpError(
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 CocodHttpError(404, "Mint quote not found");
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
- 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
+ }
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 CocodHttpError(
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 CocodHttpError(
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
- const manuallyEnabled = new Set(
1491
- 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)),
1492
1765
  );
1493
- const disabledProviders: string[] = [
1494
- ...new Set([
1495
- ...(state.disabledProviders || []),
1496
- ...(state.manuallyDisabledProviders || []),
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.includes(baseUrl),
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
+ }
@@ -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,
@@ -1,4 +1,11 @@
1
- import { ModelManager, ProviderManager, type SdkStore } from "@routstr/sdk";
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
- const existing = bestById.get(model.id);
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(model.id, model);
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
- const modelsById = new Map(discoveredModels.map((model) => [model.id, model]));
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 = modelsById.get(modelId);
162
- return model || { id: modelId, name: modelId };
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 from first (cheapest) provider
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
- const firstProvider = allModels[cheapest.baseUrl];
208
- const modelInfo = firstProvider?.find((m: { id: string }) => m.id === modelId);
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: modelInfo.id,
250
+ id: modelId,
216
251
  name: modelInfo.name,
217
252
  description: modelInfo.description,
218
253
  context_length: modelInfo.context_length,