routstrd 0.4.5 → 0.4.7

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.
@@ -0,0 +1,408 @@
1
+ import { createServer } from "http";
2
+ import { existsSync, unlinkSync } from "fs";
3
+ import {
4
+ ProviderManager,
5
+ createStorageAdapterFromStore,
6
+ createSdkStore,
7
+ } from "@routstr/sdk";
8
+ // ModelManager must come from the bun entrypoint so persistent Nostr event
9
+ // storage (eventStoreDbPath) gets its SQLite-backed factory. The default
10
+ // "@routstr/sdk" export is browser-safe and throws without that factory
11
+ // (SDK 0.3.7+ browser-safe entrypoint split).
12
+ import { ModelManager } from "@routstr/sdk/bun";
13
+ import type { SdkLogger } from "@routstr/sdk";
14
+ import {
15
+ CONFIG_DIR,
16
+ DB_PATH,
17
+ SOCKET_PATH,
18
+ PID_FILE,
19
+ REQUEST_RESPONSE_LOGS_DIR,
20
+ } from "../utils/config";
21
+ import { logger } from "../utils/logger";
22
+
23
+
24
+ function makeSdkLogger(prefix?: string): SdkLogger {
25
+ const tag = prefix ? `[${prefix}]` : undefined;
26
+ const fmt = (...args: unknown[]) => (tag ? [tag, ...args] : args);
27
+ return {
28
+ log: (...args: unknown[]) => logger.log(...fmt(...args)),
29
+ warn: (...args: unknown[]) => logger.warn(...fmt(...args)),
30
+ error: (...args: unknown[]) => logger.error(...fmt(...args)),
31
+ debug: (...args: unknown[]) => logger.debug(...fmt(...args)),
32
+ child: (p: string) => makeSdkLogger(prefix ? `${prefix}:${p}` : p),
33
+ };
34
+ }
35
+ const daemonSdkLogger: SdkLogger = makeSdkLogger();
36
+ const STARTUP_LOG_PREFIX = "[routstrd:start]";
37
+
38
+ function startupProgress(message: string): void {
39
+ logger.info(message);
40
+ console.log(`${STARTUP_LOG_PREFIX} ${message}`);
41
+ }
42
+
43
+ import { parseArgs } from "./args";
44
+ import { ensureDirs, loadDaemonConfig, loadDaemonConfigSync, saveDaemonConfig } from "./config-store";
45
+ import {
46
+ createBunSqliteDriver,
47
+ createBunSqliteUsageTrackingDriver,
48
+ createShardedDiscoveryAdapter,
49
+ } from "@routstr/sdk/storage/bun";
50
+ import { createWalletAdapter } from "./wallet";
51
+ import type { AutoRefillConfig } from "./wallet/auto-refill";
52
+ import { createModelService } from "./models";
53
+ import { createDaemonRequestHandler } from "./http";
54
+ import { FileRequestResponseLogSink } from "./request-response-log-sink";
55
+ import { refreshModelsAndIntegrations } from "../integrations";
56
+ import { RoutstrClient } from "@routstr/sdk";
57
+ import { mkdirSync } from "fs";
58
+ import { dirname } from "path";
59
+ import {
60
+ assertLegacyCocodNotRunning,
61
+ claimLegacyCocodPidFile,
62
+ createCocoClient,
63
+ stopLegacyCocod,
64
+ } from "./wallet/coco-client";
65
+ import { migrateLegacyWallet } from "./wallet/migration";
66
+ import {
67
+ legacyCocodPidPath,
68
+ legacyCocodSocketPath,
69
+ } from "./wallet/paths";
70
+ import { installGlobalErrorHandlers } from "./fatal-error";
71
+
72
+ // Global error handlers — the daemon is spawned detached with stdout/stderr
73
+ // redirected to a file, so without these, uncaught async errors would kill
74
+ // the process silently. Uncaught exceptions are fatal: the process state can
75
+ // no longer be trusted, so the daemon logs and exits for its supervisor to
76
+ // restart (see fatal-error.ts).
77
+ installGlobalErrorHandlers();
78
+
79
+ async function main(): Promise<void> {
80
+ // Install signal handlers before migration and wallet startup. If a signal
81
+ // arrives before the full shutdown path is wired, process.exit() still runs
82
+ // the synchronous PID-lock exit hooks registered by claimPidFile.
83
+ let shutdownDaemon: () => void = () => process.exit(0);
84
+ const shutdownForSignal = (signal: NodeJS.Signals) => {
85
+ logger.log(`Received ${signal}; shutting down...`);
86
+ shutdownDaemon();
87
+ };
88
+ process.once("SIGINT", shutdownForSignal);
89
+ process.once("SIGTERM", shutdownForSignal);
90
+
91
+ startupProgress("Loading configuration...");
92
+ const args = parseArgs(process.argv);
93
+ const config = await loadDaemonConfig();
94
+
95
+ const port = args.port;
96
+ const host = args.host || config.host || "127.0.0.1";
97
+ const provider = args.provider || config.provider;
98
+ const requestResponseLogDir =
99
+ process.env.ROUTSTRD_REQUEST_RESPONSE_LOG_DIR ||
100
+ (config.requestResponseLogging?.enabled
101
+ ? config.requestResponseLogging.dir || REQUEST_RESPONSE_LOGS_DIR
102
+ : undefined);
103
+ const requestResponseLogSink = requestResponseLogDir
104
+ ? new FileRequestResponseLogSink({
105
+ dir: requestResponseLogDir,
106
+ logger: daemonSdkLogger.child("request-response-log"),
107
+ })
108
+ : undefined;
109
+
110
+ await ensureDirs();
111
+
112
+ const updatedConfig = { ...config, port, host, provider };
113
+ saveDaemonConfig(updatedConfig);
114
+
115
+ startupProgress("Opening Routstr databases...");
116
+ const sqliteDriver = await createBunSqliteDriver(DB_PATH, { logger: daemonSdkLogger });
117
+ const { store, hydrate } = createSdkStore({ driver: sqliteDriver });
118
+ await hydrate;
119
+ const usageTrackingDriver = await createBunSqliteUsageTrackingDriver({
120
+ dbPath: DB_PATH,
121
+ legacyStorageDriver: sqliteDriver,
122
+ });
123
+
124
+ const discoveryAdapter = await createShardedDiscoveryAdapter({ driver: sqliteDriver });
125
+ const storageAdapter = createStorageAdapterFromStore(store);
126
+ startupProgress("Routstr databases ready.");
127
+ const modelManager = new ModelManager(discoveryAdapter, {
128
+ logger: daemonSdkLogger,
129
+ eventStoreDbPath: `${CONFIG_DIR}/events.db`,
130
+ routstrPubkey: config.routstrPubkey,
131
+ routstrModelsPubkey: config.routstrModelsPubkey,
132
+ nostrRelays: config.relays,
133
+ });
134
+ // Create shared ProviderManager for consistent failure tracking across all requests
135
+ const providerManager = new ProviderManager(discoveryAdapter, store, daemonSdkLogger);
136
+ const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } =
137
+ createModelService(modelManager, providerManager, store);
138
+
139
+ // The daemon may be launched directly (or by an older/global CLI), so do
140
+ // not rely on the parent command having stopped the external wallet first.
141
+ await stopLegacyCocod({
142
+ socketPath: legacyCocodSocketPath(),
143
+ pidFilePath: legacyCocodPidPath(),
144
+ });
145
+
146
+ const migration = await migrateLegacyWallet({
147
+ assertLegacyStopped: () =>
148
+ assertLegacyCocodNotRunning({
149
+ socketPath: legacyCocodSocketPath(),
150
+ }),
151
+ acquireLegacyLock: () => {
152
+ mkdirSync(dirname(legacyCocodPidPath()), {
153
+ recursive: true,
154
+ mode: 0o700,
155
+ });
156
+ return claimLegacyCocodPidFile({
157
+ pidFilePath: legacyCocodPidPath(),
158
+ });
159
+ },
160
+ });
161
+ if (migration.status === "migrated") {
162
+ startupProgress(`Wallet migrated from ${migration.from} to ${migration.to}.`);
163
+ for (const warning of migration.cleanupWarnings) logger.warn(warning);
164
+ }
165
+
166
+ const walletClient = await createCocoClient();
167
+
168
+ // ── Auto-refill configuration ────────────────────────────────
169
+ // Uses a getter that reads config from disk each cycle, so
170
+ // CLI changes take effect immediately without a daemon restart.
171
+
172
+ const getAutoRefillConfig = (): AutoRefillConfig | undefined => {
173
+ const cfg = loadDaemonConfigSync();
174
+ if (cfg.nwc?.autoRefill?.enabled && cfg.nwc?.connectionString) {
175
+ return {
176
+ threshold: cfg.nwc.autoRefill.threshold,
177
+ amount: cfg.nwc.autoRefill.amount,
178
+ cooldownMs: cfg.nwc.autoRefill.cooldownMs,
179
+ };
180
+ }
181
+ return undefined;
182
+ };
183
+
184
+ const walletAdapter = await createWalletAdapter({
185
+ cocodPath: config.cocodPath,
186
+ walletClient,
187
+ getAutoRefillConfig,
188
+ nwcConnectionString: config.nwc?.connectionString,
189
+ });
190
+
191
+ const refundClient = new RoutstrClient(
192
+ walletAdapter,
193
+ storageAdapter,
194
+ discoveryAdapter,
195
+ "min",
196
+ "apikeys",
197
+ { logger: daemonSdkLogger },
198
+ );
199
+
200
+ const server = createServer();
201
+ server.on(
202
+ "request",
203
+ createDaemonRequestHandler({
204
+ provider,
205
+ server,
206
+ shutdown: () => shutdownDaemon(),
207
+ store,
208
+ walletClient,
209
+ walletAdapter,
210
+ storageAdapter,
211
+ discoveryAdapter,
212
+ modelManager,
213
+ ensureProvidersBootstrapped,
214
+ getRoutstr21Models,
215
+ getModelProviders,
216
+ refreshProvidersAndModels,
217
+ mode: config.mode || "apikeys",
218
+ maxTokens: config.maxTokens ?? 64000,
219
+ routstrPubkey: config.routstrPubkey,
220
+ routstrModelsPubkey: config.routstrModelsPubkey,
221
+ usageTrackingDriver,
222
+ providerManager,
223
+ refundClient,
224
+ requestResponseLogSink,
225
+ }),
226
+ );
227
+
228
+ Bun.write(PID_FILE, String(process.pid));
229
+
230
+ try {
231
+ if (existsSync(SOCKET_PATH)) {
232
+ unlinkSync(SOCKET_PATH);
233
+ }
234
+ } catch {
235
+ // Ignore
236
+ }
237
+
238
+ const REFRESH_INTERVAL_MS = 21 * 60 * 1000; // 21 mins
239
+
240
+ // Recurring job to refresh routstr21 models
241
+ let refreshInterval: ReturnType<typeof setInterval> | null = null;
242
+
243
+ const startModelRefreshJob = () => {
244
+ logger.log(
245
+ `Starting recurring model refresh job (every ${REFRESH_INTERVAL_MS / 1000 / 60 / 60} hours)`,
246
+ );
247
+
248
+ refreshInterval = setInterval(() => {
249
+ (async () => {
250
+ logger.log("Running scheduled Nostr event refresh...");
251
+ try {
252
+ await modelManager.refreshNostrEvents();
253
+ } catch (error) {
254
+ logger.error("Scheduled Nostr event refresh failed:", error);
255
+ }
256
+
257
+ logger.log("Running scheduled model refresh...");
258
+ try {
259
+ await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled");
260
+ } catch (error) {
261
+ logger.error("Scheduled model refresh failed:", error);
262
+ }
263
+ })().catch((error) => logger.error("Model refresh interval escaped:", error));
264
+ }, REFRESH_INTERVAL_MS);
265
+ };
266
+
267
+ const stopModelRefreshJob = () => {
268
+ if (refreshInterval) {
269
+ clearInterval(refreshInterval);
270
+ refreshInterval = null;
271
+ logger.log("Stopped recurring model refresh job.");
272
+ }
273
+ };
274
+
275
+ // Recurring job to refund pending tokens every 42 minutes
276
+ const REFUND_INTERVAL_MS = 42 * 60 * 1000; // 42 minutes
277
+ let refundInterval: ReturnType<typeof setInterval> | null = null;
278
+
279
+ const startRefundJob = async () => {
280
+ logger.log(
281
+ `Starting recurring refund job (every ${REFUND_INTERVAL_MS / 1000 / 60} minutes)`,
282
+ );
283
+
284
+ refundInterval = setInterval(() => {
285
+ (async () => {
286
+ logger.log("Running scheduled refund...");
287
+ try {
288
+ const state = store.getState() as any;
289
+ const pendingDistribution = (state.cachedTokens || []).map(
290
+ (t: { baseUrl: string; balance?: number }) => ({
291
+ baseUrl: t.baseUrl,
292
+ amount: t.balance || 0,
293
+ }),
294
+ );
295
+ const apiKeysStored = (state.apiKeys || []).map(
296
+ (k: { baseUrl: string; balance?: number }) => ({
297
+ baseUrl: k.baseUrl,
298
+ amount: k.balance || 0,
299
+ }),
300
+ );
301
+
302
+ if (pendingDistribution.length === 0 && apiKeysStored.length === 0) {
303
+ logger.log("No pending tokens to refund.");
304
+ return;
305
+ }
306
+
307
+ const mintUrl = walletAdapter.getActiveMintUrl();
308
+ if (!mintUrl) {
309
+ logger.log("No active mint URL for refund.");
310
+ return;
311
+ }
312
+
313
+ const spender = refundClient.getCashuSpender();
314
+ const results = await spender.refundProviders(mintUrl);
315
+
316
+ const successCount = results.filter(
317
+ (r: { success: boolean }) => r.success,
318
+ ).length;
319
+ logger.log(
320
+ `Scheduled refund completed: ${successCount}/${results.length} providers refunded.`,
321
+ );
322
+ } catch (error) {
323
+ logger.error("Scheduled refund failed:", error);
324
+ }
325
+ })().catch((error) => logger.error("Refund interval escaped:", error));
326
+ }, REFUND_INTERVAL_MS);
327
+ };
328
+
329
+ const stopRefundJob = () => {
330
+ if (refundInterval) {
331
+ clearInterval(refundInterval);
332
+ refundInterval = null;
333
+ logger.log("Stopped recurring refund job.");
334
+ }
335
+ };
336
+
337
+ let walletDisposePromise: Promise<void> | undefined;
338
+ const disposeWallet = (): Promise<void> => {
339
+ walletDisposePromise ??= walletClient.dispose?.() || Promise.resolve();
340
+ return walletDisposePromise;
341
+ };
342
+
343
+ server.on("close", () => {
344
+ stopModelRefreshJob();
345
+ stopRefundJob();
346
+ void disposeWallet().catch((error) => {
347
+ logger.error("Failed to dispose wallet:", error);
348
+ });
349
+ });
350
+
351
+ shutdownDaemon = () => {
352
+ server.close(() => {
353
+ void disposeWallet().finally(() => process.exit(0));
354
+ });
355
+ };
356
+
357
+ // Without this a listen failure only reaches uncaughtException, which logs and
358
+ // returns, leaving the daemon alive holding the wallet locks with no listener.
359
+ server.on("error", (error) => {
360
+ logger.error("Failed to start Routstr daemon:", error);
361
+ console.error(
362
+ "Failed to start Routstr daemon:",
363
+ error instanceof Error ? error.message : error,
364
+ );
365
+ process.exit(1);
366
+ });
367
+
368
+ startupProgress("Starting HTTP server...");
369
+ server.listen(port, host, async () => {
370
+ logger.log(`Routstr daemon listening on http://${host}:${port}/v1`);
371
+ if (requestResponseLogDir) {
372
+ logger.log(`Raw request/response logs: ${requestResponseLogDir}`);
373
+ }
374
+
375
+ // Start the recurring model refresh job after initial bootstrap
376
+ void ensureProvidersBootstrapped()
377
+ .then(async () => {
378
+ // Catch up on any Nostr events published since last run
379
+ logger.log("Running initial Nostr event refresh...");
380
+ await modelManager.refreshNostrEvents();
381
+
382
+ startModelRefreshJob();
383
+ startRefundJob();
384
+ // Run an immediate refresh to populate models right away
385
+ logger.log("Running initial model refresh...");
386
+ await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Initial");
387
+ })
388
+ .catch((error) => {
389
+ logger.error("Initial model refresh failed:", error);
390
+ // Still start the jobs even if initial refresh fails
391
+ startModelRefreshJob();
392
+ startRefundJob();
393
+ });
394
+ });
395
+ }
396
+
397
+ if (import.meta.main) {
398
+ main().catch((error) => {
399
+ logger.error("Failed to start Routstr daemon:", error);
400
+ // Also write to stderr so the spawning CLI can surface the real error
401
+ // (stdout/stderr are redirected to debug.log by start-daemon.ts).
402
+ console.error(
403
+ "Failed to start Routstr daemon:",
404
+ error instanceof Error ? error.message : error,
405
+ );
406
+ process.exit(1);
407
+ });
408
+ }