refactored the daemon

This commit is contained in:
redshift
2026-03-19 16:25:57 +00:00
parent 052659350d
commit e299c80d5b
16 changed files with 1432 additions and 1211 deletions

71
routstr-cost-logging.md Normal file
View File

@@ -0,0 +1,71 @@
# routstr Proxy Cost Logging Issue
## Problem
The routstr proxy at `localhost:8009` returns usage data that differs slightly from what the pi-ai `openai-completions` provider expects.
## What routstr Returns
**Streaming response (last chunk):**
```json
{
"usage": {
"prompt_tokens": 9,
"completion_tokens": 9,
"total_tokens": 18,
"cost": 0.000018,
"prompt_tokens_details": {
"cached_tokens": 0,
"cache_write_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0
}
},
"cost": {
"total_usd": 0.000018
}
}
```
## What pi-ai Expects
The `openai-completions` provider in pi-ai expects the standard OpenAI format:
```json
{
"usage": {
"prompt_tokens": 9,
"completion_tokens": 9,
"prompt_tokens_details": {
"cached_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0
}
}
}
```
## Current Handling
The pi-ai provider already handles the standard format correctly via `parseChunkUsage()`:
- `input``prompt_tokens - cached_tokens`
- `output``completion_tokens + reasoning_tokens`
- `cacheRead``prompt_tokens_details.cached_tokens`
- `cacheWrite`**NOT CURRENTLY PARSED** (hardcoded to 0)
The provider then calculates cost using `calculateCost()` based on the model's configured cost per million tokens, ignoring any `cost` field from the response.
## Gap
The `parseChunkUsage()` function in `packages/ai/src/providers/openai-completions.ts` does not currently extract:
1. `cache_write_tokens` from `prompt_tokens_details` (routstr-specific field)
Currently `cacheWrite` is hardcoded to 0.
## Resolution
The existing pi-ai `openai-completions` provider should work with routstr as-is since routstr returns the standard OpenAI format fields. The usage should be logged correctly if:
1. `stream_options: { include_usage: true }` is passed
2. The model has a `cost` configuration in the registry

View File

@@ -1,4 +1,4 @@
import { startDaemon } from "./daemon";
import { startDaemon } from "./start-daemon";
import {
program,
handleDaemonCommand,

File diff suppressed because it is too large Load Diff

19
src/daemon/args.ts Normal file
View File

@@ -0,0 +1,19 @@
export function parseArgs(argv: string[]): {
port: number;
provider: string | null;
} {
const portFlagIndex = argv.findIndex((arg) => arg === "--port");
const providerFlagIndex = argv.findIndex(
(arg) => arg === "--provider" || arg === "-p",
);
const port =
portFlagIndex !== -1
? Number.parseInt(argv[portFlagIndex + 1] || "8008", 10)
: 8008;
const providerValue =
providerFlagIndex !== -1 ? argv[providerFlagIndex + 1] : undefined;
const provider = providerValue ? providerValue.trim() : null;
return { port, provider };
}

View File

@@ -0,0 +1,36 @@
import { mkdir } from "fs/promises";
import { existsSync } from "fs";
import {
CONFIG_DIR,
CONFIG_FILE,
DEFAULT_CONFIG,
type RoutstrdConfig,
} from "../utils/config";
import { logger } from "../utils/logger";
export const REQUESTS_DIR = `${CONFIG_DIR}/requests`;
export async function ensureDirs(): Promise<void> {
try {
await mkdir(CONFIG_DIR, { recursive: true });
await mkdir(REQUESTS_DIR, { recursive: true });
} catch {
// Directory may already exist
}
}
export async function loadDaemonConfig(): Promise<RoutstrdConfig> {
try {
if (existsSync(CONFIG_FILE)) {
const content = await Bun.file(CONFIG_FILE).text();
return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
}
} catch (error) {
logger.error("Failed to load config:", error);
}
return DEFAULT_CONFIG;
}
export function saveDaemonConfig(config: RoutstrdConfig): void {
Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
}

482
src/daemon/http/index.ts Normal file
View File

@@ -0,0 +1,482 @@
import { type IncomingMessage, type ServerResponse } from "http";
import { Readable } from "stream";
import { ReadableStream as WebReadableStream } from "stream/web";
import { routeRequests, InsufficientBalanceError } from "@routstr/sdk";
import { createSSEParserTransform } from "../sse";
import {
createUsageTracker,
extractResponseId,
extractUsageFromResponseBody,
resolveUsageBaseUrl,
} from "../usage";
import type { UsageData } from "../types";
import { logger } from "../../utils/logger";
async function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
req.on("data", (chunk) => {
data += chunk.toString();
});
req.on("end", () => resolve(data));
req.on("error", reject);
});
}
function parseLimit(value: string | null, fallback = 10): number {
const requested = Number.parseInt(value || String(fallback), 10);
return Number.isFinite(requested) && requested > 0
? Math.min(requested, 1000)
: fallback;
}
export function createDaemonRequestHandler(deps: {
provider: string | null;
server: { close(cb?: () => void): void };
store: any;
walletAdapter: any;
storageAdapter: any;
providerRegistry: any;
discoveryAdapter: any;
modelManager: any;
ensureProvidersBootstrapped: () => Promise<void>;
getRoutstr21Models: () => Promise<any[]>;
runWalletCommand: (args: string[]) => Promise<string>;
parseBalances: (output: string) => Record<string, number>;
}) {
const usageTracker = createUsageTracker(deps.store);
return async function handler(req: IncomingMessage, res: ServerResponse) {
const host = req.headers.host || "localhost";
const url = new URL(req.url || "/", `http://${host}`);
if (req.method === "GET" && url.pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method === "GET" && url.pathname === "/ping") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ output: "pong" }));
return;
}
if (req.method === "GET" && url.pathname === "/status") {
try {
const balancesOutput = await deps.runWalletCommand(["balance"]);
const balances = deps.parseBalances(balancesOutput);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
output: {
daemon: "running",
wallet: "connected",
balances,
},
}),
);
} catch (error) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
output: {
daemon: "running",
wallet: "error",
error: String(error),
},
}),
);
}
return;
}
if (req.method === "GET" && url.pathname === "/models") {
try {
const models = await deps.getRoutstr21Models();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ output: { models } }));
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: String(error) }));
}
return;
}
if (req.method === "GET" && url.pathname === "/v1/models") {
try {
const models = await deps.getRoutstr21Models();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
object: "list",
data: models.map((model) => ({ ...model, object: "model" })),
}),
);
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: String(error) }));
}
return;
}
if (req.method === "POST" && url.pathname === "/stop") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ output: "stopping" }));
setTimeout(() => {
deps.server.close(() => {
process.exit(0);
});
}, 50);
return;
}
if (req.method === "POST" && url.pathname === "/refund") {
try {
const bodyText = await readBody(req);
const body = bodyText ? JSON.parse(bodyText) : {};
const mintUrl = body.mintUrl as string | undefined;
if (!mintUrl) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({ error: "Missing required 'mintUrl' field." }),
);
return;
}
const state = deps.store.getState();
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) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
output: { message: "No pending tokens to refund", results: [] },
}),
);
return;
}
const refundBaseUrls = pendingDistribution
.map((p: { baseUrl: string }) => p.baseUrl)
.concat(apiKeysStored.map((p: { baseUrl: string }) => p.baseUrl));
const { RoutstrClient } = await import("@routstr/sdk");
const client = new RoutstrClient(
deps.walletAdapter,
deps.storageAdapter,
deps.providerRegistry,
"min",
"lazyrefund",
);
const spender = client.getCashuSpender();
const results = await spender.refundProviders(
refundBaseUrls,
mintUrl,
true,
);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
output: {
message: `Refunded to ${mintUrl}`,
pendingTokens: pendingDistribution.length,
apiKeys: apiKeysStored.length,
results: results.map(
(r: { baseUrl: string; success: boolean }) => ({
baseUrl: r.baseUrl,
success: r.success,
}),
),
},
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`Refund error: ${message}`);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: message }));
}
return;
}
if (req.method === "GET" && url.pathname === "/balance") {
try {
const balances = await deps.walletAdapter.getBalances();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
output: {
balances,
unit: "sat",
activeMint: deps.walletAdapter.getActiveMintUrl(),
},
}),
);
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: String(error) }));
}
return;
}
if (req.method === "GET" && url.pathname === "/keys/balance") {
try {
const walletBalances = await deps.walletAdapter.getBalances();
const totalWallet = Object.values(walletBalances).reduce<number>(
(sum, balance) => sum + Number(balance),
0,
);
const state = deps.store.getState();
const cachedTokens = state.cachedTokens || [];
const totalCached = cachedTokens.reduce(
(sum: number, t: { balance?: number }) => sum + (t.balance || 0),
0,
);
const apiKeys = state.apiKeys || [];
const totalApiKeys = apiKeys.reduce(
(sum: number, k: { balance?: number }) => sum + (k.balance || 0),
0,
);
const keys: Array<{ id: string; name: string; balance: number }> = [
{ id: "wallet", name: "Wallet", balance: totalWallet },
...cachedTokens.map((t: { baseUrl: string; balance?: number }) => ({
id: `cached:${t.baseUrl}`,
name: `Cached: ${t.baseUrl}`,
balance: t.balance || 0,
})),
...apiKeys.map((k: { baseUrl: string; balance?: number }) => ({
id: `apikey:${k.baseUrl}`,
name: `API Key: ${k.baseUrl}`,
balance: k.balance || 0,
})),
];
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
output: {
keys,
total: totalWallet + totalCached + totalApiKeys,
unit: "sat",
apikeysCalled: apiKeys.length,
},
}),
);
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: String(error) }));
}
return;
}
if (req.method === "GET" && url.pathname === "/usage") {
try {
const output = usageTracker.listRecent(parseLimit(url.searchParams.get("limit")));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ output }));
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: String(error) }));
}
return;
}
if (req.method === "GET" && url.pathname === "/usagePi") {
try {
const timestamp = (url.searchParams.get("timestamp") || "").trim();
if (!timestamp) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: "Missing required 'timestamp' query parameter.",
}),
);
return;
}
const output = usageTracker.listForTimestamp(
timestamp,
parseLimit(url.searchParams.get("limit")),
);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ output }));
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: String(error) }));
}
return;
}
if (req.method !== "POST") {
res.writeHead(405, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Only POST is supported." }));
return;
}
let requestBody: unknown = {};
try {
const bodyText = await readBody(req);
requestBody = bodyText ? JSON.parse(bodyText) : {};
} catch (error) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: "Invalid JSON body.",
details: error instanceof Error ? error.message : String(error),
}),
);
return;
}
const bodyObj = requestBody as Record<string, unknown>;
const modelId = typeof bodyObj.model === "string" ? bodyObj.model : "";
if (!modelId) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing required 'model' field." }));
return;
}
const forcedProvider =
url.searchParams.get("provider") ||
(req.headers["x-routstr-provider"] as string | undefined) ||
deps.provider ||
undefined;
try {
await deps.ensureProvidersBootstrapped();
const response = await routeRequests({
modelId,
requestBody,
forcedProvider,
walletAdapter: deps.walletAdapter,
storageAdapter: deps.storageAdapter,
providerRegistry: deps.providerRegistry,
discoveryAdapter: deps.discoveryAdapter,
modelManager: deps.modelManager,
debugLevel: "DEBUG",
});
const isStream = bodyObj.stream === true;
const requestId = response.headers.get("x-routstr-request-id") || undefined;
logger.log("Request ID, ", requestId, " with path: ", url.pathname);
const usageBaseUrl = resolveUsageBaseUrl(response, forcedProvider);
if (isStream) {
res.statusCode = response.status;
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
const body = response.body;
if (body) {
let capturedUsage: UsageData | null = null;
let capturedResponseId: string | undefined;
const nodeReadable = Readable.fromWeb(
body as unknown as WebReadableStream,
);
const sseParser = createSSEParserTransform(
(usage) => {
capturedUsage = usage;
},
(responseId) => {
capturedResponseId = responseId;
},
);
nodeReadable.pipe(sseParser).pipe(res);
res.on("finish", () => {
if (capturedUsage) {
const usageRequestId = capturedResponseId || requestId || "unknown";
usageTracker.append({
id:
usageRequestId === "unknown"
? `req-${Date.now()}-${modelId}`
: usageRequestId,
timestamp: Date.now(),
modelId,
baseUrl: usageBaseUrl,
requestId: usageRequestId,
...capturedUsage,
});
logger.log(
"Streaming request usage:",
JSON.stringify(capturedUsage),
);
}
});
} else {
res.end();
}
return;
}
const responseBody = await response.json();
const nonStreamUsage = extractUsageFromResponseBody(responseBody);
if (nonStreamUsage) {
const responseRequestId =
extractResponseId(responseBody) || requestId || "unknown";
usageTracker.append({
id:
responseRequestId === "unknown"
? `req-${Date.now()}-${modelId}`
: responseRequestId,
timestamp: Date.now(),
modelId,
baseUrl: usageBaseUrl,
requestId: responseRequestId,
...nonStreamUsage,
});
}
res.writeHead(response.status, {
"Content-Type": "application/json",
});
res.end(JSON.stringify(responseBody));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`[daemon] Error: ${message}`);
if (error instanceof InsufficientBalanceError) {
const balanceError = error as {
required?: number;
available?: number;
maxMintBalance?: number;
maxMintUrl?: string;
};
res.writeHead(402, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: message,
error_type: "insufficient_balance",
required: balanceError.required,
available: balanceError.available,
maxMintBalance: balanceError.maxMintBalance,
maxMintUrl: balanceError.maxMintUrl,
}),
);
return;
}
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: message }));
}
};
}

94
src/daemon/index.ts Normal file
View File

@@ -0,0 +1,94 @@
import { createServer } from "http";
import { existsSync } from "fs";
import {
ModelManager,
createDiscoveryAdapterFromStore,
createProviderRegistryFromStore,
createStorageAdapterFromStore,
createSdkStore,
} from "@routstr/sdk";
import { DB_PATH, SOCKET_PATH, PID_FILE } from "../utils/config";
import { logger } from "../utils/logger";
import { parseArgs } from "./args";
import {
ensureDirs,
loadDaemonConfig,
saveDaemonConfig,
} from "./config-store";
import { createBunSqliteDriver } from "./sqlite-driver";
import {
createWalletAdapter,
parseBalances,
runWalletCommand,
} from "./wallet";
import { createModelService } from "./models";
import { createDaemonRequestHandler } from "./http";
async function main(): Promise<void> {
const args = parseArgs(process.argv);
const config = await loadDaemonConfig();
const port = args.port;
const provider = args.provider || config.provider;
await ensureDirs();
const updatedConfig = { ...config, port, provider };
saveDaemonConfig(updatedConfig);
const sqliteDriver = createBunSqliteDriver(DB_PATH);
const store = await createSdkStore({ driver: sqliteDriver });
const discoveryAdapter = createDiscoveryAdapterFromStore(store);
const providerRegistry = createProviderRegistryFromStore(store);
const storageAdapter = createStorageAdapterFromStore(store);
const modelManager = new ModelManager(discoveryAdapter);
const { ensureProvidersBootstrapped, getRoutstr21Models } =
createModelService(modelManager);
void ensureProvidersBootstrapped().catch(() => {
// Error is already logged; keep daemon alive for troubleshooting/retries.
});
const walletAdapter = await createWalletAdapter();
const server = createServer();
server.on(
"request",
createDaemonRequestHandler({
provider,
server,
store,
walletAdapter,
storageAdapter,
providerRegistry,
discoveryAdapter,
modelManager,
ensureProvidersBootstrapped,
getRoutstr21Models,
runWalletCommand,
parseBalances,
}),
);
Bun.write(PID_FILE, String(process.pid));
try {
if (existsSync(SOCKET_PATH)) {
Bun.spawn(["rm", SOCKET_PATH]);
}
} catch {
// Ignore
}
server.listen(port, async () => {
logger.log(`Routstr daemon listening on http://localhost:${port}`);
});
}
if (import.meta.main) {
main().catch((error) => {
logger.error("Failed to start Routstr daemon:", error);
process.exit(1);
});
}

44
src/daemon/models.ts Normal file
View File

@@ -0,0 +1,44 @@
import { ModelManager } from "@routstr/sdk";
import type { ExposedModel } from "./types";
import { logger } from "../utils/logger";
export function createModelService(modelManager: ModelManager) {
let providerBootstrapPromise: Promise<void> | null = null;
const ensureProvidersBootstrapped = (): Promise<void> => {
if (!providerBootstrapPromise) {
providerBootstrapPromise = (async () => {
logger.log("Bootstrapping providers...");
const providers = await modelManager.bootstrapProviders(false);
logger.log(`Bootstrapped ${providers.length} providers`);
await modelManager.fetchModels(providers);
logger.log("Provider bootstrap complete.");
})().catch((error) => {
logger.error("Provider bootstrap failed:", error);
throw error;
});
}
return providerBootstrapPromise;
};
const getRoutstr21Models = async (): Promise<ExposedModel[]> => {
await ensureProvidersBootstrapped();
const routstr21ModelIds = Array.from(
new Set(await modelManager.fetchRoutstr21Models()),
).slice(0, 21);
const baseUrls = modelManager.getBaseUrls();
const discoveredModels = await modelManager.fetchModels(baseUrls);
const modelsById = new Map(discoveredModels.map((model) => [model.id, model]));
return routstr21ModelIds.map((modelId) => {
const model = modelsById.get(modelId);
return model || { id: modelId, name: modelId };
});
};
return {
ensureProvidersBootstrapped,
getRoutstr21Models,
};
}

View File

@@ -0,0 +1,51 @@
import SQLite from "bun:sqlite";
import { logger } from "../utils/logger";
export function createBunSqliteDriver(dbPath: string) {
const db = new SQLite(dbPath);
db.run(`
CREATE TABLE IF NOT EXISTS sdk_storage (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
return {
async getItem<T>(key: string, defaultValue: T): Promise<T> {
try {
const row = db
.query("SELECT value FROM sdk_storage WHERE key = ?")
.get(key) as { value: string } | undefined;
if (!row || typeof row.value !== "string") return defaultValue;
try {
return JSON.parse(row.value) as T;
} catch (parseError) {
if (typeof defaultValue === "string") {
return row.value as T;
}
throw parseError;
}
} catch (error) {
logger.error(`SQLite getItem failed for key "${key}":`, error);
return defaultValue;
}
},
async setItem<T>(key: string, value: T): Promise<void> {
try {
db.query(
"INSERT INTO sdk_storage (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
).run(key, JSON.stringify(value));
} catch (error) {
logger.error(`SQLite setItem failed for key "${key}":`, error);
}
},
async removeItem(key: string): Promise<void> {
try {
db.query("DELETE FROM sdk_storage WHERE key = ?").run(key);
} catch (error) {
logger.error(`SQLite removeItem failed for key "${key}":`, error);
}
},
};
}

98
src/daemon/sse.ts Normal file
View File

@@ -0,0 +1,98 @@
import { Transform } from "stream";
import type { UsageData } from "./types";
export function createSSEParserTransform(
onUsage: (usage: UsageData) => void,
onResponseId?: (responseId: string) => void,
): Transform {
let buffer = "";
const maybeCaptureUsageFromJson = (jsonText: string): void => {
try {
const data = JSON.parse(jsonText) as any;
const responseId = data.id;
if (typeof responseId === "string" && responseId.trim().length > 0) {
onResponseId?.(responseId.trim());
}
if (data.usage) {
const usageCost = data.usage.cost;
const cost =
typeof usageCost === "number"
? usageCost
: usageCost?.total_usd ??
data.metadata?.routstr?.cost?.total_usd ??
0;
const msats =
data.metadata?.routstr?.cost?.total_msats ??
(typeof data.usage.cost_sats === "number"
? data.usage.cost_sats * 1000
: 0);
onUsage({
promptTokens: data.usage.prompt_tokens ?? 0,
completionTokens: data.usage.completion_tokens ?? 0,
totalTokens: data.usage.total_tokens ?? 0,
cost,
satsCost: msats / 1000,
});
}
} catch {
// Ignore non-JSON lines/events.
}
};
const processLine = (self: Transform, line: string): void => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
if (trimmed === "data: [DONE]" || trimmed === "[DONE]") {
self.push("data: [DONE]\n\n");
return;
}
if (trimmed.startsWith("data:")) {
const dataStr = trimmed.startsWith("data: ")
? trimmed.slice(6)
: trimmed.slice(5).trimStart();
if (dataStr === "[DONE]") {
self.push("data: [DONE]\n\n");
return;
}
maybeCaptureUsageFromJson(dataStr);
self.push(`data: ${dataStr}\n\n`);
return;
}
if (trimmed.startsWith("{")) {
maybeCaptureUsageFromJson(trimmed);
self.push(`data: ${trimmed}\n\n`);
return;
}
self.push(line + "\n");
};
return new Transform({
transform(chunk, encoding, callback) {
buffer += chunk.toString();
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() || "";
for (const line of lines) {
processLine(this, line);
}
callback();
},
flush(callback) {
if (buffer.trim()) {
processLine(this, buffer);
}
buffer = "";
callback();
},
});
}

25
src/daemon/types.ts Normal file
View File

@@ -0,0 +1,25 @@
export type ExposedModel = {
id: string;
name?: string;
description?: string;
context_length?: number;
};
export type UsageData = {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cost: number;
satsCost: number;
};
export type UsageTrackingEntry = UsageData & {
id: string;
timestamp: number;
modelId: string;
baseUrl: string;
requestId: string;
client?: string;
sessionId?: string;
tags?: string[];
};

135
src/daemon/usage.ts Normal file
View File

@@ -0,0 +1,135 @@
import type { UsageData, UsageTrackingEntry } from "./types";
import { logger } from "../utils/logger";
export function extractUsageFromResponseBody(body: unknown): UsageData | null {
if (!body || typeof body !== "object") return null;
const usage = (body as { usage?: Record<string, unknown> }).usage;
if (!usage || typeof usage !== "object") return null;
const promptTokens = Number(usage.prompt_tokens ?? 0);
const completionTokens = Number(usage.completion_tokens ?? 0);
const totalTokens = Number(usage.total_tokens ?? 0);
const costValue = usage.cost;
let cost = 0;
let satsCost = 0;
if (typeof costValue === "number") {
cost = costValue;
} else if (costValue && typeof costValue === "object") {
const costObj = costValue as Record<string, unknown>;
const totalUsd = costObj.total_usd;
const totalMsats = costObj.total_msats;
cost = typeof totalUsd === "number" ? totalUsd : 0;
satsCost = typeof totalMsats === "number" ? totalMsats / 1000 : 0;
}
if (
promptTokens === 0 &&
completionTokens === 0 &&
totalTokens === 0 &&
cost === 0 &&
satsCost === 0
) {
return null;
}
return {
promptTokens,
completionTokens,
totalTokens,
cost,
satsCost,
};
}
export function resolveUsageBaseUrl(response: Response, fallback?: string): string {
const responseWithBaseUrl = response as Response & { baseUrl?: unknown };
if (typeof responseWithBaseUrl.baseUrl === "string") {
return responseWithBaseUrl.baseUrl;
}
try {
if (response.url) {
const parsed = new URL(response.url);
return `${parsed.protocol}//${parsed.host}`;
}
} catch {
// Ignore URL parsing failures.
}
return fallback || "unknown";
}
export function extractResponseId(body: unknown): string | undefined {
if (!body || typeof body !== "object") return undefined;
const id = (body as { id?: unknown }).id;
if (typeof id !== "string") return undefined;
const trimmed = id.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function createUsageTracker(store: { getState(): any }) {
const append = (entry: UsageTrackingEntry): void => {
const state = store.getState();
const nextUsage = [...(state.usageTracking || []), entry];
state.setUsageTracking(nextUsage);
logger.log("Usage tracking saved:", JSON.stringify(entry));
};
const listRecent = (limit: number) => {
const usageTracking =
((store.getState().usageTracking || []) as UsageTrackingEntry[]) || [];
const recent = usageTracking.slice(-limit).reverse();
const totalSatsCost = usageTracking.reduce(
(sum, entry) => sum + (entry.satsCost || 0),
0,
);
const recentSatsCost = recent.reduce(
(sum, entry) => sum + (entry.satsCost || 0),
0,
);
return {
entries: recent,
totalEntries: usageTracking.length,
totalSatsCost,
recentSatsCost,
limit,
};
};
const listForTimestamp = (timestamp: string, limit: number) => {
const usageTracking =
((store.getState().usageTracking || []) as UsageTrackingEntry[]) || [];
const requestIdPrefix = `gen-${timestamp}-`;
const filteredUsage = usageTracking.filter((entry) =>
entry.requestId.startsWith(requestIdPrefix),
);
const recent = filteredUsage.slice(-limit).reverse();
const totalSatsCost = filteredUsage.reduce(
(sum, entry) => sum + (entry.satsCost || 0),
0,
);
const recentSatsCost = recent.reduce(
(sum, entry) => sum + (entry.satsCost || 0),
0,
);
return {
entries: recent,
totalEntries: filteredUsage.length,
totalSatsCost,
recentSatsCost,
limit,
timestamp,
};
};
return {
append,
listRecent,
listForTimestamp,
};
}

207
src/daemon/wallet/index.ts Normal file
View File

@@ -0,0 +1,207 @@
import { spawn } from "child_process";
import { getDecodedToken } from "@cashu/cashu-ts";
import { logger } from "../../utils/logger";
export async function runWalletCommand(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn("cocod", args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (error) => reject(error));
child.on("close", (code) => {
if (code && code !== 0) {
reject(
new Error(stderr.trim() || stdout.trim() || "Wallet CLI failed"),
);
return;
}
resolve(stdout.trim());
});
});
}
export function parseBalances(output: string): Record<string, number> {
const trimmed = output.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed) as Record<
string,
{ sats?: number } | number
>;
if (parsed && typeof parsed === "object") {
return Object.fromEntries(
Object.entries(parsed).map(([mintUrl, value]) => {
if (typeof value === "number") {
return [mintUrl, value];
}
if (value && typeof value === "object" && "sats" in value) {
return [mintUrl, Number(value.sats ?? 0)];
}
return [mintUrl, 0];
}),
);
}
} catch {
// Fall back to line parsing.
}
const balances: Record<string, number> = {};
trimmed
.split("\n")
.map((line) => line.trim())
.forEach((line) => {
const match = line.match(/^(\S+):\s+(\d+)\s+s$/);
const mintUrl = match?.[1];
const amount = match?.[2];
if (mintUrl && amount) {
balances[mintUrl] = Number.parseInt(amount, 10);
}
});
return balances;
}
export function parseMints(
output: string,
): Array<{ url: string; trusted: boolean }> {
return output
.split("\n")
.map((line) => line.trim())
.map((line) => {
const urlMatch = line.match(/https?:\/\/\S+/i);
if (!urlMatch) return null;
const trustedMatch = line.match(/trusted:\s*(true|false)/i);
const trustedValue = trustedMatch?.[1];
return {
url: urlMatch[0],
trusted: trustedMatch ? trustedValue?.toLowerCase() === "true" : false,
};
})
.filter((entry): entry is { url: string; trusted: boolean } =>
Boolean(entry),
);
}
export function pickTokenLine(output: string): string {
const lines = output
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return lines[lines.length - 1] || "";
}
export async function createWalletAdapter() {
let activeMintUrl: string | null = null;
let mintUnits: Record<string, "sat" | "msat"> = {};
const walletAdapter = {
async getBalances(): Promise<Record<string, number>> {
const output = await runWalletCommand(["balance"]);
const balances = parseBalances(output);
mintUnits = Object.fromEntries(
Object.keys(balances).map((mintUrl) => [mintUrl, "sat"]),
);
if (!activeMintUrl) {
activeMintUrl = Object.keys(balances)[0] || null;
}
return balances;
},
getMintUnits(): Record<string, "sat" | "msat"> {
return mintUnits;
},
getActiveMintUrl(): string | null {
return activeMintUrl;
},
async sendToken(mintUrl: string, amount: number): Promise<string> {
const maxRetries = 3;
const retryDelayMs = 5000;
const retryErrorPattern = "Proof already reserved by operation";
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const output = await runWalletCommand([
"send",
"cashu",
String(amount),
"--mint-url",
mintUrl,
]);
const token = pickTokenLine(output);
if (!token) {
throw new Error("Wallet CLI did not return a token.");
}
return token;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const shouldRetry =
attempt < maxRetries &&
errorMessage.includes(retryErrorPattern);
if (shouldRetry) {
logger.log(
`sendToken attempt ${attempt + 1} failed with reserved proof error, retrying in ${retryDelayMs / 1000}s...`,
);
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs),
);
continue;
}
logger.error("Error in walletAdapter sendToken:", error);
throw error;
}
}
throw new Error("sendToken failed after max retries");
},
async receiveToken(token: string): Promise<{
success: boolean;
amount: number;
unit: "sat" | "msat";
message?: string;
}> {
try {
await runWalletCommand(["receive", "cashu", token]);
const decoded = getDecodedToken(token);
const amount = decoded?.proofs?.reduce(
(sum, proof) => sum + proof.amount,
0,
);
const unit = decoded?.unit === "msat" ? "msat" : "sat";
return { success: true, amount: amount ?? 0, unit };
} catch (error) {
console.log("Eerro in receive", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
const message = errorMessage.includes("Failed to fetch mint")
? errorMessage
: undefined;
return { success: false, amount: 0, unit: "sat", message };
}
},
isUsingNip60(): boolean {
return false;
},
};
try {
const mintsOutput = await runWalletCommand(["mints", "list"]);
const mints = parseMints(mintsOutput);
activeMintUrl =
mints.find((mint) => mint.trusted)?.url || mints[0]?.url || null;
} catch (error) {
logger.error("Failed to read mints from wallet:", error);
}
return walletAdapter;
}

View File

@@ -2,6 +2,7 @@ import type { RoutstrdConfig } from "../utils/config";
import { logger } from "../utils/logger";
import { installOpencodeIntegration } from "./opencode";
import { installOpenClawIntegration } from "./openclaw";
import { installPiIntegration } from "./pi";
function ask(question: string): Promise<string> {
process.stdout.write(question);
@@ -26,7 +27,7 @@ function parseChoice(input: string): number {
}
const parsed = Number.parseInt(input, 10);
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 3) {
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 4) {
return parsed;
}
@@ -37,7 +38,8 @@ export async function setupIntegration(config: RoutstrdConfig): Promise<void> {
logger.log("\nChoose an integration to set up:");
logger.log("1. OpenCode (default)");
logger.log("2. OpenClaw");
logger.log("3. Skip for now");
logger.log("3. Pi");
logger.log("4. Skip for now");
const answer = await ask("Select integration [1]: ");
const choice = parseChoice(answer);
@@ -52,5 +54,10 @@ export async function setupIntegration(config: RoutstrdConfig): Promise<void> {
return;
}
if (choice === 3) {
await installPiIntegration(config);
return;
}
logger.log("Skipping integration setup.");
}

82
src/integrations/pi.ts Normal file
View File

@@ -0,0 +1,82 @@
import { existsSync, mkdirSync } from "fs";
import { readFile, writeFile } from "fs/promises";
import { dirname, join } from "path";
import type { RoutstrdConfig } from "../utils/config";
import { logger } from "../utils/logger";
const PI_CONFIG_PATH = join(process.env.HOME || "", ".pi/agent/models.json");
type RoutstrModel = {
id: string;
name?: string;
};
type PiModelEntry = {
id: string;
};
type PiProviderConfig = {
baseUrl?: string;
api?: string;
apiKey?: string;
models?: PiModelEntry[];
};
type PiConfig = {
providers?: Record<string, PiProviderConfig>;
};
export async function installPiIntegration(config: RoutstrdConfig): Promise<void> {
logger.log("\nInstalling routstr models in pi models.json...");
const port = config.port || 8008;
const baseUrl = `http://localhost:${port}/v1`;
let piConfig: PiConfig = {};
try {
if (existsSync(PI_CONFIG_PATH)) {
const content = await readFile(PI_CONFIG_PATH, "utf-8");
piConfig = JSON.parse(content) as PiConfig;
}
} catch {
piConfig = {};
}
if (!piConfig.providers) {
piConfig.providers = {};
}
try {
// Ensure directory exists
mkdirSync(dirname(PI_CONFIG_PATH), { recursive: true });
const response = await fetch(`http://localhost:${port}/models`);
const data = await response.json() as { output?: { models: RoutstrModel[] } };
const models = data.output?.models || [];
if (models.length === 0) {
logger.log("No models found from routstr daemon.");
return;
}
const providerModels: PiModelEntry[] = models.map((model) => ({
id: model.id,
}));
// Preserve existing apiKey if present
const existingApiKey = piConfig.providers["routstr"]?.apiKey;
piConfig.providers["routstr"] = {
baseUrl,
api: "openai-completions",
apiKey: existingApiKey || "placeholder",
models: providerModels,
};
await writeFile(PI_CONFIG_PATH, JSON.stringify(piConfig, null, 2));
logger.log(`Added "routstr" provider with ${models.length} models to pi models.json`);
} catch (error) {
logger.error("Failed to install models in pi models.json:", error);
}
}

77
src/start-daemon.ts Normal file
View File

@@ -0,0 +1,77 @@
import { LOG_FILE } from "./utils/config";
import { logger } from "./utils/logger";
export async function startDaemon(
options: { port?: string; provider?: string } = {},
): Promise<void> {
const args: string[] = [];
const port = options.port || "8008";
const pollIntervalMs = 250;
const startupTimeoutMs = 10 * 60 * 1000;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
const existing = await fetch(`http://localhost:${port}/health`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (existing.ok) {
logger.log(`Routstr daemon already running on http://localhost:${port}`);
return;
}
} catch {
// Daemon is not running yet; continue with startup.
}
if (options.port) {
args.push("--port", options.port);
}
if (options.provider) {
args.push("--provider", options.provider);
}
const logFile = Bun.file(LOG_FILE);
const proc = Bun.spawn(
["bun", "run", `${import.meta.dir}/daemon/index.ts`, ...args],
{
stdout: logFile,
stderr: logFile,
stdin: "ignore",
detached: true,
},
);
proc.unref();
let exitCode: number | null = null;
proc.exited.then((code) => {
exitCode = code;
});
const maxPolls = Math.ceil(startupTimeoutMs / pollIntervalMs);
for (let i = 0; i < maxPolls; i++) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
if (exitCode !== null) {
throw new Error(
`Daemon process exited early with code ${exitCode}. Check logs at ${LOG_FILE}`,
);
}
try {
const res = await fetch(`http://localhost:${port}/health`);
if (res.ok) {
logger.log(`Routstr daemon started (PID: ${proc.pid}).`);
return;
}
} catch {
// Not ready yet
}
}
throw new Error(
`Daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds. Check logs at ${LOG_FILE}`,
);
}