routstrd 0.1.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.
@@ -0,0 +1,151 @@
1
+ import { createServer } from "http";
2
+ import { existsSync } from "fs";
3
+ import {
4
+ ModelManager,
5
+ createDiscoveryAdapterFromStore,
6
+ createProviderRegistryFromStore,
7
+ createStorageAdapterFromStore,
8
+ createSdkStore,
9
+ } from "@routstr/sdk";
10
+ import { DB_PATH, SOCKET_PATH, PID_FILE } from "../utils/config";
11
+ import { logger } from "../utils/logger";
12
+ import { parseArgs } from "./args";
13
+ import {
14
+ ensureDirs,
15
+ loadDaemonConfig,
16
+ saveDaemonConfig,
17
+ } from "./config-store";
18
+ import {
19
+ createBunSqliteDriver,
20
+ createBunSqliteUsageTrackingDriver,
21
+ } from "@routstr/sdk/storage";
22
+ import {
23
+ createWalletAdapter,
24
+ parseBalances,
25
+ runWalletCommand,
26
+ } from "./wallet";
27
+ import { createModelService } from "./models";
28
+ import { createDaemonRequestHandler } from "./http";
29
+
30
+ async function main(): Promise<void> {
31
+ const args = parseArgs(process.argv);
32
+ const config = await loadDaemonConfig();
33
+
34
+ const port = args.port;
35
+ const provider = args.provider || config.provider;
36
+
37
+ await ensureDirs();
38
+
39
+ const updatedConfig = { ...config, port, provider };
40
+ saveDaemonConfig(updatedConfig);
41
+
42
+ const sqliteDriver = await createBunSqliteDriver(DB_PATH);
43
+ const { store } = await createSdkStore({ driver: sqliteDriver });
44
+ const { Database } = await import("bun:sqlite");
45
+ const usageTrackingDriver = createBunSqliteUsageTrackingDriver({
46
+ dbPath: DB_PATH,
47
+ sqlite: { Database },
48
+ legacyStorageDriver: sqliteDriver,
49
+ });
50
+
51
+ const discoveryAdapter = createDiscoveryAdapterFromStore(store);
52
+ const providerRegistry = createProviderRegistryFromStore(store);
53
+ const storageAdapter = createStorageAdapterFromStore(store);
54
+ const modelManager = new ModelManager(discoveryAdapter);
55
+ const { ensureProvidersBootstrapped, getRoutstr21Models } =
56
+ createModelService(modelManager);
57
+
58
+ const walletAdapter = await createWalletAdapter();
59
+
60
+ const server = createServer();
61
+ server.on(
62
+ "request",
63
+ createDaemonRequestHandler({
64
+ provider,
65
+ server,
66
+ store,
67
+ walletAdapter,
68
+ storageAdapter,
69
+ providerRegistry,
70
+ discoveryAdapter,
71
+ modelManager,
72
+ ensureProvidersBootstrapped,
73
+ getRoutstr21Models,
74
+ runWalletCommand,
75
+ parseBalances,
76
+ mode: config.mode || "apikeys",
77
+ usageTrackingDriver,
78
+ }),
79
+ );
80
+
81
+ Bun.write(PID_FILE, String(process.pid));
82
+
83
+ try {
84
+ if (existsSync(SOCKET_PATH)) {
85
+ Bun.spawn(["rm", SOCKET_PATH]);
86
+ }
87
+ } catch {
88
+ // Ignore
89
+ }
90
+
91
+ const REFRESH_INTERVAL_MS = 3.5 * 60 * 60 * 1000; // 3.5 hours
92
+
93
+ // Recurring job to refresh routstr21 models
94
+ let refreshInterval: ReturnType<typeof setInterval> | null = null;
95
+
96
+ const startModelRefreshJob = () => {
97
+ logger.log(
98
+ `Starting recurring model refresh job (every ${REFRESH_INTERVAL_MS / 1000 / 60 / 60} hours)`,
99
+ );
100
+
101
+ refreshInterval = setInterval(async () => {
102
+ logger.log("Running scheduled model refresh...");
103
+ try {
104
+ await getRoutstr21Models(true);
105
+ logger.log("Scheduled model refresh completed successfully.");
106
+ } catch (error) {
107
+ logger.error("Scheduled model refresh failed:", error);
108
+ }
109
+ }, REFRESH_INTERVAL_MS);
110
+ };
111
+
112
+ const stopModelRefreshJob = () => {
113
+ if (refreshInterval) {
114
+ clearInterval(refreshInterval);
115
+ refreshInterval = null;
116
+ logger.log("Stopped recurring model refresh job.");
117
+ }
118
+ };
119
+
120
+ server.on("close", () => {
121
+ stopModelRefreshJob();
122
+ });
123
+
124
+ server.listen(port, async () => {
125
+ logger.log(`Routstr daemon listening on http://localhost:${port}`);
126
+
127
+ // Start the recurring model refresh job after initial bootstrap
128
+ void ensureProvidersBootstrapped()
129
+ .then(() => {
130
+ startModelRefreshJob();
131
+ // Run an immediate refresh to populate models right away
132
+ logger.log("Running initial model refresh...");
133
+ return getRoutstr21Models(true);
134
+ })
135
+ .then(() => {
136
+ logger.log("Initial model refresh completed.");
137
+ })
138
+ .catch((error) => {
139
+ logger.error("Initial model refresh failed:", error);
140
+ // Still start the job even if initial refresh fails
141
+ startModelRefreshJob();
142
+ });
143
+ });
144
+ }
145
+
146
+ if (import.meta.main) {
147
+ main().catch((error) => {
148
+ logger.error("Failed to start Routstr daemon:", error);
149
+ process.exit(1);
150
+ });
151
+ }
@@ -0,0 +1,49 @@
1
+ import { ModelManager } from "@routstr/sdk";
2
+ import type { ExposedModel } from "./types";
3
+ import { logger } from "../utils/logger";
4
+
5
+ export function createModelService(modelManager: ModelManager) {
6
+ let providerBootstrapPromise: Promise<void> | null = null;
7
+
8
+ const ensureProvidersBootstrapped = (): Promise<void> => {
9
+ if (!providerBootstrapPromise) {
10
+ providerBootstrapPromise = (async () => {
11
+ logger.log("Bootstrapping providers...");
12
+ const providers = await modelManager.bootstrapProviders(false);
13
+ logger.log(`Bootstrapped ${providers.length} providers`);
14
+ await modelManager.fetchModels(providers);
15
+ logger.log("Provider bootstrap complete.");
16
+ })().catch((error) => {
17
+ logger.error("Provider bootstrap failed:", error);
18
+ throw error;
19
+ });
20
+ }
21
+ return providerBootstrapPromise;
22
+ };
23
+
24
+ const getRoutstr21Models = async (
25
+ forceRefresh = false,
26
+ ): Promise<ExposedModel[]> => {
27
+ await ensureProvidersBootstrapped();
28
+
29
+ const routstr21ModelIds = Array.from(
30
+ new Set(await modelManager.fetchRoutstr21Models(forceRefresh)),
31
+ ).slice(0, 21);
32
+ const baseUrls = modelManager.getBaseUrls();
33
+ const discoveredModels = await modelManager.fetchModels(
34
+ baseUrls,
35
+ forceRefresh,
36
+ );
37
+ const modelsById = new Map(discoveredModels.map((model) => [model.id, model]));
38
+
39
+ return routstr21ModelIds.map((modelId) => {
40
+ const model = modelsById.get(modelId);
41
+ return model || { id: modelId, name: modelId };
42
+ });
43
+ };
44
+
45
+ return {
46
+ ensureProvidersBootstrapped,
47
+ getRoutstr21Models,
48
+ };
49
+ }
@@ -0,0 +1,98 @@
1
+ import { Transform } from "stream";
2
+ import type { UsageData } from "./types";
3
+
4
+ export function createSSEParserTransform(
5
+ onUsage: (usage: UsageData) => void,
6
+ onResponseId?: (responseId: string) => void,
7
+ ): Transform {
8
+ let buffer = "";
9
+
10
+ const maybeCaptureUsageFromJson = (jsonText: string): void => {
11
+ try {
12
+ const data = JSON.parse(jsonText) as any;
13
+ const responseId = data.id;
14
+ if (typeof responseId === "string" && responseId.trim().length > 0) {
15
+ onResponseId?.(responseId.trim());
16
+ }
17
+
18
+ if (data.usage) {
19
+ const usageCost = data.usage.cost;
20
+ const cost =
21
+ typeof usageCost === "number"
22
+ ? usageCost
23
+ : usageCost?.total_usd ??
24
+ data.metadata?.routstr?.cost?.total_usd ??
25
+ 0;
26
+ const msats =
27
+ data.metadata?.routstr?.cost?.total_msats ??
28
+ (typeof data.usage.cost_sats === "number"
29
+ ? data.usage.cost_sats * 1000
30
+ : 0);
31
+ onUsage({
32
+ promptTokens: data.usage.prompt_tokens ?? 0,
33
+ completionTokens: data.usage.completion_tokens ?? 0,
34
+ totalTokens: data.usage.total_tokens ?? 0,
35
+ cost,
36
+ satsCost: msats / 1000,
37
+ });
38
+ }
39
+ } catch {
40
+ // Ignore non-JSON lines/events.
41
+ }
42
+ };
43
+
44
+ const processLine = (self: Transform, line: string): void => {
45
+ const trimmed = line.trim();
46
+ if (!trimmed) {
47
+ return;
48
+ }
49
+
50
+ if (trimmed === "data: [DONE]" || trimmed === "[DONE]") {
51
+ self.push("data: [DONE]\n\n");
52
+ return;
53
+ }
54
+
55
+ if (trimmed.startsWith("data:")) {
56
+ const dataStr = trimmed.startsWith("data: ")
57
+ ? trimmed.slice(6)
58
+ : trimmed.slice(5).trimStart();
59
+ if (dataStr === "[DONE]") {
60
+ self.push("data: [DONE]\n\n");
61
+ return;
62
+ }
63
+ maybeCaptureUsageFromJson(dataStr);
64
+ self.push(`data: ${dataStr}\n\n`);
65
+ return;
66
+ }
67
+
68
+ if (trimmed.startsWith("{")) {
69
+ maybeCaptureUsageFromJson(trimmed);
70
+ self.push(`data: ${trimmed}\n\n`);
71
+ return;
72
+ }
73
+
74
+ self.push(line + "\n");
75
+ };
76
+
77
+ return new Transform({
78
+ transform(chunk, encoding, callback) {
79
+ buffer += chunk.toString();
80
+
81
+ const lines = buffer.split(/\r?\n/);
82
+ buffer = lines.pop() || "";
83
+
84
+ for (const line of lines) {
85
+ processLine(this, line);
86
+ }
87
+
88
+ callback();
89
+ },
90
+ flush(callback) {
91
+ if (buffer.trim()) {
92
+ processLine(this, buffer);
93
+ }
94
+ buffer = "";
95
+ callback();
96
+ },
97
+ });
98
+ }
@@ -0,0 +1,25 @@
1
+ export type ExposedModel = {
2
+ id: string;
3
+ name?: string;
4
+ description?: string;
5
+ context_length?: number;
6
+ };
7
+
8
+ export type UsageData = {
9
+ promptTokens: number;
10
+ completionTokens: number;
11
+ totalTokens: number;
12
+ cost: number;
13
+ satsCost: number;
14
+ };
15
+
16
+ export type UsageTrackingEntry = UsageData & {
17
+ id: string;
18
+ timestamp: number;
19
+ modelId: string;
20
+ baseUrl: string;
21
+ requestId: string;
22
+ client?: string;
23
+ sessionId?: string;
24
+ tags?: string[];
25
+ };
@@ -0,0 +1,207 @@
1
+ import { spawn } from "child_process";
2
+ import { getDecodedToken } from "@cashu/cashu-ts";
3
+ import { logger } from "../../utils/logger";
4
+
5
+ export async function runWalletCommand(args: string[]): Promise<string> {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn("cocod", args, {
8
+ stdio: ["ignore", "pipe", "pipe"],
9
+ });
10
+ let stdout = "";
11
+ let stderr = "";
12
+
13
+ child.stdout.on("data", (chunk) => {
14
+ stdout += chunk.toString();
15
+ });
16
+ child.stderr.on("data", (chunk) => {
17
+ stderr += chunk.toString();
18
+ });
19
+ child.on("error", (error) => reject(error));
20
+ child.on("close", (code) => {
21
+ if (code && code !== 0) {
22
+ reject(
23
+ new Error(stderr.trim() || stdout.trim() || "Wallet CLI failed"),
24
+ );
25
+ return;
26
+ }
27
+ resolve(stdout.trim());
28
+ });
29
+ });
30
+ }
31
+
32
+ export function parseBalances(output: string): Record<string, number> {
33
+ const trimmed = output.trim();
34
+ if (!trimmed) return {};
35
+
36
+ try {
37
+ const parsed = JSON.parse(trimmed) as Record<
38
+ string,
39
+ { sats?: number } | number
40
+ >;
41
+ if (parsed && typeof parsed === "object") {
42
+ return Object.fromEntries(
43
+ Object.entries(parsed).map(([mintUrl, value]) => {
44
+ if (typeof value === "number") {
45
+ return [mintUrl, value];
46
+ }
47
+ if (value && typeof value === "object" && "sats" in value) {
48
+ return [mintUrl, Number(value.sats ?? 0)];
49
+ }
50
+ return [mintUrl, 0];
51
+ }),
52
+ );
53
+ }
54
+ } catch {
55
+ // Fall back to line parsing.
56
+ }
57
+
58
+ const balances: Record<string, number> = {};
59
+ trimmed
60
+ .split("\n")
61
+ .map((line) => line.trim())
62
+ .forEach((line) => {
63
+ const match = line.match(/^(\S+):\s+(\d+)\s+s$/);
64
+ const mintUrl = match?.[1];
65
+ const amount = match?.[2];
66
+ if (mintUrl && amount) {
67
+ balances[mintUrl] = Number.parseInt(amount, 10);
68
+ }
69
+ });
70
+ return balances;
71
+ }
72
+
73
+ export function parseMints(
74
+ output: string,
75
+ ): Array<{ url: string; trusted: boolean }> {
76
+ return output
77
+ .split("\n")
78
+ .map((line) => line.trim())
79
+ .map((line) => {
80
+ const urlMatch = line.match(/https?:\/\/\S+/i);
81
+ if (!urlMatch) return null;
82
+ const trustedMatch = line.match(/trusted:\s*(true|false)/i);
83
+ const trustedValue = trustedMatch?.[1];
84
+ return {
85
+ url: urlMatch[0],
86
+ trusted: trustedMatch ? trustedValue?.toLowerCase() === "true" : false,
87
+ };
88
+ })
89
+ .filter((entry): entry is { url: string; trusted: boolean } =>
90
+ Boolean(entry),
91
+ );
92
+ }
93
+
94
+ export function pickTokenLine(output: string): string {
95
+ const lines = output
96
+ .split("\n")
97
+ .map((line) => line.trim())
98
+ .filter(Boolean);
99
+ return lines[lines.length - 1] || "";
100
+ }
101
+
102
+ export async function createWalletAdapter() {
103
+ let activeMintUrl: string | null = null;
104
+ let mintUnits: Record<string, "sat" | "msat"> = {};
105
+
106
+ const walletAdapter = {
107
+ async getBalances(): Promise<Record<string, number>> {
108
+ const output = await runWalletCommand(["balance"]);
109
+ const balances = parseBalances(output);
110
+ mintUnits = Object.fromEntries(
111
+ Object.keys(balances).map((mintUrl) => [mintUrl, "sat"]),
112
+ );
113
+ if (!activeMintUrl) {
114
+ activeMintUrl = Object.keys(balances)[0] || null;
115
+ }
116
+ return balances;
117
+ },
118
+ getMintUnits(): Record<string, "sat" | "msat"> {
119
+ return mintUnits;
120
+ },
121
+ getActiveMintUrl(): string | null {
122
+ return activeMintUrl;
123
+ },
124
+ async sendToken(mintUrl: string, amount: number): Promise<string> {
125
+ const maxRetries = 3;
126
+ const retryDelayMs = 5000;
127
+ const retryErrorPattern = "Proof already reserved by operation";
128
+
129
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
130
+ try {
131
+ const output = await runWalletCommand([
132
+ "send",
133
+ "cashu",
134
+ String(amount),
135
+ "--mint-url",
136
+ mintUrl,
137
+ ]);
138
+ const token = pickTokenLine(output);
139
+ if (!token) {
140
+ throw new Error("Wallet CLI did not return a token.");
141
+ }
142
+ return token;
143
+ } catch (error) {
144
+ const errorMessage =
145
+ error instanceof Error ? error.message : String(error);
146
+
147
+ const shouldRetry =
148
+ attempt < maxRetries &&
149
+ errorMessage.includes(retryErrorPattern);
150
+
151
+ if (shouldRetry) {
152
+ logger.log(
153
+ `sendToken attempt ${attempt + 1} failed with reserved proof error, retrying in ${retryDelayMs / 1000}s...`,
154
+ );
155
+ await new Promise((resolve) =>
156
+ setTimeout(resolve, retryDelayMs),
157
+ );
158
+ continue;
159
+ }
160
+
161
+ logger.error("Error in walletAdapter sendToken:", error);
162
+ throw error;
163
+ }
164
+ }
165
+ throw new Error("sendToken failed after max retries");
166
+ },
167
+ async receiveToken(token: string): Promise<{
168
+ success: boolean;
169
+ amount: number;
170
+ unit: "sat" | "msat";
171
+ message?: string;
172
+ }> {
173
+ try {
174
+ await runWalletCommand(["receive", "cashu", token]);
175
+ const decoded = getDecodedToken(token);
176
+ const amount = decoded?.proofs?.reduce(
177
+ (sum, proof) => sum + proof.amount,
178
+ 0,
179
+ );
180
+ const unit = decoded?.unit === "msat" ? "msat" : "sat";
181
+ return { success: true, amount: amount ?? 0, unit };
182
+ } catch (error) {
183
+ console.log("Eerro in receive", error);
184
+ const errorMessage =
185
+ error instanceof Error ? error.message : String(error);
186
+ const message = errorMessage.includes("Failed to fetch mint")
187
+ ? errorMessage
188
+ : undefined;
189
+ return { success: false, amount: 0, unit: "sat", message };
190
+ }
191
+ },
192
+ isUsingNip60(): boolean {
193
+ return false;
194
+ },
195
+ };
196
+
197
+ try {
198
+ const mintsOutput = await runWalletCommand(["mints", "list"]);
199
+ const mints = parseMints(mintsOutput);
200
+ activeMintUrl =
201
+ mints.find((mint) => mint.trusted)?.url || mints[0]?.url || null;
202
+ } catch (error) {
203
+ logger.error("Failed to read mints from wallet:", error);
204
+ }
205
+
206
+ return walletAdapter;
207
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./daemon/index";
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { cli } from "./cli";
3
+
4
+ cli(process.argv);
@@ -0,0 +1,67 @@
1
+ import type { RoutstrdConfig } from "../utils/config";
2
+ import { logger } from "../utils/logger";
3
+ import { installOpencodeIntegration } from "./opencode";
4
+ import { installOpenClawIntegration } from "./openclaw";
5
+ import { installPiIntegration } from "./pi";
6
+ import type { SdkStore } from "@routstr/sdk";
7
+
8
+ function ask(question: string): Promise<string> {
9
+ process.stdout.write(question);
10
+
11
+ if (!process.stdin.isTTY) {
12
+ return Promise.resolve("1");
13
+ }
14
+
15
+ return new Promise((resolve) => {
16
+ process.stdin.resume();
17
+ process.stdin.setEncoding("utf8");
18
+ process.stdin.once("data", (data) => {
19
+ process.stdin.pause();
20
+ resolve(data.toString().trim());
21
+ });
22
+ });
23
+ }
24
+
25
+ function parseChoice(input: string): number {
26
+ if (input === "") {
27
+ return 1;
28
+ }
29
+
30
+ const parsed = Number.parseInt(input, 10);
31
+ if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 4) {
32
+ return parsed;
33
+ }
34
+
35
+ return 1;
36
+ }
37
+
38
+ export async function setupIntegration(
39
+ config: RoutstrdConfig,
40
+ store: SdkStore,
41
+ ): Promise<void> {
42
+ logger.log("\nChoose an integration to set up:");
43
+ logger.log("1. OpenCode (default)");
44
+ logger.log("2. OpenClaw");
45
+ logger.log("3. Pi");
46
+ logger.log("4. Skip for now");
47
+
48
+ const answer = await ask("Select integration [1]: ");
49
+ const choice = parseChoice(answer);
50
+
51
+ if (choice === 1) {
52
+ await installOpencodeIntegration(config, store);
53
+ return;
54
+ }
55
+
56
+ if (choice === 2) {
57
+ await installOpenClawIntegration(config, store);
58
+ return;
59
+ }
60
+
61
+ if (choice === 3) {
62
+ await installPiIntegration(config, store);
63
+ return;
64
+ }
65
+
66
+ logger.log("Skipping integration setup.");
67
+ }