routstrd 0.3.10 → 0.4.0

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.
Files changed (37) hide show
  1. package/IMPLEMENTATION.md +253 -0
  2. package/README.md +45 -0
  3. package/SECURITY.md +23 -0
  4. package/SKILL.md +13 -3
  5. package/bun.lock +68 -41
  6. package/dist/daemon/index.js +47850 -11349
  7. package/dist/index.js +4730 -448
  8. package/package.json +8 -3
  9. package/src/cli.test.ts +55 -0
  10. package/src/cli.ts +502 -229
  11. package/src/daemon/args.ts +8 -1
  12. package/src/daemon/models.ts +119 -39
  13. package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
  14. package/src/daemon/wallet/coco-client.test.ts +508 -0
  15. package/src/daemon/wallet/coco-client.ts +835 -0
  16. package/src/daemon/wallet/cocod-client.ts +86 -2
  17. package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
  18. package/src/daemon/wallet/migration.test.ts +147 -0
  19. package/src/daemon/wallet/migration.ts +209 -0
  20. package/src/daemon/wallet/paths.ts +33 -0
  21. package/src/integrations/claudecode.ts +2 -1
  22. package/src/integrations/hermes.ts +46 -6
  23. package/src/integrations/openclaw.ts +3 -2
  24. package/src/integrations/opencode.ts +3 -2
  25. package/src/integrations/pi.ts +3 -2
  26. package/src/integrations/registry.ts +7 -5
  27. package/src/start-daemon.ts +168 -22
  28. package/src/tui/usage/app.ts +41 -2
  29. package/src/tui/usage/render.ts +10 -3
  30. package/src/tui/usage/types.ts +6 -0
  31. package/src/utils/clients.ts +21 -0
  32. package/src/utils/config.ts +11 -0
  33. package/src/utils/daemon-client.ts +104 -25
  34. package/src/utils/logger.ts +42 -28
  35. package/src/utils/update-checker.ts +114 -0
  36. package/tests/utils/daemon-client.test.ts +61 -0
  37. package/tests/wallet/short-keyset-token.test.ts +106 -0
@@ -1,8 +1,10 @@
1
1
  export function parseArgs(argv: string[]): {
2
2
  port: number;
3
+ host: string | null;
3
4
  provider: string | null;
4
5
  } {
5
6
  const portFlagIndex = argv.findIndex((arg) => arg === "--port");
7
+ const hostFlagIndex = argv.findIndex((arg) => arg === "--host");
6
8
  const providerFlagIndex = argv.findIndex(
7
9
  (arg) => arg === "--provider" || arg === "-p",
8
10
  );
@@ -11,9 +13,14 @@ export function parseArgs(argv: string[]): {
11
13
  portFlagIndex !== -1
12
14
  ? Number.parseInt(argv[portFlagIndex + 1] || "8008", 10)
13
15
  : 8008;
16
+
17
+ const hostValue =
18
+ hostFlagIndex !== -1 ? argv[hostFlagIndex + 1] : undefined;
19
+ const host = hostValue?.trim() || null;
20
+
14
21
  const providerValue =
15
22
  providerFlagIndex !== -1 ? argv[providerFlagIndex + 1] : undefined;
16
23
  const provider = providerValue ? providerValue.trim() : null;
17
24
 
18
- return { port, provider };
25
+ return { port, host, provider };
19
26
  }
@@ -1,9 +1,10 @@
1
- import { ModelManager, type SdkStore } from "@routstr/sdk";
1
+ import { ModelManager, ProviderManager, type SdkStore } from "@routstr/sdk";
2
2
  import type { ExposedModel } from "./types";
3
3
  import { logger } from "../utils/logger";
4
4
 
5
5
  export type ModelProviderInfo = {
6
6
  baseUrl: string;
7
+ disabled: boolean;
7
8
  pricing: {
8
9
  prompt: number;
9
10
  completion: number;
@@ -16,16 +17,75 @@ export type ModelWithProviders = ExposedModel & {
16
17
  providers: ModelProviderInfo[];
17
18
  };
18
19
 
19
- export function createModelService(modelManager: ModelManager, store: SdkStore) {
20
+ export function createModelService(
21
+ modelManager: ModelManager,
22
+ providerManager: ProviderManager,
23
+ store: SdkStore,
24
+ ) {
20
25
  let providerBootstrapPromise: Promise<void> | null = null;
21
26
 
27
+ const normalizeBaseUrl = (url: string): string =>
28
+ url.endsWith("/") ? url : `${url}/`;
29
+
30
+ /**
31
+ * Build the same cheapest-per-model view as ModelManager.fetchModels, but
32
+ * exclusively from the persisted cache. Model-list reads must not wait for
33
+ * unavailable providers; explicit and scheduled refreshes own network I/O.
34
+ */
35
+ const getCachedModels = (): ExposedModel[] => {
36
+ type PricedModel = ExposedModel & {
37
+ sats_pricing?: { completion?: number };
38
+ };
39
+
40
+ const cachedByProvider = modelManager.getAllCachedModels();
41
+ const currentProviders = new Set(
42
+ modelManager.getBaseUrls().map(normalizeBaseUrl),
43
+ );
44
+ const s = store.getState();
45
+ const manuallyEnabled = new Set(
46
+ (s.manuallyEnabledProviders || []).map(normalizeBaseUrl),
47
+ );
48
+ const disabledProviders = new Set(
49
+ [
50
+ ...(s.disabledProviders || []),
51
+ ...(s.manuallyDisabledProviders || []),
52
+ ]
53
+ .map(normalizeBaseUrl)
54
+ .filter((url) => !manuallyEnabled.has(url)),
55
+ );
56
+ const bestById = new Map<string, PricedModel>();
57
+
58
+ for (const [baseUrl, models] of Object.entries(cachedByProvider)) {
59
+ const normalized = normalizeBaseUrl(baseUrl);
60
+ if (
61
+ disabledProviders.has(normalized) ||
62
+ (currentProviders.size > 0 && !currentProviders.has(normalized))
63
+ ) {
64
+ continue;
65
+ }
66
+
67
+ for (const model of models as PricedModel[]) {
68
+ if (!model.sats_pricing) continue;
69
+ const existing = bestById.get(model.id);
70
+ if (
71
+ !existing ||
72
+ (model.sats_pricing.completion ?? 0) <
73
+ (existing.sats_pricing?.completion ?? 0)
74
+ ) {
75
+ bestById.set(model.id, model);
76
+ }
77
+ }
78
+ }
79
+
80
+ return [...bestById.values()];
81
+ };
82
+
22
83
  const ensureProvidersBootstrapped = (): Promise<void> => {
23
84
  if (!providerBootstrapPromise) {
24
85
  providerBootstrapPromise = (async () => {
25
86
  logger.log("Bootstrapping providers...");
26
87
  const providers = await modelManager.bootstrapProviders(false);
27
88
  logger.log(`Bootstrapped ${providers.length} providers`);
28
- await modelManager.fetchModels(providers);
29
89
 
30
90
  // Sync discovered providers into the store so `providers list` reflects
31
91
  // the same set that the model manager knows about.
@@ -44,6 +104,7 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
44
104
 
45
105
  logger.log("Provider bootstrap complete.");
46
106
  })().catch((error) => {
107
+ providerBootstrapPromise = null;
47
108
  logger.error("Provider bootstrap failed:", error);
48
109
  throw error;
49
110
  });
@@ -54,16 +115,27 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
54
115
  const getRoutstr21Models = async (
55
116
  forceRefresh = false,
56
117
  ): Promise<ExposedModel[]> => {
57
- await ensureProvidersBootstrapped();
58
-
59
118
  const routstr21ModelIds = Array.from(
60
119
  new Set(await modelManager.fetchRoutstr21Models(forceRefresh)),
61
120
  ).slice(0, 21);
62
- const baseUrls = modelManager.getBaseUrls();
63
- const discoveredModels = await modelManager.fetchModels(
64
- baseUrls,
65
- forceRefresh,
66
- );
121
+
122
+ let discoveredModels: ExposedModel[];
123
+ if (!forceRefresh) {
124
+ discoveredModels = getCachedModels();
125
+ } else {
126
+ discoveredModels = [];
127
+ }
128
+
129
+ // Warm reads are cache-only. A cold start and explicit refresh still
130
+ // populate models from the provider network.
131
+ if (forceRefresh || discoveredModels.length === 0) {
132
+ await ensureProvidersBootstrapped();
133
+ discoveredModels = await modelManager.fetchModels(
134
+ modelManager.getBaseUrls(),
135
+ forceRefresh,
136
+ );
137
+ }
138
+
67
139
  const modelsById = new Map(discoveredModels.map((model) => [model.id, model]));
68
140
 
69
141
  return routstr21ModelIds.map((modelId) => {
@@ -77,33 +149,42 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
77
149
  ): Promise<ModelWithProviders | null> => {
78
150
  await ensureProvidersBootstrapped();
79
151
 
80
- const allModels = modelManager.getAllCachedModels();
81
- const providers: ModelProviderInfo[] = [];
82
-
83
- for (const [baseUrl, models] of Object.entries(allModels)) {
84
- const model = models.find((m) => m.id === modelId);
85
- if (model && model.sats_pricing) {
86
- providers.push({
87
- baseUrl,
88
- pricing: {
89
- prompt: model.sats_pricing.prompt,
90
- completion: model.sats_pricing.completion,
91
- request: model.sats_pricing.request,
92
- max_cost: model.sats_pricing.max_cost,
93
- },
94
- });
95
- }
96
- }
152
+ const s = store.getState();
153
+ const manuallyEnabled = new Set<string>(
154
+ (s.manuallyEnabledProviders || []).map(normalizeBaseUrl),
155
+ );
156
+ const disabledSet = new Set<string>(
157
+ [
158
+ ...(s.disabledProviders || []),
159
+ ...(s.manuallyDisabledProviders || []),
160
+ ].filter((url) => !manuallyEnabled.has(normalizeBaseUrl(url))),
161
+ );
162
+
163
+ // Use the SDK ranking (sorted by prompt+completion per million tokens)
164
+ // so the display order matches real routing. includeDisabled keeps
165
+ // disabled providers visible so we can annotate them.
166
+ const ranking = providerManager.getProviderPriceRankingForModel(modelId, {
167
+ includeDisabled: true,
168
+ });
97
169
 
98
- // Sort by max_cost (cheapest first)
99
- providers.sort((a, b) => a.pricing.max_cost - b.pricing.max_cost);
170
+ const providers: ModelProviderInfo[] = ranking.map((entry: any) => ({
171
+ baseUrl: entry.baseUrl,
172
+ disabled: disabledSet.has(entry.baseUrl),
173
+ pricing: {
174
+ prompt: entry.promptPerMillion / 1_000_000,
175
+ completion: entry.completionPerMillion / 1_000_000,
176
+ request: entry.model.sats_pricing?.request ?? 0,
177
+ max_cost: entry.model.sats_pricing?.max_cost ?? 0,
178
+ },
179
+ }));
100
180
 
101
181
  if (providers.length === 0) {
102
182
  return null;
103
183
  }
104
184
 
105
- // Get model metadata from first provider that has it
185
+ // Get model metadata from first (cheapest) provider
106
186
  const cheapest = providers[0]!;
187
+ const allModels = modelManager.getAllCachedModels();
107
188
  const firstProvider = allModels[cheapest.baseUrl];
108
189
  const modelInfo = firstProvider?.find((m: { id: string }) => m.id === modelId);
109
190
 
@@ -149,28 +230,27 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
149
230
  undefined,
150
231
  true,
151
232
  );
152
- if (reviewedDisabled.length > 0) {
233
+ if (reviewedDisabled && reviewedDisabled.length > 0) {
153
234
  console.log(
154
235
  `Review sync disabled ${reviewedDisabled.length} provider(s): ${reviewedDisabled.join(", ")}`,
155
236
  );
156
237
  }
157
238
 
158
239
  // Sync discovered providers into the store
159
- const { baseUrlsList, setBaseUrlsList, disabledProviders, setDisabledProviders } =
160
- store.getState() as any;
240
+ const { setBaseUrlsList, setDisabledProviders } = store.getState() as any;
161
241
 
162
242
  // Replace baseUrlsList with the fresh provider list
163
243
  setBaseUrlsList(providers);
164
244
 
165
- // Merge review-disabled providers into the store's disabled list
166
- const existingDisabled = new Set(disabledProviders || []);
167
- for (const url of reviewedDisabled) {
168
- existingDisabled.add(url);
245
+ // Mirror the review-disabled set into the store's auto-disabled list.
246
+ // `null` means the review sync left the adapter unchanged (e.g. no lgtm
247
+ // reviews found), so we must not clobber the store list with an empty array.
248
+ if (reviewedDisabled !== null) {
249
+ setDisabledProviders(reviewedDisabled);
169
250
  }
170
- setDisabledProviders([...existingDisabled]);
171
251
 
172
252
  console.log(
173
- `Provider refresh complete: ${providers.length} total, ${existingDisabled.size} disabled`,
253
+ `Provider refresh complete: ${providers.length} total, ${reviewedDisabled?.length ?? store.getState().disabledProviders?.length ?? 0} review-disabled`,
174
254
  );
175
255
  };
176
256
 
@@ -0,0 +1,252 @@
1
+ import { afterEach, describe, expect, it, mock } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+
6
+ // The real NPCPlugin opens a websocket to npubx.cash once the wallet is ready.
7
+ // Mock the module before importing coco-client so tests never touch the network.
8
+ const npcState = {
9
+ info: { name: "alice" as string | null, pubkey: "ab".repeat(32) },
10
+ usernameResult: { success: true } as
11
+ | { success: true }
12
+ | { success: false; pr: { amount: number; mints: string[] } },
13
+ setUsernameCalls: [] as Array<{ username: string; attemptPayment?: boolean }>,
14
+ syncCalls: 0,
15
+ };
16
+
17
+ class MockNPCPlugin {
18
+ readonly name = "npc";
19
+ readonly required = [] as const;
20
+ private readonly api = {
21
+ getInfo: async () => npcState.info,
22
+ setUsername: async (username: string, attemptPayment?: boolean) => {
23
+ npcState.setUsernameCalls.push({ username, attemptPayment });
24
+ return npcState.usernameResult;
25
+ },
26
+ sync: async () => {
27
+ npcState.syncCalls += 1;
28
+ },
29
+ };
30
+
31
+ onInit(ctx: { registerExtension: (key: string, api: unknown) => void }) {
32
+ ctx.registerExtension("npc", this.api);
33
+ return () => {};
34
+ }
35
+ }
36
+
37
+ mock.module("coco-cashu-plugin-npc", () => ({
38
+ NPCPlugin: MockNPCPlugin,
39
+ }));
40
+
41
+ const { createCocoClient } = await import("./coco-client");
42
+ const { createCocodClient, CocodHttpError } = await import("./cocod-client");
43
+
44
+ const TEST_MNEMONIC =
45
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
46
+
47
+ const tempDirs: string[] = [];
48
+
49
+ function makeWalletDir(): string {
50
+ const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-npc-test-"));
51
+ tempDirs.push(dir);
52
+ const walletDir = join(dir, "wallet");
53
+ mkdirSync(walletDir, { recursive: true });
54
+ writeFileSync(
55
+ join(walletDir, "config.json"),
56
+ JSON.stringify({ version: 1, mnemonic: TEST_MNEMONIC, encrypted: false }),
57
+ );
58
+ return walletDir;
59
+ }
60
+
61
+ function testClientOptions(walletDir = makeWalletDir()) {
62
+ return {
63
+ walletDir,
64
+ legacySocketPath: join(walletDir, "legacy-cocod.sock"),
65
+ legacyPidPath: join(walletDir, "legacy-cocod.pid"),
66
+ };
67
+ }
68
+
69
+ function resetNpcState(): void {
70
+ npcState.info = { name: "alice", pubkey: "ab".repeat(32) };
71
+ npcState.usernameResult = { success: true };
72
+ npcState.setUsernameCalls = [];
73
+ npcState.syncCalls = 0;
74
+ }
75
+
76
+ afterEach(() => {
77
+ resetNpcState();
78
+ for (const dir of tempDirs.splice(0)) {
79
+ rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ describe("in-process coco client NPC integration", () => {
84
+ it("registers the plugin and exposes the wallet's NPC lightning address", async () => {
85
+ const client = await createCocoClient(testClientOptions());
86
+ try {
87
+ const info = await client.getNpcAddress();
88
+ expect(info).toEqual({
89
+ address: "alice@npubx.cash",
90
+ name: "alice",
91
+ pubkey: "ab".repeat(32),
92
+ });
93
+ } finally {
94
+ await client.dispose?.();
95
+ }
96
+ });
97
+
98
+ it("falls back to the npub address when no username is set", async () => {
99
+ npcState.info = { name: null, pubkey: "ab".repeat(32) };
100
+ const client = await createCocoClient(testClientOptions());
101
+ try {
102
+ const info = await client.getNpcAddress();
103
+ expect(info.address).toStartWith("npub1");
104
+ expect(info.address).toEndWith("@npubx.cash");
105
+ expect(info.name).toBeUndefined();
106
+ expect(info.pubkey).toBe("ab".repeat(32));
107
+ } finally {
108
+ await client.dispose?.();
109
+ }
110
+ });
111
+
112
+ it("maps payment-required username claims without paying", async () => {
113
+ npcState.usernameResult = {
114
+ success: false,
115
+ pr: { amount: 21, mints: ["https://mint.example.com"] },
116
+ };
117
+ const client = await createCocoClient(testClientOptions());
118
+ try {
119
+ const result = await client.setNpcUsername("bob");
120
+ expect(result).toEqual({
121
+ success: false,
122
+ paymentRequest: { amount: 21, mints: ["https://mint.example.com"] },
123
+ });
124
+ expect(npcState.setUsernameCalls).toEqual([
125
+ { username: "bob", attemptPayment: false },
126
+ ]);
127
+ } finally {
128
+ await client.dispose?.();
129
+ }
130
+ });
131
+
132
+ it("confirms the claim fee payment when requested", async () => {
133
+ const client = await createCocoClient(testClientOptions());
134
+ try {
135
+ const result = await client.setNpcUsername("bob", true);
136
+ expect(result).toEqual({ success: true });
137
+ expect(npcState.setUsernameCalls).toEqual([
138
+ { username: "bob", attemptPayment: true },
139
+ ]);
140
+ } finally {
141
+ await client.dispose?.();
142
+ }
143
+ });
144
+
145
+ it("triggers a manual quote sync", async () => {
146
+ const client = await createCocoClient(testClientOptions());
147
+ try {
148
+ await client.syncNpc();
149
+ expect(npcState.syncCalls).toBe(1);
150
+ } finally {
151
+ await client.dispose?.();
152
+ }
153
+ });
154
+
155
+ it("rejects NPC calls with a clear error when the plugin is disabled", async () => {
156
+ const client = await createCocoClient({
157
+ ...testClientOptions(),
158
+ enableNpc: false,
159
+ });
160
+ try {
161
+ await expect(client.getNpcAddress()).rejects.toThrow(
162
+ "NPC plugin is not enabled",
163
+ );
164
+ await expect(client.syncNpc()).rejects.toThrow(
165
+ "NPC plugin is not enabled",
166
+ );
167
+ } finally {
168
+ await client.dispose?.();
169
+ }
170
+ });
171
+ });
172
+
173
+ describe("legacy cocod client NPC passthroughs", () => {
174
+ type FetchImpl = (
175
+ input: string | URL | Request,
176
+ init?: RequestInit & { unix: string },
177
+ ) => Promise<Response>;
178
+
179
+ function legacyClient(handler: (path: string, body?: unknown) => Response) {
180
+ const requests: Array<{ path: string; body?: unknown }> = [];
181
+ const fetchImpl: FetchImpl = async (input, init) => {
182
+ const path = String(input).replace("http://localhost", "");
183
+ const body =
184
+ typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
185
+ if (path !== "/ping") requests.push({ path, body });
186
+ if (path === "/ping") {
187
+ return new Response(JSON.stringify({ output: "pong" }));
188
+ }
189
+ return handler(path, body);
190
+ };
191
+ const client = createCocodClient({
192
+ socketPath: "/tmp/routstrd-test-legacy-npc/cocod.sock",
193
+ fetchImpl,
194
+ });
195
+ return { client, requests };
196
+ }
197
+
198
+ it("fetches and parses a username NPC address", async () => {
199
+ const { client, requests } = legacyClient(
200
+ () => new Response(JSON.stringify({ output: "alice@npubx.cash" })),
201
+ );
202
+ const info = await client.getNpcAddress();
203
+ expect(info).toEqual({ address: "alice@npubx.cash", name: "alice" });
204
+ expect(requests[0]?.path).toBe("/npc/address");
205
+ });
206
+
207
+ it("omits the name for npub fallback addresses", async () => {
208
+ const { client } = legacyClient(
209
+ () =>
210
+ new Response(JSON.stringify({ output: "npub1abc123@npubx.cash" })),
211
+ );
212
+ const info = await client.getNpcAddress();
213
+ expect(info).toEqual({ address: "npub1abc123@npubx.cash" });
214
+ });
215
+
216
+ it("posts username claims with the confirm flag", async () => {
217
+ const { client, requests } = legacyClient(
218
+ () => new Response(JSON.stringify({ output: { success: true } })),
219
+ );
220
+ const result = await client.setNpcUsername("bob", true);
221
+ expect(result).toEqual({ success: true });
222
+ expect(requests[0]).toEqual({
223
+ path: "/npc/username",
224
+ body: { username: "bob", confirm: true },
225
+ });
226
+ });
227
+
228
+ it("preserves the 402 payment-required error from cocod", async () => {
229
+ const { client } = legacyClient(
230
+ () =>
231
+ new Response(
232
+ JSON.stringify({ error: "Payment required to set username" }),
233
+ { status: 402 },
234
+ ),
235
+ );
236
+ try {
237
+ await client.setNpcUsername("bob");
238
+ expect.unreachable("should have thrown");
239
+ } catch (error) {
240
+ expect(error).toBeInstanceOf(CocodHttpError);
241
+ expect((error as InstanceType<typeof CocodHttpError>).status).toBe(402);
242
+ expect((error as Error).message).toContain("Payment required");
243
+ }
244
+ });
245
+
246
+ it("reports manual sync as unsupported", async () => {
247
+ const { client } = legacyClient(
248
+ () => new Response(JSON.stringify({ output: {} })),
249
+ );
250
+ await expect(client.syncNpc()).rejects.toThrow("not supported");
251
+ });
252
+ });