mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
wire the doctor helpers from the previous commit into a read-only health check surfaced by the daemon and the CLI: - coco-client: runWalletDoctor orchestrates the four checks against an injected structural source (unit-testable like runMintQuoteRecovery); diagnoseWallet() wires it to coco - trusted mints, pending/in-flight mint ops, and failed ops + inflight proofs via coco's private repositories, resolved fail-closed so a coco upgrade that renames them surfaces immediately. Quote checks are plain NUT-04 reads (never coco's observe-and-persist path), bounded by an overall budget and a concurrency limit; uncheckable quotes are counted, not dropped. - http: GET /wallet/doctor returns the report (501 when the wallet client has no doctor support). - cli: wallet doctor now prints the health section first (mint reachability, recent unpaid quotes, paid-but-not-issued with recover remediation, stuck melts), then the existing offline migration diagnosis. NO_COLOR/TTY-aware colors, --json for scripting, exit 1 when money is provably at risk or the wallets conflict. When the daemon is down the live section is skipped rather than auto-starting the daemon. - tests: HTTP route tests, runWalletDoctor unit tests with a fake source, and real-Manager + fake-mint integration tests covering the full unpaid -> paid-unissued -> recovered -> clean arc and unreachable-mint degradation. - docs: wallet doctor section in wallet-mint-recovery.md.
2191 lines
72 KiB
TypeScript
2191 lines
72 KiB
TypeScript
import { randomBytes } from "crypto";
|
||
import { type IncomingMessage, type ServerResponse } from "http";
|
||
import { Readable } from "stream";
|
||
import {
|
||
routeRequests,
|
||
InsufficientBalanceError,
|
||
ProviderManager,
|
||
} from "@routstr/sdk";
|
||
import type { UsageTrackingDriver, SdkLogger } from "@routstr/sdk";
|
||
import type { RequestResponseLogSink } from "../request-response-log-sink";
|
||
import { getEncodedToken } from "@cashu/coco-core";
|
||
import type { HistoryEntry } from "@cashu/coco-core";
|
||
import { logger } from "../../utils/logger";
|
||
import { loadDaemonConfig, saveDaemonConfig } from "../config-store";
|
||
import {
|
||
WalletHttpError,
|
||
type WalletClient,
|
||
type WalletRuntimeState,
|
||
type WalletRecoveryProgress,
|
||
} from "../wallet/wallet-client";
|
||
import { receiveCashuToken } from "../wallet";
|
||
import { getClientsFromStore } from "../../utils/clients";
|
||
import { getUsageSummary } from "./usage-summary";
|
||
import { applyDefaultOutputTokenLimit } from "./request-body";
|
||
import { collapseDuplicatedV1 } from "./request-path";
|
||
import {
|
||
buildCooldownsOutput,
|
||
type StoredCooldownEntry,
|
||
} from "../../utils/cooldowns";
|
||
|
||
// Hop-by-hop headers describe the *upstream* connection, not this one, and must
|
||
// never be copied onto our response. In particular, copying the upstream's
|
||
// `Transfer-Encoding: chunked` while Node also frames the streamed body as
|
||
// chunked produces a duplicated `Transfer-Encoding: chunked, chunked` header,
|
||
// which strict HTTP/1.x clients (e.g. Go's net/http) reject with
|
||
// "too many transfer encodings". Node derives Content-Length for buffered
|
||
// bodies and Transfer-Encoding: chunked for streams itself.
|
||
const HOP_BY_HOP_HEADERS = new Set([
|
||
"connection",
|
||
"keep-alive",
|
||
"proxy-authenticate",
|
||
"proxy-authorization",
|
||
"te",
|
||
"trailer",
|
||
"transfer-encoding",
|
||
"upgrade",
|
||
]);
|
||
|
||
type ClientMode = "xcashu" | "lazyrefund" | "apikeys";
|
||
|
||
type WalletStatusOutput = {
|
||
daemon: "running";
|
||
wallet: "connected" | "recovering" | "error";
|
||
walletState: WalletRuntimeState;
|
||
balances?: Record<string, number>;
|
||
mode: ClientMode;
|
||
error?: string;
|
||
};
|
||
|
||
type DaemonDeps = {
|
||
provider: string | null;
|
||
server: { close(cb?: () => void): void };
|
||
shutdown?: () => void;
|
||
store: any;
|
||
walletClient: WalletClient;
|
||
walletAdapter: any;
|
||
storageAdapter: any;
|
||
discoveryAdapter: any;
|
||
modelManager: any;
|
||
ensureProvidersBootstrapped: () => Promise<void>;
|
||
getRoutstr21Models: (forceRefresh?: boolean) => Promise<any[]>;
|
||
getModelProviders: (modelId: string) => Promise<any>;
|
||
refreshProvidersAndModels: () => Promise<void>;
|
||
mode?: ClientMode;
|
||
/** Nostr hex pubkey for routstr review/audit events (kind 38425). */
|
||
routstrPubkey?: string;
|
||
/** Nostr hex pubkey for the routstr-21 model list only (kind 38423). Falls back to routstrPubkey. */
|
||
routstrModelsPubkey?: string;
|
||
providerManager: ProviderManager;
|
||
refundClient: any;
|
||
requestResponseLogSink?: RequestResponseLogSink;
|
||
};
|
||
|
||
/**
|
||
* Extracts the client ID from an incoming request by looking up the API key
|
||
* in the store's clientIds list.
|
||
*/
|
||
function getClientIdFromRequest(
|
||
req: IncomingMessage,
|
||
store: { getState(): any },
|
||
): string | undefined {
|
||
const authHeader = req.headers.authorization;
|
||
|
||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||
return undefined;
|
||
}
|
||
|
||
const apiKey = authHeader.slice(7); // Remove "Bearer " prefix
|
||
|
||
if (!apiKey.startsWith("sk-")) {
|
||
return undefined;
|
||
}
|
||
|
||
const clients = getClientsFromStore(store);
|
||
const matchingClient = clients.find((c) => c.apiKey === apiKey);
|
||
|
||
return matchingClient?.clientId;
|
||
}
|
||
function generateApiKey(): string {
|
||
const bytes = randomBytes(24);
|
||
return `sk-${bytes.toString("hex")}`;
|
||
}
|
||
|
||
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);
|
||
});
|
||
}
|
||
|
||
async function readJsonBody(
|
||
req: IncomingMessage,
|
||
): Promise<Record<string, unknown>> {
|
||
const bodyText = await readBody(req);
|
||
if (!bodyText) {
|
||
return {};
|
||
}
|
||
|
||
try {
|
||
return JSON.parse(bodyText) as Record<string, unknown>;
|
||
} catch {
|
||
throw new WalletHttpError(400, "Invalid JSON body.");
|
||
}
|
||
}
|
||
|
||
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, 100000) // Cap at 100k entries
|
||
: fallback;
|
||
}
|
||
|
||
/** Normalize a provider URL for matching (add https:// and a trailing slash). */
|
||
function normalizeProviderBaseUrl(url: string): string {
|
||
if (!url.startsWith("http")) {
|
||
url = `https://${url}`;
|
||
}
|
||
return url.endsWith("/") ? url : `${url}/`;
|
||
}
|
||
|
||
/** Extract the provider base URLs a kind 38421 event advertises.
|
||
* Parses `u` tags first, then falls back to `content` endpoint URLs,
|
||
* mirroring ModelManager.bootstrapFromNostr. */
|
||
function collectProviderUrlsFromEvent(event: {
|
||
tags: string[][];
|
||
content: string;
|
||
}): string[] {
|
||
const urls: string[] = [];
|
||
for (const tag of event.tags) {
|
||
if (tag[0] === "u" && typeof tag[1] === "string" && tag[1]) {
|
||
urls.push(normalizeProviderBaseUrl(tag[1]));
|
||
}
|
||
}
|
||
if (urls.length > 0) return urls;
|
||
try {
|
||
const content = JSON.parse(event.content);
|
||
const providers = Array.isArray(content)
|
||
? content
|
||
: content.providers || [];
|
||
for (const p of providers) {
|
||
if (p?.endpoint_url) urls.push(normalizeProviderBaseUrl(p.endpoint_url));
|
||
}
|
||
} catch {
|
||
/* unparseable content — ignore */
|
||
}
|
||
return urls;
|
||
}
|
||
|
||
/** Read a tag value by name from a Nostr event's tag array. */
|
||
function tagValue(tags: string[][], name: string): string | null {
|
||
for (const tag of tags) {
|
||
if (tag[0] === name && typeof tag[1] === "string" && tag[1]) return tag[1];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function sendJson(
|
||
res: ServerResponse,
|
||
status: number,
|
||
payload: Record<string, unknown>,
|
||
): void {
|
||
res.writeHead(status, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify(payload));
|
||
}
|
||
|
||
function toErrorMessage(error: unknown): string {
|
||
return error instanceof Error ? error.message : String(error);
|
||
}
|
||
|
||
function getWalletStateMessage(state: WalletRuntimeState): string {
|
||
switch (state) {
|
||
case "LOCKED":
|
||
return "Wallet is locked. Unlock it before performing wallet operations.";
|
||
case "UNINITIALIZED":
|
||
return "Wallet is not initialized. Run 'routstrd onboard' first.";
|
||
case "RECOVERING":
|
||
return "Wallet is recovering from a previous run. Balance and send/receive operations will be available once recovery completes.";
|
||
case "ERROR":
|
||
return "Wallet is in an error state.";
|
||
default:
|
||
return "Wallet is unavailable.";
|
||
}
|
||
}
|
||
|
||
function respondWithError(
|
||
res: ServerResponse,
|
||
error: unknown,
|
||
fallbackStatus = 500,
|
||
): void {
|
||
if (error instanceof WalletHttpError) {
|
||
sendJson(res, error.status, { error: error.message });
|
||
return;
|
||
}
|
||
|
||
sendJson(res, fallbackStatus, { error: toErrorMessage(error) });
|
||
}
|
||
|
||
async function respond(
|
||
res: ServerResponse,
|
||
getPayload: () => Promise<Record<string, unknown>>,
|
||
): Promise<void> {
|
||
try {
|
||
sendJson(res, 200, await getPayload());
|
||
} catch (error) {
|
||
respondWithError(res, error);
|
||
}
|
||
}
|
||
|
||
function requireStringField(
|
||
body: Record<string, unknown>,
|
||
field: string,
|
||
): string | null {
|
||
const value = body[field];
|
||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||
}
|
||
|
||
function getRequiredStringField(
|
||
body: Record<string, unknown>,
|
||
field: string,
|
||
): string {
|
||
const value = requireStringField(body, field);
|
||
if (!value) {
|
||
throw new WalletHttpError(400, `Missing required '${field}' field.`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function getRequiredPositiveNumberField(
|
||
body: Record<string, unknown>,
|
||
field: string,
|
||
): number {
|
||
const value = body[field];
|
||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||
return value;
|
||
}
|
||
if (typeof value === "string" && value.trim()) {
|
||
const parsed = Number.parseInt(value.trim(), 10);
|
||
if (Number.isFinite(parsed) && parsed > 0) {
|
||
return parsed;
|
||
}
|
||
}
|
||
throw new WalletHttpError(400, `Missing required '${field}' field.`);
|
||
}
|
||
|
||
function optionalStringField(
|
||
body: Record<string, unknown>,
|
||
field: string,
|
||
): string | undefined {
|
||
const value = body[field];
|
||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||
}
|
||
|
||
function optionalStringArrayField(
|
||
body: Record<string, unknown>,
|
||
field: string,
|
||
): string[] | undefined {
|
||
const value = body[field];
|
||
if (value === undefined) return undefined;
|
||
if (
|
||
!Array.isArray(value) ||
|
||
value.some((item) => typeof item !== "string" || !item.trim())
|
||
) {
|
||
throw new WalletHttpError(400, `'${field}' must be an array of non-empty strings.`);
|
||
}
|
||
return value.map((item: string) => item.trim());
|
||
}
|
||
|
||
function getCurrentMode(deps: DaemonDeps): ClientMode {
|
||
const stateMode = deps.store.getState()?.mode;
|
||
return stateMode || deps.mode || "apikeys";
|
||
}
|
||
|
||
async function buildStatusOutput(
|
||
deps: DaemonDeps,
|
||
): Promise<WalletStatusOutput> {
|
||
const mode = getCurrentMode(deps);
|
||
|
||
try {
|
||
const walletState = await deps.walletClient.getStatus();
|
||
if (walletState === "RECOVERING") {
|
||
return {
|
||
daemon: "running",
|
||
wallet: "recovering",
|
||
walletState,
|
||
mode,
|
||
error: getWalletStateMessage(walletState),
|
||
};
|
||
}
|
||
if (walletState !== "UNLOCKED") {
|
||
return {
|
||
daemon: "running",
|
||
wallet: "error",
|
||
walletState,
|
||
mode,
|
||
error: getWalletStateMessage(walletState),
|
||
};
|
||
}
|
||
|
||
const balances = await deps.walletAdapter.getBalances();
|
||
return {
|
||
daemon: "running",
|
||
wallet: "connected",
|
||
walletState,
|
||
balances,
|
||
mode,
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
daemon: "running",
|
||
wallet: "error",
|
||
walletState: "ERROR",
|
||
mode,
|
||
error: toErrorMessage(error),
|
||
};
|
||
}
|
||
}
|
||
|
||
async function buildWalletDetails(deps: DaemonDeps): Promise<{
|
||
state: WalletRuntimeState;
|
||
ready: boolean;
|
||
balances?: Record<string, number>;
|
||
unit?: "sat";
|
||
activeMint?: string | null;
|
||
defaultMint?: string | null;
|
||
recovery?: WalletRecoveryProgress;
|
||
}> {
|
||
const state = await deps.walletClient.getStatus();
|
||
const recovery = await deps.walletClient.getRecoveryProgress?.();
|
||
if (state !== "UNLOCKED") {
|
||
return { state, ready: false, ...(recovery ? { recovery } : {}) };
|
||
}
|
||
|
||
const [balances, defaultMint] = await Promise.all([
|
||
deps.walletAdapter.getBalances(),
|
||
deps.walletClient.getDefaultMint(),
|
||
]);
|
||
return {
|
||
state,
|
||
ready: true,
|
||
balances,
|
||
unit: "sat",
|
||
activeMint: deps.walletAdapter.getActiveMintUrl(),
|
||
defaultMint,
|
||
...(recovery ? { recovery } : {}),
|
||
};
|
||
}
|
||
|
||
function makeSdkLogger(...parts: string[]): SdkLogger {
|
||
const tag = parts.length ? parts.map((p) => `[${p}]`).join(" ") : 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(...parts, p),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Parse a `type` query parameter into a list of normalized transaction types.
|
||
* Accepts comma-separated values and is case-insensitive.
|
||
*/
|
||
export function parseHistoryTypes(raw: string | null | undefined): string[] {
|
||
if (!raw) return [];
|
||
return raw
|
||
.split(",")
|
||
.map((value) => value.trim().toLowerCase())
|
||
.filter((value) => value.length > 0);
|
||
}
|
||
|
||
/** Page size used when scanning history to apply a type filter. */
|
||
const HISTORY_TYPE_SCAN_PAGE_SIZE = 200;
|
||
|
||
/**
|
||
* Return history entries matching `types`, with offset/limit applied after
|
||
* filtering. The coco history repository only paginates by raw position, so we
|
||
* scan pages until enough matches accumulate (or history is exhausted).
|
||
*/
|
||
export async function getHistoryByTypes(
|
||
client: Pick<WalletClient, "getHistory">,
|
||
types: string[],
|
||
offset: number,
|
||
limit: number,
|
||
): Promise<HistoryEntry[]> {
|
||
const wanted = new Set(types);
|
||
const matched: HistoryEntry[] = [];
|
||
let scanOffset = 0;
|
||
while (matched.length < offset + limit) {
|
||
const page = await client.getHistory(scanOffset, HISTORY_TYPE_SCAN_PAGE_SIZE);
|
||
for (const entry of page) {
|
||
if (wanted.has(entry.type)) matched.push(entry);
|
||
}
|
||
if (page.length < HISTORY_TYPE_SCAN_PAGE_SIZE) break;
|
||
scanOffset += HISTORY_TYPE_SCAN_PAGE_SIZE;
|
||
}
|
||
return matched.slice(offset, offset + limit);
|
||
}
|
||
|
||
export function createDaemonRequestHandler(deps: {
|
||
provider: string | null;
|
||
server: { close(cb?: () => void): void };
|
||
shutdown?: () => void;
|
||
store: any;
|
||
walletClient: WalletClient;
|
||
walletAdapter: any;
|
||
storageAdapter: any;
|
||
discoveryAdapter: any;
|
||
modelManager: any;
|
||
ensureProvidersBootstrapped: () => Promise<void>;
|
||
getRoutstr21Models: (forceRefresh?: boolean) => Promise<any[]>;
|
||
getModelProviders: (modelId: string) => Promise<any>;
|
||
refreshProvidersAndModels: () => Promise<void>;
|
||
mode?: "xcashu" | "apikeys";
|
||
/** Default max_tokens/max_output_tokens to inject when the client omits one. */
|
||
maxTokens: number;
|
||
/** Enable SDK automatic DeepSeek V4.1 Flash model-path selection. */
|
||
autoModelPath?: boolean;
|
||
/** Nostr hex pubkey for routstr review/audit events (kind 38425). */
|
||
routstrPubkey?: string;
|
||
/** Nostr hex pubkey for the routstr-21 model list only (kind 38423). Falls back to routstrPubkey. */
|
||
routstrModelsPubkey?: string;
|
||
usageTrackingDriver: UsageTrackingDriver;
|
||
providerManager: ProviderManager;
|
||
refundClient: any;
|
||
requestResponseLogSink?: RequestResponseLogSink;
|
||
}) {
|
||
return async function handler(req: IncomingMessage, res: ServerResponse) {
|
||
const host = req.headers.host || "localhost";
|
||
const url = new URL(req.url || "/", `http://${host}`);
|
||
|
||
// The node canonicalizes exactly ONE optional "v1/" segment (routstr-core
|
||
// `_canonical_api_path`). A client whose base URL already ends in /v1 while
|
||
// its transport appends /v1 itself — the Anthropic SDKs do, the OpenAI ones
|
||
// do not — would otherwise get a paid upstream 404 from every provider in
|
||
// the pool. Collapse the duplicate before any routing or payment decision.
|
||
const canonicalPath = collapseDuplicatedV1(url.pathname);
|
||
if (canonicalPath !== url.pathname) {
|
||
logger.warn(
|
||
`[daemon] Collapsing duplicated '/v1' in request path: ${url.pathname} -> ${canonicalPath}`,
|
||
);
|
||
url.pathname = canonicalPath;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/health") {
|
||
sendJson(res, 200, { ok: true });
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/ping") {
|
||
sendJson(res, 200, { output: "pong" });
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/status") {
|
||
const output = await buildStatusOutput(deps);
|
||
sendJson(res, 200, { output });
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/wallet/status") {
|
||
await respond(res, async () => ({
|
||
output: await buildWalletDetails(deps),
|
||
}));
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/unlock") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const passphrase = getRequiredStringField(body, "passphrase");
|
||
const message = await deps.walletClient.unlock(passphrase);
|
||
const state = await deps.walletClient.getStatus();
|
||
return { output: { message, state } };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/wallet/balance") {
|
||
await respond(res, async () => {
|
||
const balances = await deps.walletAdapter.getBalances();
|
||
return {
|
||
output: {
|
||
balances,
|
||
unit: "sat",
|
||
activeMint: deps.walletAdapter.getActiveMintUrl(),
|
||
walletState: "UNLOCKED",
|
||
},
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/cleanup") {
|
||
await respond(res, async () => {
|
||
if (!deps.walletClient.cleanupStuckOperations) {
|
||
throw new WalletHttpError(
|
||
501,
|
||
"Wallet cleanup is not supported by this wallet client.",
|
||
);
|
||
}
|
||
|
||
const body = await readJsonBody(req);
|
||
const result = await deps.walletClient.cleanupStuckOperations({
|
||
mintUrl: optionalStringField(body, "mintUrl"),
|
||
minAgeMs:
|
||
typeof body.minAgeMs === "number" && Number.isFinite(body.minAgeMs)
|
||
? body.minAgeMs
|
||
: undefined,
|
||
dryRun: body.dryRun === true,
|
||
force: body.force === true,
|
||
});
|
||
return { output: result };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/wallet/doctor") {
|
||
await respond(res, async () => {
|
||
if (!deps.walletClient.diagnoseWallet) {
|
||
throw new WalletHttpError(
|
||
501,
|
||
"Wallet doctor is not supported by this wallet client.",
|
||
);
|
||
}
|
||
return { output: await deps.walletClient.diagnoseWallet() };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/recover") {
|
||
await respond(res, async () => {
|
||
if (!deps.walletClient.recoverMintQuotes) {
|
||
throw new WalletHttpError(
|
||
501,
|
||
"Mint quote recovery is not supported by this wallet client.",
|
||
);
|
||
}
|
||
|
||
const body = await readJsonBody(req);
|
||
const operationIds = optionalStringArrayField(body, "operationIds");
|
||
if (body.includeFailed === true && !operationIds?.length) {
|
||
throw new WalletHttpError(
|
||
400,
|
||
"'includeFailed' requires non-empty 'operationIds'.",
|
||
);
|
||
}
|
||
if (
|
||
body.timeoutMs !== undefined &&
|
||
(typeof body.timeoutMs !== "number" ||
|
||
!Number.isFinite(body.timeoutMs) || body.timeoutMs <= 0)
|
||
) {
|
||
throw new WalletHttpError(
|
||
400,
|
||
"'timeoutMs' must be a positive finite number.",
|
||
);
|
||
}
|
||
const result = await deps.walletClient.recoverMintQuotes({
|
||
operationIds,
|
||
includeFailed: body.includeFailed === true,
|
||
timeoutMs: body.timeoutMs as number | undefined,
|
||
});
|
||
return { output: result };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/recover/operations") {
|
||
await respond(res, async () => {
|
||
if (!deps.walletClient.recoverStuckOperations) {
|
||
throw new WalletHttpError(
|
||
501,
|
||
"Stuck operation recovery is not supported by this wallet client.",
|
||
);
|
||
}
|
||
return { output: await deps.walletClient.recoverStuckOperations() };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/receive/cashu") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const token = getRequiredStringField(body, "token");
|
||
return {
|
||
output: await receiveCashuToken(deps.walletClient, token),
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/receive/bolt11") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const amount = getRequiredPositiveNumberField(body, "amount");
|
||
const mintUrl = optionalStringField(body, "mintUrl");
|
||
const { invoice, operationId } = await deps.walletClient.receiveBolt11(
|
||
amount,
|
||
mintUrl,
|
||
);
|
||
return { output: { invoice, operationId, amount, mintUrl } };
|
||
});
|
||
return;
|
||
}
|
||
|
||
const mintQuoteMatch = url.pathname.match(/^\/wallet\/receive\/bolt11\/([^/]+)$/);
|
||
if (req.method === "GET" && mintQuoteMatch?.[1]) {
|
||
const operationId = decodeURIComponent(mintQuoteMatch[1]);
|
||
await respond(res, async () => {
|
||
const quote = await deps.walletClient.getMintQuote?.(operationId);
|
||
if (!quote) throw new WalletHttpError(404, "Mint quote not found");
|
||
return { output: quote };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/send/cashu") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const amount = getRequiredPositiveNumberField(body, "amount");
|
||
const mintUrl = optionalStringField(body, "mintUrl");
|
||
const token = await deps.walletClient.sendCashu(amount, mintUrl);
|
||
return { output: { token, amount, mintUrl } };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/send/bolt11") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const invoice = getRequiredStringField(body, "invoice");
|
||
const mintUrl = optionalStringField(body, "mintUrl");
|
||
const message = await deps.walletClient.sendBolt11(invoice, mintUrl);
|
||
return { output: { message, invoice, mintUrl } };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/wallet/mints") {
|
||
await respond(res, async () => {
|
||
const [mints, defaultMint] = await Promise.all([
|
||
deps.walletClient.listMints(),
|
||
deps.walletClient.getDefaultMint(),
|
||
]);
|
||
return {
|
||
output: {
|
||
mints,
|
||
activeMint: defaultMint,
|
||
defaultMint,
|
||
},
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/mints") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const mintUrl = getRequiredStringField(body, "url");
|
||
const message = await deps.walletClient.addMint(mintUrl);
|
||
return { output: { message, url: mintUrl } };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/mints/info") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const mintUrl = getRequiredStringField(body, "url");
|
||
const info = await deps.walletClient.getMintInfo(mintUrl);
|
||
return { output: { url: mintUrl, info } };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/wallet/mints/default") {
|
||
await respond(res, async () => {
|
||
const defaultMint = await deps.walletClient.getDefaultMint();
|
||
return { output: { defaultMint } };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/mints/default") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const mintUrl = getRequiredStringField(body, "url");
|
||
const message = await deps.walletClient.setDefaultMint(mintUrl);
|
||
return { output: { message, url: mintUrl } };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/wallet/history") {
|
||
await respond(res, async () => {
|
||
const offsetParam = url.searchParams.get("offset");
|
||
const limitParam = url.searchParams.get("limit");
|
||
const idParam = url.searchParams.get("id")?.trim() || "";
|
||
const types = parseHistoryTypes(url.searchParams.get("type"));
|
||
const offset = offsetParam ? parseInt(offsetParam, 10) || 0 : 0;
|
||
const limit = limitParam ? parseInt(limitParam, 10) || 50 : 50;
|
||
|
||
let entries: HistoryEntry[];
|
||
if (idParam) {
|
||
const entry = await deps.walletClient.getHistoryEntryById(idParam);
|
||
entries = entry ? [entry] : [];
|
||
} else if (types.length > 0) {
|
||
entries = await getHistoryByTypes(
|
||
deps.walletClient,
|
||
types,
|
||
offset,
|
||
limit,
|
||
);
|
||
} else {
|
||
entries = await deps.walletClient.getHistory(offset, limit);
|
||
}
|
||
|
||
const encoded = entries.map((entry: HistoryEntry) => {
|
||
const base = { ...entry } as Record<string, unknown>;
|
||
if (
|
||
(entry.type === "send" || entry.type === "receive") &&
|
||
entry.token
|
||
) {
|
||
base.encodedToken = getEncodedToken(entry.token);
|
||
}
|
||
return base;
|
||
});
|
||
|
||
return { output: { entries: encoded, offset, limit } };
|
||
});
|
||
return;
|
||
}
|
||
|
||
// ── NPC (npubx.cash) Lightning address endpoints ──────────────
|
||
|
||
if (req.method === "GET" && url.pathname === "/wallet/npc/address") {
|
||
await respond(res, async () => {
|
||
const info = await deps.walletClient.getNpcAddress();
|
||
return { output: info };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/npc/username") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const username = getRequiredStringField(body, "username");
|
||
const confirm = body.confirm === true;
|
||
const result = await deps.walletClient.setNpcUsername(username, confirm);
|
||
if (!result.success) {
|
||
const pr = result.paymentRequest ?? {};
|
||
const amount = typeof pr.amount === "number" ? pr.amount : 0;
|
||
const mints = Array.isArray(pr.mints) ? pr.mints.join(", ") : "";
|
||
if (confirm) {
|
||
throw new WalletHttpError(
|
||
402,
|
||
`Failed to set username. Required amount: ${amount} SATS. Required mints: ${mints}`,
|
||
);
|
||
}
|
||
throw new WalletHttpError(
|
||
402,
|
||
`Payment required to set username: ${amount} SATS. ` +
|
||
`Use 'routstrd wallet npc username ${username} --confirm' to proceed`,
|
||
);
|
||
}
|
||
return { output: { success: true, username } };
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/wallet/npc/sync") {
|
||
await respond(res, async () => {
|
||
await deps.walletClient.syncNpc();
|
||
return { output: { message: "NPC sync completed" } };
|
||
});
|
||
return;
|
||
}
|
||
|
||
// ── NWC endpoints ─────────────────────────────────────────────
|
||
|
||
if (req.method === "GET" && url.pathname === "/nwc/status") {
|
||
await respond(res, async () => {
|
||
const status = await deps.walletAdapter.getNwcStatus();
|
||
const autoRefill = deps.walletAdapter.getAutoRefillConfig();
|
||
return {
|
||
output: {
|
||
...status,
|
||
autoRefill: autoRefill
|
||
? {
|
||
enabled: autoRefill.enabled,
|
||
threshold: autoRefill.threshold,
|
||
amount: autoRefill.amount,
|
||
cooldownMs: autoRefill.cooldownMs,
|
||
cooldownMinutes: autoRefill.cooldownMs / 60000,
|
||
}
|
||
: undefined,
|
||
},
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/nwc/connect") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const connectionString = getRequiredStringField(body, "connectionString");
|
||
|
||
// Reload config and set NWC connection
|
||
const config = await loadDaemonConfig();
|
||
config.nwc = {
|
||
mode: "funding_source",
|
||
connectionString,
|
||
autoRefill: config.nwc?.autoRefill,
|
||
};
|
||
saveDaemonConfig(config);
|
||
|
||
// Hot-reload: reconnect the wallet adapter with the new connection string
|
||
await deps.walletAdapter.reconnect(connectionString);
|
||
|
||
return {
|
||
output: {
|
||
message: "NWC connection string saved and connected.",
|
||
},
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/nwc/disconnect") {
|
||
await respond(res, async () => {
|
||
const config = await loadDaemonConfig();
|
||
if (config.nwc) {
|
||
delete config.nwc.connectionString;
|
||
if (config.nwc.autoRefill) {
|
||
config.nwc.autoRefill.enabled = false;
|
||
}
|
||
}
|
||
saveDaemonConfig(config);
|
||
|
||
// Hot-reload: disconnect the wallet adapter
|
||
await deps.walletAdapter.reconnect();
|
||
|
||
return {
|
||
output: { message: "NWC disconnected." },
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/nwc/fund") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const amount = getRequiredPositiveNumberField(body, "amount");
|
||
const result = await deps.walletAdapter.fundFromNWC(amount);
|
||
if (!result.success) {
|
||
throw new Error(result.error || "NWC funding failed");
|
||
}
|
||
const balances = await deps.walletAdapter.getBalances();
|
||
const activeMint = deps.walletAdapter.getActiveMintUrl();
|
||
const balance = activeMint ? (balances[activeMint] ?? 0) : 0;
|
||
|
||
return {
|
||
output: {
|
||
message: `Successfully funded ${amount} sats via NWC`,
|
||
amount,
|
||
balance,
|
||
},
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/nwc/auto-refill") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const enabled = body.enabled === true || body.enabled === "true";
|
||
|
||
const config = await loadDaemonConfig();
|
||
if (!config.nwc) {
|
||
config.nwc = { mode: "funding_source" };
|
||
}
|
||
|
||
if (enabled) {
|
||
const threshold =
|
||
typeof body.threshold === "number" && body.threshold > 0
|
||
? body.threshold
|
||
: 500;
|
||
const amount =
|
||
typeof body.amount === "number" && body.amount > 0
|
||
? body.amount
|
||
: 1000;
|
||
const cooldownMs =
|
||
typeof body.cooldownMs === "number" && body.cooldownMs > 0
|
||
? body.cooldownMs
|
||
: 300000;
|
||
|
||
config.nwc.autoRefill = {
|
||
enabled: true,
|
||
threshold,
|
||
amount,
|
||
cooldownMs,
|
||
};
|
||
} else {
|
||
config.nwc.autoRefill = config.nwc.autoRefill
|
||
? { ...config.nwc.autoRefill, enabled: false }
|
||
: { enabled: false, threshold: 500, amount: 1000, cooldownMs: 300000 };
|
||
}
|
||
|
||
saveDaemonConfig(config);
|
||
|
||
return {
|
||
output: {
|
||
message: `Auto-refill ${enabled ? "enabled" : "disabled"}.`,
|
||
autoRefill: {
|
||
...config.nwc.autoRefill,
|
||
cooldownMinutes: config.nwc.autoRefill.cooldownMs / 60000,
|
||
},
|
||
},
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Scheduled refresh job (Nostr events, models, client integrations).
|
||
// The job re-reads this on every tick, so no daemon restart is needed.
|
||
if (req.method === "POST" && url.pathname === "/settings/auto-refresh") {
|
||
await respond(res, async () => {
|
||
const body = await readJsonBody(req);
|
||
const enabled = body.enabled === true || body.enabled === "true";
|
||
|
||
const config = await loadDaemonConfig();
|
||
config.autoRefresh = { ...config.autoRefresh, enabled };
|
||
saveDaemonConfig(config);
|
||
|
||
return {
|
||
output: {
|
||
message: `Automatic refresh ${enabled ? "enabled" : "disabled"}.`,
|
||
autoRefresh: config.autoRefresh,
|
||
},
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/models") {
|
||
try {
|
||
const forceRefresh =
|
||
url.searchParams.get("refresh")?.toLowerCase() === "true";
|
||
const models = await deps.getRoutstr21Models(forceRefresh);
|
||
sendJson(res, 200, { output: { models } });
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: toErrorMessage(error) });
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Get providers for a specific model
|
||
const modelProvidersMatch = url.pathname.match(/^\/models\/([^\/]+)\/providers$/);
|
||
if (req.method === "GET" && modelProvidersMatch && modelProvidersMatch[1]) {
|
||
try {
|
||
const modelId = decodeURIComponent(modelProvidersMatch[1]);
|
||
const modelWithProviders = await deps.getModelProviders(modelId);
|
||
if (!modelWithProviders) {
|
||
sendJson(res, 404, { error: `Model '${modelId}' not found` });
|
||
return;
|
||
}
|
||
sendJson(res, 200, { output: modelWithProviders });
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: toErrorMessage(error) });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/v1/models") {
|
||
try {
|
||
const forceRefresh =
|
||
url.searchParams.get("refresh")?.toLowerCase() === "true";
|
||
const models = await deps.getRoutstr21Models(forceRefresh);
|
||
sendJson(res, 200, {
|
||
object: "list",
|
||
data: models.map((model) => ({ ...model, object: "model" })),
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: toErrorMessage(error) });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/stop") {
|
||
sendJson(res, 200, { output: "stopping" });
|
||
setTimeout(() => {
|
||
if (deps.shutdown) {
|
||
deps.shutdown();
|
||
} else {
|
||
deps.server.close(() => process.exit(0));
|
||
}
|
||
}, 50);
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/refund") {
|
||
try {
|
||
const body = await readJsonBody(req);
|
||
const mintUrl = getRequiredStringField(body, "mintUrl");
|
||
|
||
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) {
|
||
sendJson(res, 200, {
|
||
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 spender = deps.refundClient.getCashuSpender();
|
||
const results = await spender.refundProviders(mintUrl, true);
|
||
|
||
sendJson(res, 200, {
|
||
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) {
|
||
logger.error(`Refund error: ${toErrorMessage(error)}`);
|
||
respondWithError(res, error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/refund/xcashu") {
|
||
try {
|
||
const body = await readJsonBody(req);
|
||
const mintUrl = getRequiredStringField(body, "mintUrl");
|
||
|
||
const spender = deps.refundClient.getCashuSpender();
|
||
const results = await spender.refundXcashuTokens(mintUrl);
|
||
|
||
sendJson(res, 200, {
|
||
output: {
|
||
message: `Refunded xcashu tokens to ${mintUrl}`,
|
||
results: results.map(
|
||
(r: { baseUrl: string; token: string; success: boolean; error?: string }) => ({
|
||
baseUrl: r.baseUrl,
|
||
token: r.token,
|
||
success: r.success,
|
||
error: r.error,
|
||
}),
|
||
),
|
||
},
|
||
});
|
||
} catch (error) {
|
||
logger.error(`xcashu refund error: ${toErrorMessage(error)}`);
|
||
respondWithError(res, error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/balance") {
|
||
try {
|
||
const balances = await deps.walletAdapter.getBalances();
|
||
sendJson(res, 200, {
|
||
output: {
|
||
balances,
|
||
unit: "sat",
|
||
activeMint: deps.walletAdapter.getActiveMintUrl(),
|
||
},
|
||
});
|
||
} catch (error) {
|
||
respondWithError(res, 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,
|
||
})),
|
||
];
|
||
|
||
sendJson(res, 200, {
|
||
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 === "/keys/api") {
|
||
try {
|
||
const apiKeys = deps.storageAdapter.getAllApiKeys() as Array<{
|
||
baseUrl: string;
|
||
key: string;
|
||
balance: number;
|
||
lastUsed: number | null;
|
||
}>;
|
||
const totalApiKeys = apiKeys.reduce(
|
||
(sum: number, k) => sum + (k.balance || 0),
|
||
0,
|
||
);
|
||
sendJson(res, 200, {
|
||
output: {
|
||
apiKeys,
|
||
count: apiKeys.length,
|
||
total: totalApiKeys,
|
||
unit: "sat",
|
||
},
|
||
});
|
||
} catch (error) {
|
||
respondWithError(res, error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Refund remaining balance to a mint before removing the key.
|
||
const apiKeyDeleteMatch =
|
||
(req.method === "DELETE" || req.method === "POST") &&
|
||
url.pathname === "/keys/api/delete";
|
||
if (apiKeyDeleteMatch) {
|
||
try {
|
||
let baseUrl: string | undefined;
|
||
let mintUrl: string | undefined;
|
||
if (req.method === "DELETE") {
|
||
baseUrl = url.searchParams.get("baseUrl") || undefined;
|
||
mintUrl = url.searchParams.get("mintUrl") || undefined;
|
||
} else {
|
||
const body = await readJsonBody(req);
|
||
baseUrl = getRequiredStringField(body, "baseUrl");
|
||
mintUrl = body.mintUrl as string | undefined;
|
||
}
|
||
if (!baseUrl) {
|
||
sendJson(res, 400, {
|
||
error: "Missing required 'baseUrl' field.",
|
||
});
|
||
return;
|
||
}
|
||
|
||
const existing = deps.storageAdapter.getApiKey(baseUrl);
|
||
if (!existing) {
|
||
sendJson(res, 200, {
|
||
output: {
|
||
baseUrl,
|
||
removed: false,
|
||
refunded: false,
|
||
message: `No API key found for ${baseUrl}`,
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!mintUrl) {
|
||
try {
|
||
mintUrl =
|
||
deps.walletAdapter.getActiveMintUrl() ??
|
||
Object.keys(await deps.walletAdapter.getBalances())[0];
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
let refunded = false;
|
||
let refundMessage: string | undefined;
|
||
let refundedAmount: number | undefined;
|
||
|
||
if (mintUrl) {
|
||
try {
|
||
const balanceManager = deps.refundClient.getBalanceManager();
|
||
if (balanceManager) {
|
||
const refundResult = await balanceManager.refundApiKey({
|
||
mintUrl,
|
||
baseUrl: existing.baseUrl,
|
||
apiKey: existing.key,
|
||
forceRefund: true,
|
||
});
|
||
refunded = refundResult.success;
|
||
refundMessage = refundResult.message;
|
||
if (refundResult.refundedAmount) {
|
||
refundedAmount = Math.floor(
|
||
refundResult.refundedAmount / 1000,
|
||
);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
refundMessage = `Refund error: ${toErrorMessage(error)}`;
|
||
}
|
||
} else {
|
||
refundMessage = "No mint available to refund to";
|
||
}
|
||
|
||
// refundApiKey removes the key on success; remove manually on failure.
|
||
if (!refunded) {
|
||
deps.storageAdapter.removeApiKey(existing.baseUrl);
|
||
}
|
||
|
||
sendJson(res, 200, {
|
||
output: {
|
||
baseUrl: existing.baseUrl,
|
||
removed: true,
|
||
refunded,
|
||
refundedAmount,
|
||
refundMessage,
|
||
message: refunded
|
||
? `Refunded and removed API key for ${existing.baseUrl}`
|
||
: `Removed API key for ${existing.baseUrl} (refund failed: ${refundMessage ?? "unknown"})`,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
respondWithError(res, error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/providers/disable") {
|
||
try {
|
||
const bodyText = await readBody(req);
|
||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||
const indices = body.indices as number[] | undefined;
|
||
|
||
if (!Array.isArray(indices)) {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
error: "Missing or invalid 'indices' field (expected number[]).",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const state = deps.store.getState();
|
||
const baseUrlsList: string[] = state.baseUrlsList || [];
|
||
// User-driven disables belong in the *manual* disabled list. The
|
||
// review-based list (setDisabledProviders) is owned by the Nostr
|
||
// kind-38425 review sync, which overwrites it and would otherwise
|
||
// silently re-enable manually-disabled providers. Disabling also
|
||
// clears any manual-enable override for that provider.
|
||
const manuallyDisabledProviders: string[] = [
|
||
...(state.manuallyDisabledProviders || []),
|
||
];
|
||
const manuallyEnabledProviders: string[] = [
|
||
...(state.manuallyEnabledProviders || []),
|
||
];
|
||
|
||
const toDisable: string[] = [];
|
||
for (const idx of indices) {
|
||
if (
|
||
typeof idx === "number" &&
|
||
idx >= 0 &&
|
||
idx < baseUrlsList.length
|
||
) {
|
||
const baseUrl = baseUrlsList[idx]!;
|
||
if (!manuallyDisabledProviders.includes(baseUrl)) {
|
||
manuallyDisabledProviders.push(baseUrl);
|
||
toDisable.push(baseUrl);
|
||
}
|
||
const enabledPos = manuallyEnabledProviders.indexOf(baseUrl);
|
||
if (enabledPos !== -1) {
|
||
manuallyEnabledProviders.splice(enabledPos, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
deps.store.getState().setManuallyDisabledProviders(manuallyDisabledProviders);
|
||
deps.discoveryAdapter.setManuallyDisabledProviders(manuallyDisabledProviders);
|
||
deps.store.getState().setManuallyEnabledProviders(manuallyEnabledProviders);
|
||
deps.discoveryAdapter.setManuallyEnabledProviders(manuallyEnabledProviders);
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
output: {
|
||
message: `Disabled ${toDisable.length} provider(s)`,
|
||
disabled: toDisable,
|
||
},
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: String(error) }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/providers/enable") {
|
||
try {
|
||
const bodyText = await readBody(req);
|
||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||
const indices = body.indices as number[] | undefined;
|
||
|
||
if (!Array.isArray(indices)) {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
error: "Missing or invalid 'indices' field (expected number[]).",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const state = deps.store.getState();
|
||
const baseUrlsList: string[] = state.baseUrlsList || [];
|
||
// Re-enabling clears the manual disable and records a manual-enable
|
||
// override so the review sync (which disables providers without an
|
||
// lgtm review) does not silently re-disable it on the next pass.
|
||
const manuallyDisabledProviders: string[] = [
|
||
...(state.manuallyDisabledProviders || []),
|
||
];
|
||
const manuallyEnabledProviders: string[] = [
|
||
...(state.manuallyEnabledProviders || []),
|
||
];
|
||
|
||
const toEnable: string[] = [];
|
||
for (const idx of indices) {
|
||
if (
|
||
typeof idx === "number" &&
|
||
idx >= 0 &&
|
||
idx < baseUrlsList.length
|
||
) {
|
||
const baseUrl = baseUrlsList[idx]!;
|
||
const pos = manuallyDisabledProviders.indexOf(baseUrl);
|
||
if (pos !== -1) {
|
||
manuallyDisabledProviders.splice(pos, 1);
|
||
}
|
||
if (!manuallyEnabledProviders.includes(baseUrl)) {
|
||
manuallyEnabledProviders.push(baseUrl);
|
||
}
|
||
toEnable.push(baseUrl);
|
||
}
|
||
}
|
||
|
||
deps.store.getState().setManuallyDisabledProviders(manuallyDisabledProviders);
|
||
deps.discoveryAdapter.setManuallyDisabledProviders(manuallyDisabledProviders);
|
||
deps.store.getState().setManuallyEnabledProviders(manuallyEnabledProviders);
|
||
deps.discoveryAdapter.setManuallyEnabledProviders(manuallyEnabledProviders);
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
output: {
|
||
message: `Enabled ${toEnable.length} provider(s)`,
|
||
enabled: toEnable,
|
||
},
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: String(error) }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/providers/nostr-sync") {
|
||
try {
|
||
const bodyText = await readBody(req);
|
||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||
const indices = body.indices as number[] | undefined;
|
||
|
||
if (!Array.isArray(indices)) {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
error: "Missing or invalid 'indices' field (expected number[]).",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const state = deps.store.getState();
|
||
const baseUrlsList: string[] = state.baseUrlsList || [];
|
||
|
||
const validIndices: number[] = [];
|
||
const skippedIndices: unknown[] = [];
|
||
for (const idx of indices) {
|
||
if (
|
||
typeof idx === "number" &&
|
||
Number.isInteger(idx) &&
|
||
idx >= 0 &&
|
||
idx < baseUrlsList.length
|
||
) {
|
||
if (!validIndices.includes(idx)) validIndices.push(idx);
|
||
} else {
|
||
skippedIndices.push(idx);
|
||
}
|
||
}
|
||
|
||
if (validIndices.length === 0) {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
error: `No valid indices provided (valid range: 0-${Math.max(baseUrlsList.length - 1, 0)}).`,
|
||
skipped: skippedIndices,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Clearing both manual lists returns the provider to the
|
||
// review-based state: the effective disabled set is the union of
|
||
// disabledProviders (kind-38425 review sync) and the manual lists,
|
||
// so with no manual entries the Nostr review verdict applies again.
|
||
const manuallyDisabledProviders: string[] = [
|
||
...(state.manuallyDisabledProviders || []),
|
||
];
|
||
const manuallyEnabledProviders: string[] = [
|
||
...(state.manuallyEnabledProviders || []),
|
||
];
|
||
|
||
const synced: string[] = [];
|
||
for (const idx of validIndices) {
|
||
const baseUrl = baseUrlsList[idx]!;
|
||
const disabledPos = manuallyDisabledProviders.indexOf(baseUrl);
|
||
if (disabledPos !== -1) {
|
||
manuallyDisabledProviders.splice(disabledPos, 1);
|
||
}
|
||
const enabledPos = manuallyEnabledProviders.indexOf(baseUrl);
|
||
if (enabledPos !== -1) {
|
||
manuallyEnabledProviders.splice(enabledPos, 1);
|
||
}
|
||
synced.push(baseUrl);
|
||
}
|
||
|
||
deps.store.getState().setManuallyDisabledProviders(manuallyDisabledProviders);
|
||
deps.discoveryAdapter.setManuallyDisabledProviders(manuallyDisabledProviders);
|
||
deps.store.getState().setManuallyEnabledProviders(manuallyEnabledProviders);
|
||
deps.discoveryAdapter.setManuallyEnabledProviders(manuallyEnabledProviders);
|
||
|
||
// Recompute the review verdict now that the manual overrides are
|
||
// gone. This is load-bearing, not just cosmetic: the SDK's review
|
||
// sync skips manually-enabled providers when it rebuilds the
|
||
// review-disabled set, so a provider that was manually enabled
|
||
// across a review refresh is absent from that set — reading the
|
||
// stale set here would fail OPEN for a review-rejected provider.
|
||
const reviewed =
|
||
await deps.modelManager.syncReviewedProvidersFromNostr(baseUrlsList);
|
||
if (reviewed !== null) {
|
||
deps.store.getState().setDisabledProviders(reviewed);
|
||
}
|
||
|
||
// Report each synced provider's resulting state from the discovery
|
||
// adapter (the source the router uses). With both manual lists
|
||
// cleared for these URLs, membership in the adapter's disabled set
|
||
// is exactly the review verdict.
|
||
const reviewDisabled = new Set(
|
||
deps.discoveryAdapter.getDisabledProviders() || [],
|
||
);
|
||
const resulting = synced.map((baseUrl) => ({
|
||
baseUrl,
|
||
disabled:
|
||
reviewDisabled.has(baseUrl) ||
|
||
reviewDisabled.has(normalizeProviderBaseUrl(baseUrl)),
|
||
}));
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
output: {
|
||
message: `Reverted ${synced.length} provider(s) to Nostr review-based state`,
|
||
providers: resulting,
|
||
...(skippedIndices.length > 0 ? { skipped: skippedIndices } : {}),
|
||
},
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: String(error) }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Client management endpoints
|
||
if (req.method === "GET" && url.pathname === "/clients") {
|
||
try {
|
||
const clients = getClientsFromStore(deps.store).map((c) => ({
|
||
id: c.clientId,
|
||
name: c.name,
|
||
apiKey: c.apiKey,
|
||
createdAt: c.createdAt,
|
||
lastUsed: c.lastUsed,
|
||
ownerNpub: c.ownerNpub,
|
||
}));
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
output: {
|
||
clients,
|
||
totalCount: clients.length,
|
||
},
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: String(error) }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/clients/add") {
|
||
try {
|
||
const bodyText = await readBody(req);
|
||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||
const name = body.name as string | undefined;
|
||
const explicitId = body.id as string | undefined;
|
||
const ownerNpub = body.ownerNpub as string | undefined;
|
||
|
||
if (!name || typeof name !== "string" || name.trim() === "") {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
error:
|
||
"Missing required 'name' field (must be a non-empty string).",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!explicitId || typeof explicitId !== "string" || explicitId.trim() === "") {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
error:
|
||
"Missing required 'id' field (must be a non-empty string).",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const sanitizeId = (value: string) =>
|
||
value
|
||
.toLowerCase()
|
||
.replace(/\s+/g, "-")
|
||
.replace(/[^a-z0-9-]/g, "");
|
||
|
||
const clientId = sanitizeId(explicitId);
|
||
|
||
if (!clientId) {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
error:
|
||
"Invalid client id. Must contain alphanumeric characters.",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const existingClients = getClientsFromStore(deps.store);
|
||
const existingClient = existingClients.find(
|
||
(c) => c.clientId === clientId,
|
||
);
|
||
|
||
if (existingClient) {
|
||
res.writeHead(409, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
error: `Client with id '${clientId}' already exists.`,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const apiKey = generateApiKey();
|
||
const newClient = {
|
||
clientId,
|
||
name: name.trim(),
|
||
apiKey,
|
||
createdAt: Date.now(),
|
||
...(ownerNpub && typeof ownerNpub === "string" ? { ownerNpub } : {}),
|
||
};
|
||
|
||
deps.store
|
||
.getState()
|
||
.setClientIds((prev: typeof existingClients) => [
|
||
...(prev || []),
|
||
newClient,
|
||
]);
|
||
|
||
logger.log(`Added client '${name}' with id '${clientId}'`);
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
output: {
|
||
message: `Client '${name}' added successfully`,
|
||
client: {
|
||
id: clientId,
|
||
name: name.trim(),
|
||
apiKey,
|
||
createdAt: newClient.createdAt,
|
||
ownerNpub: newClient.ownerNpub,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
respondWithError(res, error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/clients/delete") {
|
||
try {
|
||
const bodyText = await readBody(req);
|
||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||
const id = body.id as string | undefined;
|
||
|
||
if (!id || typeof id !== "string" || id.trim() === "") {
|
||
res.writeHead(400, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
error:
|
||
"Missing required 'id' field (must be a non-empty string).",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const existingClients = getClientsFromStore(deps.store);
|
||
const index = existingClients.findIndex(
|
||
(c) => c.clientId === id,
|
||
);
|
||
|
||
if (index === -1) {
|
||
res.writeHead(404, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
error: `Client with id '${id}' not found.`,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const removedClient = existingClients[index]!;
|
||
const updatedClients = existingClients.filter(
|
||
(_c: unknown, i: number) => i !== index,
|
||
);
|
||
|
||
deps.store.getState().setClientIds(updatedClients);
|
||
|
||
logger.log(
|
||
`Deleted client '${removedClient.name}' with id '${id}'`,
|
||
);
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
output: {
|
||
message: `Client '${removedClient.name}' deleted successfully`,
|
||
id,
|
||
},
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
respondWithError(res, error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/providers") {
|
||
try {
|
||
const forceRefresh =
|
||
url.searchParams.get("refresh")?.toLowerCase() === "true";
|
||
|
||
if (forceRefresh) {
|
||
logger.log("Force-refreshing providers from Nostr and fetching models...");
|
||
await deps.refreshProvidersAndModels();
|
||
}
|
||
|
||
const state = deps.store.getState();
|
||
const baseUrlsList: string[] = state.baseUrlsList || [];
|
||
// Read disable/override state from the discovery adapter (the same
|
||
// source the router/ProviderManager uses), not the mirror in the
|
||
// SdkStore, which can lag behind the scheduled kind-38425 review
|
||
// sync — that updates the adapter without touching the store.
|
||
const manuallyEnabled = new Set<string>(
|
||
(deps.discoveryAdapter.getManuallyEnabledProviders?.() || []) as string[],
|
||
);
|
||
const manuallyDisabled = new Set<string>(
|
||
(deps.discoveryAdapter.getManuallyDisabledProviders?.() || []) as string[],
|
||
);
|
||
const disabledProviders = new Set<string>(
|
||
[
|
||
...((deps.discoveryAdapter.getDisabledProviders() || []) as string[]),
|
||
...manuallyDisabled,
|
||
].filter((url) => !manuallyEnabled.has(url)),
|
||
);
|
||
// Adapter URLs are normalized (trailing slash); the store list may
|
||
// not be. Compare against both forms.
|
||
const inSet = (set: Set<string>, baseUrl: string) =>
|
||
set.has(baseUrl) || set.has(normalizeProviderBaseUrl(baseUrl));
|
||
|
||
const providers = baseUrlsList.map((baseUrl, index) => ({
|
||
index,
|
||
baseUrl,
|
||
disabled: inSet(disabledProviders, baseUrl),
|
||
manuallyDisabled: inSet(manuallyDisabled, baseUrl),
|
||
manuallyEnabled: inSet(manuallyEnabled, baseUrl),
|
||
}));
|
||
|
||
// Only count disabled providers that are actually in the current list
|
||
// (filter out stale entries from previously disabled providers that are no longer present)
|
||
const activeDisabledCount = providers.filter((p) => p.disabled).length;
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
output: {
|
||
providers,
|
||
disabledCount: activeDisabledCount,
|
||
totalCount: baseUrlsList.length,
|
||
},
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: String(error) }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Providers/models the router is currently skipping. Cooldown state lives
|
||
// in the SdkStore (written by the SDK's ProviderManager); entries that have
|
||
// already expired are filtered out by buildCooldownsOutput.
|
||
if (req.method === "GET" && url.pathname === "/cooldowns") {
|
||
await respond(res, async () => {
|
||
const state = deps.store.getState();
|
||
const stored: StoredCooldownEntry[] = state.providersOnCooldown || [];
|
||
return {
|
||
output: buildCooldownsOutput(
|
||
stored,
|
||
deps.providerManager.getCooldownDurationMs(),
|
||
),
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/providers/reviews") {
|
||
try {
|
||
const state = deps.store.getState();
|
||
const baseUrlsList: string[] = state.baseUrlsList || [];
|
||
|
||
// Read disable state from the discovery adapter (the same source the
|
||
// router/ProviderManager uses), not the mirror in the SdkStore, which
|
||
// can lag behind the review sync on warm boots.
|
||
const manuallyEnabled = new Set(
|
||
deps.discoveryAdapter.getManuallyEnabledProviders?.() || [],
|
||
);
|
||
const disabledSet = new Set(
|
||
[
|
||
...(deps.discoveryAdapter.getDisabledProviders() || []),
|
||
...(deps.discoveryAdapter.getManuallyDisabledProviders() || []),
|
||
].filter((u: string) => !manuallyEnabled.has(u)),
|
||
);
|
||
|
||
// url -> set of node pubkeys advertised by that provider's 38421 event(s)
|
||
const providerNodes = new Map<string, Set<string>>();
|
||
// All stored kind 38425 review events
|
||
const reviewEvents: any[] = [];
|
||
|
||
const eventStore = await deps.modelManager.getEventStore();
|
||
if (eventStore) {
|
||
const providerEvents = eventStore.getTimeline({ kinds: [38421] });
|
||
for (const ev of providerEvents) {
|
||
const pubkey = ev.pubkey;
|
||
for (const u of collectProviderUrlsFromEvent(ev)) {
|
||
let set = providerNodes.get(u);
|
||
if (!set) {
|
||
set = new Set<string>();
|
||
providerNodes.set(u, set);
|
||
}
|
||
set.add(pubkey);
|
||
}
|
||
}
|
||
reviewEvents.push(...eventStore.getTimeline({ kinds: [38425] }));
|
||
}
|
||
|
||
const mapReview = (rv: any) => ({
|
||
eventId: rv.id,
|
||
createdAt: rv.created_at,
|
||
authorPubkey: rv.pubkey,
|
||
nodePubkey: tagValue(rv.tags, "node"),
|
||
label: tagValue(rv.tags, "t") ?? "",
|
||
isLgtm:
|
||
(tagValue(rv.tags, "t") ?? "").toLowerCase() === "lgtm",
|
||
tags: rv.tags,
|
||
});
|
||
|
||
const providers = baseUrlsList.map((baseUrl, index) => {
|
||
const normalized = normalizeProviderBaseUrl(baseUrl);
|
||
const nodePubkeys = Array.from(
|
||
providerNodes.get(normalized) || new Set<string>(),
|
||
);
|
||
const reviews = reviewEvents
|
||
.filter((rv) => {
|
||
const node = tagValue(rv.tags, "node");
|
||
return node ? nodePubkeys.includes(node) : false;
|
||
})
|
||
.sort((a, b) => b.created_at - a.created_at)
|
||
.map(mapReview);
|
||
return {
|
||
index,
|
||
baseUrl,
|
||
disabled: disabledSet.has(baseUrl),
|
||
nodePubkeys,
|
||
reviewCount: reviews.length,
|
||
reviews,
|
||
};
|
||
});
|
||
|
||
// Review events that reference a node pubkey we could not map back to
|
||
// any currently-known provider URL (kept for completeness).
|
||
const knownNodePubkeys = new Set<string>();
|
||
for (const set of providerNodes.values()) {
|
||
for (const pk of set) knownNodePubkeys.add(pk);
|
||
}
|
||
const unmatchedReviews = reviewEvents
|
||
.filter((rv) => {
|
||
const node = tagValue(rv.tags, "node");
|
||
return node ? !knownNodePubkeys.has(node) : false;
|
||
})
|
||
.sort((a, b) => b.created_at - a.created_at)
|
||
.map(mapReview);
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
output: {
|
||
providers,
|
||
unmatchedReviews,
|
||
totalCount: baseUrlsList.length,
|
||
reviewEventCount: reviewEvents.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 npubFilter = url.searchParams.get("npub")?.trim();
|
||
const clients = npubFilter ? getClientsFromStore(deps.store) : undefined;
|
||
const clientFilter = npubFilter
|
||
? clients!
|
||
.filter((c) => c.ownerNpub === npubFilter)
|
||
.map((c) => c.clientId)
|
||
: undefined;
|
||
const output = await deps.usageTrackingDriver.list({
|
||
limit: parseLimit(url.searchParams.get("limit")),
|
||
...(clientFilter ? { clients: clientFilter } : {}),
|
||
});
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ output }));
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: toErrorMessage(error) });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/usage/summary") {
|
||
try {
|
||
const tz = Number.parseInt(url.searchParams.get("tz") || "0", 10) || 0;
|
||
const npubFilter = url.searchParams.get("npub")?.trim();
|
||
const clients = getClientsFromStore(deps.store);
|
||
const clientFilter = npubFilter
|
||
? clients
|
||
.filter((c) => c.ownerNpub === npubFilter)
|
||
.map((c) => c.clientId)
|
||
: undefined;
|
||
const summary = await getUsageSummary(deps.usageTrackingDriver, clients, tz, clientFilter);
|
||
sendJson(res, 200, { output: summary });
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: toErrorMessage(error) });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/usagePi") {
|
||
try {
|
||
const timestamp = (url.searchParams.get("timestamp") || "").trim();
|
||
if (!timestamp) {
|
||
sendJson(res, 400, {
|
||
error: "Missing required 'timestamp' query parameter.",
|
||
});
|
||
return;
|
||
}
|
||
|
||
const usageDriver = deps.usageTrackingDriver;
|
||
const limit = parseLimit(url.searchParams.get("limit"));
|
||
const allMatching = await usageDriver.list();
|
||
const requestIdPrefix = `gen-${timestamp}-`;
|
||
const filtered = allMatching.filter((entry) =>
|
||
entry.requestId.startsWith(requestIdPrefix),
|
||
);
|
||
const entries = filtered.slice(0, limit);
|
||
const totalEntries = filtered.length;
|
||
const totalSatsCost = filtered.reduce(
|
||
(sum, entry) => sum + (entry.satsCost || 0),
|
||
0,
|
||
);
|
||
const recentSatsCost = entries.reduce(
|
||
(sum, entry) => sum + (entry.satsCost || 0),
|
||
0,
|
||
);
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
output: {
|
||
entries,
|
||
totalEntries,
|
||
totalSatsCost,
|
||
recentSatsCost,
|
||
limit,
|
||
timestamp,
|
||
},
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
sendJson(res, 500, { error: toErrorMessage(error) });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (
|
||
req.method !== "POST" &&
|
||
!url.pathname.startsWith("/clients") &&
|
||
!url.pathname.startsWith("/keys/api")
|
||
) {
|
||
res.writeHead(405, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: "Only POST is supported." }));
|
||
return;
|
||
}
|
||
|
||
let requestBody: unknown = {};
|
||
try {
|
||
requestBody = await readJsonBody(req);
|
||
} catch (error) {
|
||
sendJson(res, 400, {
|
||
error: "Invalid JSON body.",
|
||
details: toErrorMessage(error),
|
||
});
|
||
return;
|
||
}
|
||
|
||
const bodyObj = requestBody as Record<string, unknown>;
|
||
const modelId = typeof bodyObj.model === "string" ? bodyObj.model : "";
|
||
|
||
if (!modelId) {
|
||
sendJson(res, 400, { error: "Missing required 'model' field." });
|
||
return;
|
||
}
|
||
|
||
// Cap the completion budget when the client does not set an output token
|
||
// limit. Without this, the SDK prices at the provider's worst-case
|
||
// max_completion_cost, which varies widely across providers (2.3× for
|
||
// kimi-k3) and balloons during provider failover. Chat/completions use
|
||
// max_tokens; the OpenAI Responses API uses max_output_tokens. Endpoints
|
||
// that do not define those fields (e.g. /v1/systemone) are forwarded
|
||
// verbatim — injecting one makes a strict upstream answer 400.
|
||
applyDefaultOutputTokenLimit(url.pathname, bodyObj, deps.maxTokens);
|
||
|
||
const forcedProvider: string | undefined =
|
||
url.searchParams.get("provider") ||
|
||
(req.headers["x-routstr-provider"] as string | undefined) ||
|
||
deps.provider ||
|
||
undefined;
|
||
|
||
// Convert req.headers to Record<string, string>
|
||
const incomingHeaders: Record<string, string> = {};
|
||
for (const [key, value] of Object.entries(req.headers)) {
|
||
if (typeof value === "string") {
|
||
incomingHeaders[key] = value;
|
||
} else if (Array.isArray(value) && value.length > 0) {
|
||
incomingHeaders[key] = value[0]!;
|
||
}
|
||
}
|
||
|
||
try {
|
||
await deps.ensureProvidersBootstrapped();
|
||
const reqId = randomBytes(4).toString("hex");
|
||
const reqLogger = makeSdkLogger(`req:${reqId}`, `model:${modelId}`);
|
||
reqLogger.log(`Routing request with path: ${url.pathname}`);
|
||
|
||
const response = await routeRequests({
|
||
modelId,
|
||
requestBody,
|
||
path: url.pathname,
|
||
forcedProvider,
|
||
autoModelPath: deps.autoModelPath === true,
|
||
headers: incomingHeaders,
|
||
walletAdapter: deps.walletAdapter,
|
||
storageAdapter: deps.storageAdapter,
|
||
discoveryAdapter: deps.discoveryAdapter,
|
||
modelManager: deps.modelManager,
|
||
debugLevel: "DEBUG",
|
||
mode: deps.mode,
|
||
usageTrackingDriver: deps.usageTrackingDriver,
|
||
sdkStore: deps.store,
|
||
providerManager: deps.providerManager,
|
||
logger: reqLogger,
|
||
...(deps.requestResponseLogSink
|
||
? { requestResponseLogSink: deps.requestResponseLogSink }
|
||
: {}),
|
||
...(deps.routstrPubkey ? { routstrPubkey: deps.routstrPubkey } : {}),
|
||
...(deps.routstrModelsPubkey
|
||
? { routstrModelsPubkey: deps.routstrModelsPubkey }
|
||
: {}),
|
||
});
|
||
|
||
// Bridge the Web `Response` to the Node `ServerResponse`: status +
|
||
// headers + pipe(body → res). Hop-by-hop headers are dropped (see
|
||
// HOP_BY_HOP_HEADERS) so Node computes correct framing for THIS
|
||
// connection.
|
||
res.statusCode = response.status;
|
||
response.headers.forEach((value, key) => {
|
||
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) return;
|
||
res.setHeader(key, value);
|
||
});
|
||
|
||
const finalize = (response as any).finalize as
|
||
| (() => Promise<number>)
|
||
| undefined;
|
||
|
||
if (!response.body) {
|
||
res.end();
|
||
if (finalize) {
|
||
try {
|
||
await finalize();
|
||
} catch (err) {
|
||
logger.error(`[daemon] finalize error: ${toErrorMessage(err)}`);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const nodeReadable = Readable.fromWeb(response.body as any);
|
||
await new Promise<void>((resolve, reject) => {
|
||
let settled = false;
|
||
const finish = () => {
|
||
if (settled) return;
|
||
settled = true;
|
||
resolve();
|
||
};
|
||
const fail = (err: unknown) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
reject(err);
|
||
};
|
||
res.once("finish", finish);
|
||
res.once("close", finish);
|
||
res.once("error", fail);
|
||
nodeReadable.once("error", fail);
|
||
nodeReadable.pipe(res);
|
||
});
|
||
|
||
if (finalize) {
|
||
try {
|
||
await finalize();
|
||
} catch (err) {
|
||
logger.error(`[daemon] finalize error: ${toErrorMessage(err)}`);
|
||
}
|
||
}
|
||
return;
|
||
} 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 }));
|
||
}
|
||
};
|
||
}
|