mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-06 04:38:23 +00:00
Problem 1 — config overridden on restart:
parseArgs() always returned port=8008 when --port was not passed on the
CLI. index.ts then spread { ...config, port, provider } and saved,
silently overwriting the user's persisted port with 8008 on every daemon
restart.
Fix: parseArgs now returns null when --port is absent. index.ts falls
back to config.port (then 8008 as last resort), so the persisted config
is preserved across restarts.
Problem 2 — fetching models from disabled providers:
The SDK's fetchModels() still makes HTTP requests to every provider in
the list, even disabled ones — it only skips adding their models to the
best-priced map. models.ts passed unfiltered provider lists to
fetchModels in three places (bootstrap, getRoutstr21Models, refresh).
Fix: Added filterDisabled() helper that reads disabledProviders from the
store and strips them before fetchModels is called, preventing wasteful
HTTP requests to providers the user has explicitly disabled.
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
import { createServer } from "http";
|
|
import { existsSync } from "fs";
|
|
import {
|
|
ProviderManager,
|
|
createStorageAdapterFromStore,
|
|
createSdkStore,
|
|
} from "@routstr/sdk";
|
|
// ModelManager must come from the bun entrypoint so persistent Nostr event
|
|
// storage (eventStoreDbPath) gets its SQLite-backed factory. The default
|
|
// "@routstr/sdk" export is browser-safe and throws without that factory
|
|
// (SDK 0.3.7+ browser-safe entrypoint split).
|
|
import { ModelManager } from "@routstr/sdk/bun";
|
|
import type { SdkLogger } from "@routstr/sdk";
|
|
import {
|
|
CONFIG_DIR,
|
|
DB_PATH,
|
|
SOCKET_PATH,
|
|
PID_FILE,
|
|
REQUEST_RESPONSE_LOGS_DIR,
|
|
} from "../utils/config";
|
|
import { logger } from "../utils/logger";
|
|
|
|
|
|
function makeSdkLogger(prefix?: string): SdkLogger {
|
|
const tag = prefix ? `[${prefix}]` : undefined;
|
|
const fmt = (...args: unknown[]) => (tag ? [tag, ...args] : args);
|
|
return {
|
|
log: (...args: unknown[]) => logger.log(...fmt(...args)),
|
|
warn: (...args: unknown[]) => logger.warn(...fmt(...args)),
|
|
error: (...args: unknown[]) => logger.error(...fmt(...args)),
|
|
debug: (...args: unknown[]) => logger.debug(...fmt(...args)),
|
|
child: (p: string) => makeSdkLogger(prefix ? `${prefix}:${p}` : p),
|
|
};
|
|
}
|
|
const daemonSdkLogger: SdkLogger = makeSdkLogger();
|
|
import { parseArgs } from "./args";
|
|
import { ensureDirs, loadDaemonConfig, loadDaemonConfigSync, saveDaemonConfig } from "./config-store";
|
|
import {
|
|
createBunSqliteDriver,
|
|
createBunSqliteUsageTrackingDriver,
|
|
createShardedDiscoveryAdapter,
|
|
} from "@routstr/sdk/storage/bun";
|
|
import { createWalletAdapter } from "./wallet";
|
|
import type { AutoRefillConfig } from "./wallet/auto-refill";
|
|
import { createCocodClient } from "./wallet/cocod-client";
|
|
import { createModelService } from "./models";
|
|
import { createDaemonRequestHandler } from "./http";
|
|
import { FileRequestResponseLogSink } from "./request-response-log-sink";
|
|
import { refreshModelsAndIntegrations } from "../integrations";
|
|
import { RoutstrClient } from "@routstr/sdk";
|
|
|
|
// Global error handlers — the daemon is spawned detached with stdout/stderr
|
|
// redirected to a file, so without these, uncaught async errors would kill
|
|
// the process silently. Log to the file logger before exiting.
|
|
process.on("uncaughtException", (error) => {
|
|
logger.error("UNCAUGHT EXCEPTION:", error);
|
|
});
|
|
|
|
process.on("unhandledRejection", (reason) => {
|
|
logger.error("UNHANDLED REJECTION:", reason);
|
|
});
|
|
|
|
async function main(): Promise<void> {
|
|
const args = parseArgs(process.argv);
|
|
const config = await loadDaemonConfig();
|
|
|
|
const port = args.port ?? config.port ?? 8008;
|
|
const provider = args.provider || config.provider;
|
|
const requestResponseLogDir =
|
|
process.env.ROUTSTRD_REQUEST_RESPONSE_LOG_DIR ||
|
|
(config.requestResponseLogging?.enabled
|
|
? config.requestResponseLogging.dir || REQUEST_RESPONSE_LOGS_DIR
|
|
: undefined);
|
|
const requestResponseLogSink = requestResponseLogDir
|
|
? new FileRequestResponseLogSink({
|
|
dir: requestResponseLogDir,
|
|
logger: daemonSdkLogger.child("request-response-log"),
|
|
})
|
|
: undefined;
|
|
|
|
await ensureDirs();
|
|
|
|
const updatedConfig = { ...config, port, provider };
|
|
saveDaemonConfig(updatedConfig);
|
|
|
|
const sqliteDriver = await createBunSqliteDriver(DB_PATH, { logger: daemonSdkLogger });
|
|
const { store, hydrate } = createSdkStore({ driver: sqliteDriver });
|
|
await hydrate;
|
|
const usageTrackingDriver = await createBunSqliteUsageTrackingDriver({
|
|
dbPath: DB_PATH,
|
|
legacyStorageDriver: sqliteDriver,
|
|
});
|
|
|
|
const discoveryAdapter = await createShardedDiscoveryAdapter({ driver: sqliteDriver });
|
|
const storageAdapter = createStorageAdapterFromStore(store);
|
|
const modelManager = new ModelManager(discoveryAdapter, {
|
|
logger: daemonSdkLogger,
|
|
eventStoreDbPath: `${CONFIG_DIR}/events.db`,
|
|
routstrPubkey: config.routstrPubkey,
|
|
nostrRelays: config.relays,
|
|
});
|
|
// Create shared ProviderManager for consistent failure tracking across all requests
|
|
const providerManager = new ProviderManager(discoveryAdapter, store, daemonSdkLogger);
|
|
const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } =
|
|
createModelService(modelManager, store);
|
|
|
|
const walletClient = createCocodClient({ cocodPath: config.cocodPath });
|
|
|
|
// ── Auto-refill configuration ────────────────────────────────
|
|
// Uses a getter that reads config from disk each cycle, so
|
|
// CLI changes take effect immediately without a daemon restart.
|
|
|
|
const getAutoRefillConfig = (): AutoRefillConfig | undefined => {
|
|
const cfg = loadDaemonConfigSync();
|
|
if (cfg.nwc?.autoRefill?.enabled && cfg.nwc?.connectionString) {
|
|
return {
|
|
threshold: cfg.nwc.autoRefill.threshold,
|
|
amount: cfg.nwc.autoRefill.amount,
|
|
cooldownMs: cfg.nwc.autoRefill.cooldownMs,
|
|
};
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const walletAdapter = await createWalletAdapter({
|
|
cocodPath: config.cocodPath,
|
|
walletClient,
|
|
getAutoRefillConfig,
|
|
nwcConnectionString: config.nwc?.connectionString,
|
|
});
|
|
|
|
const refundClient = new RoutstrClient(
|
|
walletAdapter,
|
|
storageAdapter,
|
|
discoveryAdapter,
|
|
"min",
|
|
"apikeys",
|
|
{ logger: daemonSdkLogger },
|
|
);
|
|
|
|
const server = createServer();
|
|
server.on(
|
|
"request",
|
|
createDaemonRequestHandler({
|
|
provider,
|
|
server,
|
|
store,
|
|
walletClient,
|
|
walletAdapter,
|
|
storageAdapter,
|
|
discoveryAdapter,
|
|
modelManager,
|
|
ensureProvidersBootstrapped,
|
|
getRoutstr21Models,
|
|
getModelProviders,
|
|
refreshProvidersAndModels,
|
|
mode: config.mode || "apikeys",
|
|
routstrPubkey: config.routstrPubkey,
|
|
usageTrackingDriver,
|
|
providerManager,
|
|
refundClient,
|
|
requestResponseLogSink,
|
|
}),
|
|
);
|
|
|
|
Bun.write(PID_FILE, String(process.pid));
|
|
|
|
try {
|
|
if (existsSync(SOCKET_PATH)) {
|
|
Bun.spawn(["rm", SOCKET_PATH]);
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
|
|
const REFRESH_INTERVAL_MS = 21 * 60 * 1000; // 21 mins
|
|
|
|
// Recurring job to refresh routstr21 models
|
|
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const startModelRefreshJob = () => {
|
|
logger.log(
|
|
`Starting recurring model refresh job (every ${REFRESH_INTERVAL_MS / 1000 / 60 / 60} hours)`,
|
|
);
|
|
|
|
refreshInterval = setInterval(() => {
|
|
(async () => {
|
|
logger.log("Running scheduled Nostr event refresh...");
|
|
try {
|
|
await modelManager.refreshNostrEvents();
|
|
} catch (error) {
|
|
logger.error("Scheduled Nostr event refresh failed:", error);
|
|
}
|
|
|
|
logger.log("Running scheduled model refresh...");
|
|
try {
|
|
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled");
|
|
} catch (error) {
|
|
logger.error("Scheduled model refresh failed:", error);
|
|
}
|
|
})().catch((error) => logger.error("Model refresh interval escaped:", error));
|
|
}, REFRESH_INTERVAL_MS);
|
|
};
|
|
|
|
const stopModelRefreshJob = () => {
|
|
if (refreshInterval) {
|
|
clearInterval(refreshInterval);
|
|
refreshInterval = null;
|
|
logger.log("Stopped recurring model refresh job.");
|
|
}
|
|
};
|
|
|
|
// Recurring job to refund pending tokens every 42 minutes
|
|
const REFUND_INTERVAL_MS = 42 * 60 * 1000; // 42 minutes
|
|
let refundInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const startRefundJob = async () => {
|
|
logger.log(
|
|
`Starting recurring refund job (every ${REFUND_INTERVAL_MS / 1000 / 60} minutes)`,
|
|
);
|
|
|
|
refundInterval = setInterval(() => {
|
|
(async () => {
|
|
logger.log("Running scheduled refund...");
|
|
try {
|
|
const state = store.getState() as any;
|
|
const pendingDistribution = (state.cachedTokens || []).map(
|
|
(t: { baseUrl: string; balance?: number }) => ({
|
|
baseUrl: t.baseUrl,
|
|
amount: t.balance || 0,
|
|
}),
|
|
);
|
|
const apiKeysStored = (state.apiKeys || []).map(
|
|
(k: { baseUrl: string; balance?: number }) => ({
|
|
baseUrl: k.baseUrl,
|
|
amount: k.balance || 0,
|
|
}),
|
|
);
|
|
|
|
if (pendingDistribution.length === 0 && apiKeysStored.length === 0) {
|
|
logger.log("No pending tokens to refund.");
|
|
return;
|
|
}
|
|
|
|
const mintUrl = walletAdapter.getActiveMintUrl();
|
|
if (!mintUrl) {
|
|
logger.log("No active mint URL for refund.");
|
|
return;
|
|
}
|
|
|
|
const spender = refundClient.getCashuSpender();
|
|
const results = await spender.refundProviders(mintUrl);
|
|
|
|
const successCount = results.filter(
|
|
(r: { success: boolean }) => r.success,
|
|
).length;
|
|
logger.log(
|
|
`Scheduled refund completed: ${successCount}/${results.length} providers refunded.`,
|
|
);
|
|
} catch (error) {
|
|
logger.error("Scheduled refund failed:", error);
|
|
}
|
|
})().catch((error) => logger.error("Refund interval escaped:", error));
|
|
}, REFUND_INTERVAL_MS);
|
|
};
|
|
|
|
const stopRefundJob = () => {
|
|
if (refundInterval) {
|
|
clearInterval(refundInterval);
|
|
refundInterval = null;
|
|
logger.log("Stopped recurring refund job.");
|
|
}
|
|
};
|
|
|
|
server.on("close", () => {
|
|
stopModelRefreshJob();
|
|
stopRefundJob();
|
|
|
|
});
|
|
|
|
server.listen(port, async () => {
|
|
logger.log(`Routstr daemon listening on http://localhost:${port}/v1`);
|
|
if (requestResponseLogDir) {
|
|
logger.log(`Raw request/response logs: ${requestResponseLogDir}`);
|
|
}
|
|
|
|
// Start the recurring model refresh job after initial bootstrap
|
|
void ensureProvidersBootstrapped()
|
|
.then(async () => {
|
|
// Catch up on any Nostr events published since last run
|
|
logger.log("Running initial Nostr event refresh...");
|
|
await modelManager.refreshNostrEvents();
|
|
|
|
startModelRefreshJob();
|
|
startRefundJob();
|
|
// Run an immediate refresh to populate models right away
|
|
logger.log("Running initial model refresh...");
|
|
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Initial");
|
|
})
|
|
.catch((error) => {
|
|
logger.error("Initial model refresh failed:", error);
|
|
// Still start the jobs even if initial refresh fails
|
|
startModelRefreshJob();
|
|
startRefundJob();
|
|
});
|
|
});
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
main().catch((error) => {
|
|
logger.error("Failed to start Routstr daemon:", error);
|
|
process.exit(1);
|
|
});
|
|
}
|