routstrd 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,79 @@
1
+ import { randomBytes } from "crypto";
1
2
  import { type IncomingMessage, type ServerResponse } from "http";
2
3
  import {
3
4
  routeRequestsToNodeResponse,
4
5
  InsufficientBalanceError,
6
+ ProviderManager,
5
7
  } from "@routstr/sdk";
6
8
  import type { UsageTrackingDriver } from "@routstr/sdk";
7
9
  import { logger } from "../../utils/logger";
10
+ import {
11
+ CocodHttpError,
12
+ type CocodClient,
13
+ type CocodState,
14
+ } from "../wallet/cocod-client";
15
+ import { decodeCashuTokenAmount } from "../wallet";
16
+
17
+ type ClientMode = "xcashu" | "lazyrefund" | "apikeys";
18
+
19
+ type WalletStatusOutput = {
20
+ daemon: "running";
21
+ wallet: "connected" | "error";
22
+ walletState: CocodState;
23
+ balances?: Record<string, number>;
24
+ mode: ClientMode;
25
+ error?: string;
26
+ };
27
+
28
+ type DaemonDeps = {
29
+ provider: string | null;
30
+ server: { close(cb?: () => void): void };
31
+ store: any;
32
+ walletClient: CocodClient;
33
+ walletAdapter: any;
34
+ storageAdapter: any;
35
+ providerRegistry: any;
36
+ discoveryAdapter: any;
37
+ modelManager: any;
38
+ ensureProvidersBootstrapped: () => Promise<void>;
39
+ getRoutstr21Models: (forceRefresh?: boolean) => Promise<any[]>;
40
+ mode?: ClientMode;
41
+ providerManager: ProviderManager;
42
+ };
43
+
44
+ /**
45
+ * Extracts the client ID from an incoming request by looking up the API key
46
+ * in the store's clientIds list.
47
+ */
48
+ function getClientIdFromRequest(
49
+ req: IncomingMessage,
50
+ store: { getState(): any },
51
+ ): string | undefined {
52
+ const authHeader = req.headers.authorization;
53
+
54
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
55
+ return undefined;
56
+ }
57
+
58
+ const apiKey = authHeader.slice(7); // Remove "Bearer " prefix
59
+
60
+ if (!apiKey.startsWith("sk-")) {
61
+ return undefined;
62
+ }
63
+
64
+ const state = store.getState();
65
+ const clientIds = state.clientIds || [];
66
+
67
+ const matchingClient = (
68
+ clientIds as { clientId: string; apiKey: string }[]
69
+ ).find((c) => c.apiKey === apiKey);
70
+
71
+ return matchingClient?.clientId;
72
+ }
73
+ function generateApiKey(): string {
74
+ const bytes = randomBytes(24);
75
+ return `sk-${bytes.toString("hex")}`;
76
+ }
8
77
 
9
78
  async function readBody(req: IncomingMessage): Promise<string> {
10
79
  return new Promise((resolve, reject) => {
@@ -17,17 +86,190 @@ async function readBody(req: IncomingMessage): Promise<string> {
17
86
  });
18
87
  }
19
88
 
89
+ async function readJsonBody(
90
+ req: IncomingMessage,
91
+ ): Promise<Record<string, unknown>> {
92
+ const bodyText = await readBody(req);
93
+ if (!bodyText) {
94
+ return {};
95
+ }
96
+
97
+ try {
98
+ return JSON.parse(bodyText) as Record<string, unknown>;
99
+ } catch {
100
+ throw new CocodHttpError(400, "Invalid JSON body.");
101
+ }
102
+ }
103
+
20
104
  function parseLimit(value: string | null, fallback = 10): number {
21
105
  const requested = Number.parseInt(value || String(fallback), 10);
22
106
  return Number.isFinite(requested) && requested > 0
23
- ? Math.min(requested, 1000)
107
+ ? Math.min(requested, 100000) // Cap at 100k entries
24
108
  : fallback;
25
109
  }
26
110
 
111
+ function sendJson(
112
+ res: ServerResponse,
113
+ status: number,
114
+ payload: Record<string, unknown>,
115
+ ): void {
116
+ res.writeHead(status, { "Content-Type": "application/json" });
117
+ res.end(JSON.stringify(payload));
118
+ }
119
+
120
+ function toErrorMessage(error: unknown): string {
121
+ return error instanceof Error ? error.message : String(error);
122
+ }
123
+
124
+ function getWalletStateMessage(state: CocodState): string {
125
+ switch (state) {
126
+ case "LOCKED":
127
+ return "Wallet is locked. Unlock it before performing wallet operations.";
128
+ case "UNINITIALIZED":
129
+ return "Wallet is not initialized. Run 'routstrd onboard' first.";
130
+ case "ERROR":
131
+ return "Wallet is in an error state.";
132
+ default:
133
+ return "Wallet is unavailable.";
134
+ }
135
+ }
136
+
137
+ function respondWithError(
138
+ res: ServerResponse,
139
+ error: unknown,
140
+ fallbackStatus = 500,
141
+ ): void {
142
+ if (error instanceof CocodHttpError) {
143
+ sendJson(res, error.status, { error: error.message });
144
+ return;
145
+ }
146
+
147
+ sendJson(res, fallbackStatus, { error: toErrorMessage(error) });
148
+ }
149
+
150
+ async function respond(
151
+ res: ServerResponse,
152
+ getPayload: () => Promise<Record<string, unknown>>,
153
+ ): Promise<void> {
154
+ try {
155
+ sendJson(res, 200, await getPayload());
156
+ } catch (error) {
157
+ respondWithError(res, error);
158
+ }
159
+ }
160
+
161
+ function requireStringField(
162
+ body: Record<string, unknown>,
163
+ field: string,
164
+ ): string | null {
165
+ const value = body[field];
166
+ return typeof value === "string" && value.trim() ? value.trim() : null;
167
+ }
168
+
169
+ function getRequiredStringField(
170
+ body: Record<string, unknown>,
171
+ field: string,
172
+ ): string {
173
+ const value = requireStringField(body, field);
174
+ if (!value) {
175
+ throw new CocodHttpError(400, `Missing required '${field}' field.`);
176
+ }
177
+ return value;
178
+ }
179
+
180
+ function getRequiredPositiveNumberField(
181
+ body: Record<string, unknown>,
182
+ field: string,
183
+ ): number {
184
+ const value = body[field];
185
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
186
+ return value;
187
+ }
188
+ if (typeof value === "string" && value.trim()) {
189
+ const parsed = Number.parseInt(value.trim(), 10);
190
+ if (Number.isFinite(parsed) && parsed > 0) {
191
+ return parsed;
192
+ }
193
+ }
194
+ throw new CocodHttpError(400, `Missing required '${field}' field.`);
195
+ }
196
+
197
+ function optionalStringField(
198
+ body: Record<string, unknown>,
199
+ field: string,
200
+ ): string | undefined {
201
+ const value = body[field];
202
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
203
+ }
204
+
205
+ function getCurrentMode(deps: DaemonDeps): ClientMode {
206
+ const stateMode = deps.store.getState()?.mode;
207
+ return stateMode || deps.mode || "apikeys";
208
+ }
209
+
210
+ async function buildStatusOutput(
211
+ deps: DaemonDeps,
212
+ ): Promise<WalletStatusOutput> {
213
+ const mode = getCurrentMode(deps);
214
+
215
+ try {
216
+ const walletState = await deps.walletClient.getStatus();
217
+ if (walletState !== "UNLOCKED") {
218
+ return {
219
+ daemon: "running",
220
+ wallet: "error",
221
+ walletState,
222
+ mode,
223
+ error: getWalletStateMessage(walletState),
224
+ };
225
+ }
226
+
227
+ const balances = await deps.walletAdapter.getBalances();
228
+ return {
229
+ daemon: "running",
230
+ wallet: "connected",
231
+ walletState,
232
+ balances,
233
+ mode,
234
+ };
235
+ } catch (error) {
236
+ return {
237
+ daemon: "running",
238
+ wallet: "error",
239
+ walletState: "ERROR",
240
+ mode,
241
+ error: toErrorMessage(error),
242
+ };
243
+ }
244
+ }
245
+
246
+ async function buildWalletDetails(deps: DaemonDeps): Promise<{
247
+ state: CocodState;
248
+ ready: boolean;
249
+ balances?: Record<string, number>;
250
+ unit?: "sat";
251
+ activeMint?: string | null;
252
+ }> {
253
+ const state = await deps.walletClient.getStatus();
254
+ if (state !== "UNLOCKED") {
255
+ return { state, ready: false };
256
+ }
257
+
258
+ const balances = await deps.walletAdapter.getBalances();
259
+ return {
260
+ state,
261
+ ready: true,
262
+ balances,
263
+ unit: "sat",
264
+ activeMint: deps.walletAdapter.getActiveMintUrl(),
265
+ };
266
+ }
267
+
27
268
  export function createDaemonRequestHandler(deps: {
28
269
  provider: string | null;
29
270
  server: { close(cb?: () => void): void };
30
271
  store: any;
272
+ walletClient: CocodClient;
31
273
  walletAdapter: any;
32
274
  storageAdapter: any;
33
275
  providerRegistry: any;
@@ -35,56 +277,137 @@ export function createDaemonRequestHandler(deps: {
35
277
  modelManager: any;
36
278
  ensureProvidersBootstrapped: () => Promise<void>;
37
279
  getRoutstr21Models: (forceRefresh?: boolean) => Promise<any[]>;
38
- runWalletCommand: (args: string[]) => Promise<string>;
39
- parseBalances: (output: string) => Record<string, number>;
40
280
  mode?: "xcashu" | "apikeys";
41
281
  usageTrackingDriver: UsageTrackingDriver;
282
+ providerManager: ProviderManager;
42
283
  }) {
43
284
  return async function handler(req: IncomingMessage, res: ServerResponse) {
44
285
  const host = req.headers.host || "localhost";
45
286
  const url = new URL(req.url || "/", `http://${host}`);
46
287
 
47
288
  if (req.method === "GET" && url.pathname === "/health") {
48
- res.writeHead(200, { "Content-Type": "application/json" });
49
- res.end(JSON.stringify({ ok: true }));
289
+ sendJson(res, 200, { ok: true });
50
290
  return;
51
291
  }
52
292
 
53
293
  if (req.method === "GET" && url.pathname === "/ping") {
54
- res.writeHead(200, { "Content-Type": "application/json" });
55
- res.end(JSON.stringify({ output: "pong" }));
294
+ sendJson(res, 200, { output: "pong" });
56
295
  return;
57
296
  }
58
297
 
59
298
  if (req.method === "GET" && url.pathname === "/status") {
60
- try {
61
- const balancesOutput = await deps.runWalletCommand(["balance"]);
62
- const balances = deps.parseBalances(balancesOutput);
63
- const state = deps.store.getState();
64
- const mode = state.mode || deps.mode || "apikeys";
65
- res.writeHead(200, { "Content-Type": "application/json" });
66
- res.end(
67
- JSON.stringify({
68
- output: {
69
- daemon: "running",
70
- wallet: "connected",
71
- mode,
72
- balances,
73
- },
74
- }),
75
- );
76
- } catch (error) {
77
- res.writeHead(200, { "Content-Type": "application/json" });
78
- res.end(
79
- JSON.stringify({
80
- output: {
81
- daemon: "running",
82
- wallet: "error",
83
- error: String(error),
84
- },
85
- }),
86
- );
87
- }
299
+ const output = await buildStatusOutput(deps);
300
+ sendJson(res, 200, { output });
301
+ return;
302
+ }
303
+
304
+ if (req.method === "GET" && url.pathname === "/wallet/status") {
305
+ await respond(res, async () => ({
306
+ output: await buildWalletDetails(deps),
307
+ }));
308
+ return;
309
+ }
310
+
311
+ if (req.method === "POST" && url.pathname === "/wallet/unlock") {
312
+ await respond(res, async () => {
313
+ const body = await readJsonBody(req);
314
+ const passphrase = getRequiredStringField(body, "passphrase");
315
+ const message = await deps.walletClient.unlock(passphrase);
316
+ const state = await deps.walletClient.getStatus();
317
+ return { output: { message, state } };
318
+ });
319
+ return;
320
+ }
321
+
322
+ if (req.method === "GET" && url.pathname === "/wallet/balance") {
323
+ await respond(res, async () => {
324
+ const balances = await deps.walletAdapter.getBalances();
325
+ return {
326
+ output: {
327
+ balances,
328
+ unit: "sat",
329
+ activeMint: deps.walletAdapter.getActiveMintUrl(),
330
+ walletState: "UNLOCKED",
331
+ },
332
+ };
333
+ });
334
+ return;
335
+ }
336
+
337
+ if (req.method === "POST" && url.pathname === "/wallet/receive/cashu") {
338
+ await respond(res, async () => {
339
+ const body = await readJsonBody(req);
340
+ const token = getRequiredStringField(body, "token");
341
+ const message = await deps.walletClient.receiveCashu(token);
342
+ const { amount, unit } = decodeCashuTokenAmount(token);
343
+ return { output: { message, amount, unit } };
344
+ });
345
+ return;
346
+ }
347
+
348
+ if (req.method === "POST" && url.pathname === "/wallet/receive/bolt11") {
349
+ await respond(res, async () => {
350
+ const body = await readJsonBody(req);
351
+ const amount = getRequiredPositiveNumberField(body, "amount");
352
+ const mintUrl = optionalStringField(body, "mintUrl");
353
+ const invoice = await deps.walletClient.receiveBolt11(amount, mintUrl);
354
+ return { output: { invoice, amount, mintUrl } };
355
+ });
356
+ return;
357
+ }
358
+
359
+ if (req.method === "POST" && url.pathname === "/wallet/send/cashu") {
360
+ await respond(res, async () => {
361
+ const body = await readJsonBody(req);
362
+ const amount = getRequiredPositiveNumberField(body, "amount");
363
+ const mintUrl = optionalStringField(body, "mintUrl");
364
+ const token = await deps.walletClient.sendCashu(amount, mintUrl);
365
+ return { output: { token, amount, mintUrl } };
366
+ });
367
+ return;
368
+ }
369
+
370
+ if (req.method === "POST" && url.pathname === "/wallet/send/bolt11") {
371
+ await respond(res, async () => {
372
+ const body = await readJsonBody(req);
373
+ const invoice = getRequiredStringField(body, "invoice");
374
+ const mintUrl = optionalStringField(body, "mintUrl");
375
+ const message = await deps.walletClient.sendBolt11(invoice, mintUrl);
376
+ return { output: { message, invoice, mintUrl } };
377
+ });
378
+ return;
379
+ }
380
+
381
+ if (req.method === "GET" && url.pathname === "/wallet/mints") {
382
+ await respond(res, async () => {
383
+ const mints = await deps.walletClient.listMints();
384
+ return {
385
+ output: {
386
+ mints,
387
+ activeMint: mints[0] || null,
388
+ },
389
+ };
390
+ });
391
+ return;
392
+ }
393
+
394
+ if (req.method === "POST" && url.pathname === "/wallet/mints") {
395
+ await respond(res, async () => {
396
+ const body = await readJsonBody(req);
397
+ const mintUrl = getRequiredStringField(body, "url");
398
+ const message = await deps.walletClient.addMint(mintUrl);
399
+ return { output: { message, url: mintUrl } };
400
+ });
401
+ return;
402
+ }
403
+
404
+ if (req.method === "POST" && url.pathname === "/wallet/mints/info") {
405
+ await respond(res, async () => {
406
+ const body = await readJsonBody(req);
407
+ const mintUrl = getRequiredStringField(body, "url");
408
+ const info = await deps.walletClient.getMintInfo(mintUrl);
409
+ return { output: { url: mintUrl, info } };
410
+ });
88
411
  return;
89
412
  }
90
413
 
@@ -93,11 +416,9 @@ export function createDaemonRequestHandler(deps: {
93
416
  const forceRefresh =
94
417
  url.searchParams.get("refresh")?.toLowerCase() === "true";
95
418
  const models = await deps.getRoutstr21Models(forceRefresh);
96
- res.writeHead(200, { "Content-Type": "application/json" });
97
- res.end(JSON.stringify({ output: { models } }));
419
+ sendJson(res, 200, { output: { models } });
98
420
  } catch (error) {
99
- res.writeHead(500, { "Content-Type": "application/json" });
100
- res.end(JSON.stringify({ error: String(error) }));
421
+ sendJson(res, 500, { error: toErrorMessage(error) });
101
422
  }
102
423
  return;
103
424
  }
@@ -107,23 +428,18 @@ export function createDaemonRequestHandler(deps: {
107
428
  const forceRefresh =
108
429
  url.searchParams.get("refresh")?.toLowerCase() === "true";
109
430
  const models = await deps.getRoutstr21Models(forceRefresh);
110
- res.writeHead(200, { "Content-Type": "application/json" });
111
- res.end(
112
- JSON.stringify({
113
- object: "list",
114
- data: models.map((model) => ({ ...model, object: "model" })),
115
- }),
116
- );
431
+ sendJson(res, 200, {
432
+ object: "list",
433
+ data: models.map((model) => ({ ...model, object: "model" })),
434
+ });
117
435
  } catch (error) {
118
- res.writeHead(500, { "Content-Type": "application/json" });
119
- res.end(JSON.stringify({ error: String(error) }));
436
+ sendJson(res, 500, { error: toErrorMessage(error) });
120
437
  }
121
438
  return;
122
439
  }
123
440
 
124
441
  if (req.method === "POST" && url.pathname === "/stop") {
125
- res.writeHead(200, { "Content-Type": "application/json" });
126
- res.end(JSON.stringify({ output: "stopping" }));
442
+ sendJson(res, 200, { output: "stopping" });
127
443
  setTimeout(() => {
128
444
  deps.server.close(() => {
129
445
  process.exit(0);
@@ -134,17 +450,8 @@ export function createDaemonRequestHandler(deps: {
134
450
 
135
451
  if (req.method === "POST" && url.pathname === "/refund") {
136
452
  try {
137
- const bodyText = await readBody(req);
138
- const body = bodyText ? JSON.parse(bodyText) : {};
139
- const mintUrl = body.mintUrl as string | undefined;
140
-
141
- if (!mintUrl) {
142
- res.writeHead(400, { "Content-Type": "application/json" });
143
- res.end(
144
- JSON.stringify({ error: "Missing required 'mintUrl' field." }),
145
- );
146
- return;
147
- }
453
+ const body = await readJsonBody(req);
454
+ const mintUrl = getRequiredStringField(body, "mintUrl");
148
455
 
149
456
  const state = deps.store.getState();
150
457
  const pendingDistribution = (state.cachedTokens || []).map(
@@ -161,12 +468,9 @@ export function createDaemonRequestHandler(deps: {
161
468
  );
162
469
 
163
470
  if (pendingDistribution.length === 0 && apiKeysStored.length === 0) {
164
- res.writeHead(200, { "Content-Type": "application/json" });
165
- res.end(
166
- JSON.stringify({
167
- output: { message: "No pending tokens to refund", results: [] },
168
- }),
169
- );
471
+ sendJson(res, 200, {
472
+ output: { message: "No pending tokens to refund", results: [] },
473
+ });
170
474
  return;
171
475
  }
172
476
 
@@ -184,29 +488,24 @@ export function createDaemonRequestHandler(deps: {
184
488
  );
185
489
 
186
490
  const spender = client.getCashuSpender();
187
- const results = await spender.refundProviders(mintUrl);
491
+ const results = await spender.refundProviders(mintUrl, true);
188
492
 
189
- res.writeHead(200, { "Content-Type": "application/json" });
190
- res.end(
191
- JSON.stringify({
192
- output: {
193
- message: `Refunded to ${mintUrl}`,
194
- pendingTokens: pendingDistribution.length,
195
- apiKeys: apiKeysStored.length,
196
- results: results.map(
197
- (r: { baseUrl: string; success: boolean }) => ({
198
- baseUrl: r.baseUrl,
199
- success: r.success,
200
- }),
201
- ),
202
- },
203
- }),
204
- );
493
+ sendJson(res, 200, {
494
+ output: {
495
+ message: `Refunded to ${mintUrl}`,
496
+ pendingTokens: pendingDistribution.length,
497
+ apiKeys: apiKeysStored.length,
498
+ results: results.map(
499
+ (r: { baseUrl: string; success: boolean }) => ({
500
+ baseUrl: r.baseUrl,
501
+ success: r.success,
502
+ }),
503
+ ),
504
+ },
505
+ });
205
506
  } catch (error) {
206
- const message = error instanceof Error ? error.message : String(error);
207
- logger.error(`Refund error: ${message}`);
208
- res.writeHead(500, { "Content-Type": "application/json" });
209
- res.end(JSON.stringify({ error: message }));
507
+ logger.error(`Refund error: ${toErrorMessage(error)}`);
508
+ respondWithError(res, error);
210
509
  }
211
510
  return;
212
511
  }
@@ -214,19 +513,15 @@ export function createDaemonRequestHandler(deps: {
214
513
  if (req.method === "GET" && url.pathname === "/balance") {
215
514
  try {
216
515
  const balances = await deps.walletAdapter.getBalances();
217
- res.writeHead(200, { "Content-Type": "application/json" });
218
- res.end(
219
- JSON.stringify({
220
- output: {
221
- balances,
222
- unit: "sat",
223
- activeMint: deps.walletAdapter.getActiveMintUrl(),
224
- },
225
- }),
226
- );
516
+ sendJson(res, 200, {
517
+ output: {
518
+ balances,
519
+ unit: "sat",
520
+ activeMint: deps.walletAdapter.getActiveMintUrl(),
521
+ },
522
+ });
227
523
  } catch (error) {
228
- res.writeHead(500, { "Content-Type": "application/json" });
229
- res.end(JSON.stringify({ error: String(error) }));
524
+ respondWithError(res, error);
230
525
  }
231
526
  return;
232
527
  }
@@ -266,14 +561,66 @@ export function createDaemonRequestHandler(deps: {
266
561
  })),
267
562
  ];
268
563
 
564
+ sendJson(res, 200, {
565
+ output: {
566
+ keys,
567
+ total: totalWallet + totalCached + totalApiKeys,
568
+ unit: "sat",
569
+ apikeysCalled: apiKeys.length,
570
+ },
571
+ });
572
+ } catch (error) {
573
+ res.writeHead(500, { "Content-Type": "application/json" });
574
+ res.end(JSON.stringify({ error: String(error) }));
575
+ }
576
+ return;
577
+ }
578
+
579
+ if (req.method === "POST" && url.pathname === "/providers/disable") {
580
+ try {
581
+ const bodyText = await readBody(req);
582
+ const body = bodyText ? JSON.parse(bodyText) : {};
583
+ const indices = body.indices as number[] | undefined;
584
+
585
+ if (!Array.isArray(indices)) {
586
+ res.writeHead(400, { "Content-Type": "application/json" });
587
+ res.end(
588
+ JSON.stringify({
589
+ error: "Missing or invalid 'indices' field (expected number[]).",
590
+ }),
591
+ );
592
+ return;
593
+ }
594
+
595
+ const state = deps.store.getState();
596
+ const baseUrlsList: string[] = state.baseUrlsList || [];
597
+ const disabledProviders: string[] = [
598
+ ...(state.disabledProviders || []),
599
+ ];
600
+
601
+ const toDisable: string[] = [];
602
+ for (const idx of indices) {
603
+ if (
604
+ typeof idx === "number" &&
605
+ idx >= 0 &&
606
+ idx < baseUrlsList.length
607
+ ) {
608
+ const baseUrl = baseUrlsList[idx]!;
609
+ if (!disabledProviders.includes(baseUrl)) {
610
+ disabledProviders.push(baseUrl);
611
+ toDisable.push(baseUrl);
612
+ }
613
+ }
614
+ }
615
+
616
+ deps.store.getState().setDisabledProviders(disabledProviders);
617
+
269
618
  res.writeHead(200, { "Content-Type": "application/json" });
270
619
  res.end(
271
620
  JSON.stringify({
272
621
  output: {
273
- keys,
274
- total: totalWallet + totalCached + totalApiKeys,
275
- unit: "sat",
276
- apikeysCalled: apiKeys.length,
622
+ message: `Disabled ${toDisable.length} provider(s)`,
623
+ disabled: toDisable,
277
624
  },
278
625
  }),
279
626
  );
@@ -284,6 +631,186 @@ export function createDaemonRequestHandler(deps: {
284
631
  return;
285
632
  }
286
633
 
634
+ if (req.method === "POST" && url.pathname === "/providers/enable") {
635
+ try {
636
+ const bodyText = await readBody(req);
637
+ const body = bodyText ? JSON.parse(bodyText) : {};
638
+ const indices = body.indices as number[] | undefined;
639
+
640
+ if (!Array.isArray(indices)) {
641
+ res.writeHead(400, { "Content-Type": "application/json" });
642
+ res.end(
643
+ JSON.stringify({
644
+ error: "Missing or invalid 'indices' field (expected number[]).",
645
+ }),
646
+ );
647
+ return;
648
+ }
649
+
650
+ const state = deps.store.getState();
651
+ const baseUrlsList: string[] = state.baseUrlsList || [];
652
+ const disabledProviders: string[] = [
653
+ ...(state.disabledProviders || []),
654
+ ];
655
+
656
+ const toEnable: string[] = [];
657
+ for (const idx of indices) {
658
+ if (
659
+ typeof idx === "number" &&
660
+ idx >= 0 &&
661
+ idx < baseUrlsList.length
662
+ ) {
663
+ const baseUrl = baseUrlsList[idx]!;
664
+ const pos = disabledProviders.indexOf(baseUrl);
665
+ if (pos !== -1) {
666
+ disabledProviders.splice(pos, 1);
667
+ toEnable.push(baseUrl);
668
+ }
669
+ }
670
+ }
671
+
672
+ deps.store.getState().setDisabledProviders(disabledProviders);
673
+
674
+ res.writeHead(200, { "Content-Type": "application/json" });
675
+ res.end(
676
+ JSON.stringify({
677
+ output: {
678
+ message: `Enabled ${toEnable.length} provider(s)`,
679
+ enabled: toEnable,
680
+ },
681
+ }),
682
+ );
683
+ } catch (error) {
684
+ res.writeHead(500, { "Content-Type": "application/json" });
685
+ res.end(JSON.stringify({ error: String(error) }));
686
+ }
687
+ return;
688
+ }
689
+
690
+ // Client management endpoints
691
+ if (req.method === "GET" && url.pathname === "/clients") {
692
+ try {
693
+ const state = deps.store.getState();
694
+ const clientIds = state.clientIds || [];
695
+
696
+ const clients = clientIds.map(
697
+ (c: {
698
+ clientId: string;
699
+ name: string;
700
+ apiKey: string;
701
+ createdAt: number;
702
+ lastUsed?: number | null;
703
+ }) => ({
704
+ id: c.clientId,
705
+ name: c.name,
706
+ apiKey: c.apiKey,
707
+ createdAt: c.createdAt,
708
+ lastUsed: c.lastUsed,
709
+ }),
710
+ );
711
+
712
+ res.writeHead(200, { "Content-Type": "application/json" });
713
+ res.end(
714
+ JSON.stringify({
715
+ output: {
716
+ clients,
717
+ totalCount: clients.length,
718
+ },
719
+ }),
720
+ );
721
+ } catch (error) {
722
+ res.writeHead(500, { "Content-Type": "application/json" });
723
+ res.end(JSON.stringify({ error: String(error) }));
724
+ }
725
+ return;
726
+ }
727
+
728
+ if (req.method === "POST" && url.pathname === "/clients/add") {
729
+ try {
730
+ const bodyText = await readBody(req);
731
+ const body = bodyText ? JSON.parse(bodyText) : {};
732
+ const name = body.name as string | undefined;
733
+
734
+ if (!name || typeof name !== "string" || name.trim() === "") {
735
+ res.writeHead(400, { "Content-Type": "application/json" });
736
+ res.end(
737
+ JSON.stringify({
738
+ error:
739
+ "Missing required 'name' field (must be a non-empty string).",
740
+ }),
741
+ );
742
+ return;
743
+ }
744
+
745
+ const clientId = name
746
+ .toLowerCase()
747
+ .replace(/\s+/g, "-")
748
+ .replace(/[^a-z0-9-]/g, "");
749
+
750
+ if (!clientId) {
751
+ res.writeHead(400, { "Content-Type": "application/json" });
752
+ res.end(
753
+ JSON.stringify({
754
+ error:
755
+ "Invalid client name. Must contain alphanumeric characters.",
756
+ }),
757
+ );
758
+ return;
759
+ }
760
+
761
+ const state = deps.store.getState();
762
+ const existingClients = state.clientIds || [];
763
+ const existingClient = existingClients.find(
764
+ (c: { clientId: string }) => c.clientId === clientId,
765
+ );
766
+
767
+ if (existingClient) {
768
+ res.writeHead(409, { "Content-Type": "application/json" });
769
+ res.end(
770
+ JSON.stringify({
771
+ error: `Client with id '${clientId}' already exists.`,
772
+ }),
773
+ );
774
+ return;
775
+ }
776
+
777
+ const apiKey = generateApiKey();
778
+ const newClient = {
779
+ clientId,
780
+ name: name.trim(),
781
+ apiKey,
782
+ createdAt: Date.now(),
783
+ };
784
+
785
+ deps.store
786
+ .getState()
787
+ .setClientIds((prev: typeof existingClients) => [
788
+ ...(prev || []),
789
+ newClient,
790
+ ]);
791
+
792
+ logger.log(`Added client '${name}' with id '${clientId}'`);
793
+
794
+ res.writeHead(200, { "Content-Type": "application/json" });
795
+ res.end(
796
+ JSON.stringify({
797
+ output: {
798
+ message: `Client '${name}' added successfully`,
799
+ client: {
800
+ id: clientId,
801
+ name: name.trim(),
802
+ apiKey,
803
+ createdAt: newClient.createdAt,
804
+ },
805
+ },
806
+ }),
807
+ );
808
+ } catch (error) {
809
+ respondWithError(res, error);
810
+ }
811
+ return;
812
+ }
813
+
287
814
  if (req.method === "GET" && url.pathname === "/providers") {
288
815
  try {
289
816
  const state = deps.store.getState();
@@ -296,12 +823,16 @@ export function createDaemonRequestHandler(deps: {
296
823
  disabled: disabledProviders.includes(baseUrl),
297
824
  }));
298
825
 
826
+ // Only count disabled providers that are actually in the current list
827
+ // (filter out stale entries from previously disabled providers that are no longer present)
828
+ const activeDisabledCount = providers.filter((p) => p.disabled).length;
829
+
299
830
  res.writeHead(200, { "Content-Type": "application/json" });
300
831
  res.end(
301
832
  JSON.stringify({
302
833
  output: {
303
834
  providers,
304
- disabledCount: disabledProviders.length,
835
+ disabledCount: activeDisabledCount,
305
836
  totalCount: baseUrlsList.length,
306
837
  },
307
838
  }),
@@ -424,30 +955,34 @@ export function createDaemonRequestHandler(deps: {
424
955
  return;
425
956
  }
426
957
 
427
- if (req.method === "GET" && url.pathname === "/usage") {
958
+ // Client management endpoints
959
+ if (req.method === "GET" && url.pathname === "/clients") {
428
960
  try {
429
- const usageDriver = deps.usageTrackingDriver;
430
- const limit = parseLimit(url.searchParams.get("limit"));
431
- const entries = await usageDriver.list({ limit });
432
- const totalEntries = await usageDriver.count();
433
- const totalSatsCost = (await usageDriver.list()).reduce(
434
- (sum, entry) => sum + (entry.satsCost || 0),
435
- 0,
436
- );
437
- const recentSatsCost = entries.reduce(
438
- (sum, entry) => sum + (entry.satsCost || 0),
439
- 0,
961
+ const state = deps.store.getState();
962
+ const clientIds = state.clientIds || [];
963
+
964
+ const clients = clientIds.map(
965
+ (c: {
966
+ clientId: string;
967
+ name: string;
968
+ apiKey: string;
969
+ createdAt: number;
970
+ lastUsed?: number | null;
971
+ }) => ({
972
+ id: c.clientId,
973
+ name: c.name,
974
+ apiKey: c.apiKey,
975
+ createdAt: c.createdAt,
976
+ lastUsed: c.lastUsed,
977
+ }),
440
978
  );
441
979
 
442
980
  res.writeHead(200, { "Content-Type": "application/json" });
443
981
  res.end(
444
982
  JSON.stringify({
445
983
  output: {
446
- entries,
447
- totalEntries,
448
- totalSatsCost,
449
- recentSatsCost,
450
- limit,
984
+ clients,
985
+ totalCount: clients.length,
451
986
  },
452
987
  }),
453
988
  );
@@ -458,19 +993,116 @@ export function createDaemonRequestHandler(deps: {
458
993
  return;
459
994
  }
460
995
 
461
- if (req.method === "GET" && url.pathname === "/usagePi") {
996
+ if (req.method === "POST" && url.pathname === "/clients/add") {
462
997
  try {
463
- const timestamp = (url.searchParams.get("timestamp") || "").trim();
464
- if (!timestamp) {
998
+ const bodyText = await readBody(req);
999
+ const body = bodyText ? JSON.parse(bodyText) : {};
1000
+ const name = body.name as string | undefined;
1001
+
1002
+ if (!name || typeof name !== "string" || name.trim() === "") {
1003
+ res.writeHead(400, { "Content-Type": "application/json" });
1004
+ res.end(
1005
+ JSON.stringify({
1006
+ error:
1007
+ "Missing required 'name' field (must be a non-empty string).",
1008
+ }),
1009
+ );
1010
+ return;
1011
+ }
1012
+
1013
+ const clientId = name
1014
+ .toLowerCase()
1015
+ .replace(/\s+/g, "-")
1016
+ .replace(/[^a-z0-9-]/g, "");
1017
+
1018
+ if (!clientId) {
465
1019
  res.writeHead(400, { "Content-Type": "application/json" });
466
1020
  res.end(
467
1021
  JSON.stringify({
468
- error: "Missing required 'timestamp' query parameter.",
1022
+ error:
1023
+ "Invalid client name. Must contain alphanumeric characters.",
1024
+ }),
1025
+ );
1026
+ return;
1027
+ }
1028
+
1029
+ const state = deps.store.getState();
1030
+ const existingClients = state.clientIds || [];
1031
+ const existingClient = existingClients.find(
1032
+ (c: { clientId: string }) => c.clientId === clientId,
1033
+ );
1034
+
1035
+ if (existingClient) {
1036
+ res.writeHead(409, { "Content-Type": "application/json" });
1037
+ res.end(
1038
+ JSON.stringify({
1039
+ error: `Client with id '${clientId}' already exists.`,
469
1040
  }),
470
1041
  );
471
1042
  return;
472
1043
  }
473
1044
 
1045
+ const apiKey = generateApiKey();
1046
+ const newClient = {
1047
+ clientId,
1048
+ name: name.trim(),
1049
+ apiKey,
1050
+ createdAt: Date.now(),
1051
+ };
1052
+
1053
+ deps.store
1054
+ .getState()
1055
+ .setClientIds((prev: typeof existingClients) => [
1056
+ ...(prev || []),
1057
+ newClient,
1058
+ ]);
1059
+
1060
+ logger.log(`Added client '${name}' with id '${clientId}'`);
1061
+
1062
+ res.writeHead(200, { "Content-Type": "application/json" });
1063
+ res.end(
1064
+ JSON.stringify({
1065
+ output: {
1066
+ message: `Client '${name}' added successfully`,
1067
+ client: {
1068
+ id: clientId,
1069
+ name: name.trim(),
1070
+ apiKey,
1071
+ createdAt: newClient.createdAt,
1072
+ },
1073
+ },
1074
+ }),
1075
+ );
1076
+ } catch (error) {
1077
+ res.writeHead(500, { "Content-Type": "application/json" });
1078
+ res.end(JSON.stringify({ error: String(error) }));
1079
+ }
1080
+ return;
1081
+ }
1082
+
1083
+ if (req.method === "GET" && url.pathname === "/usage") {
1084
+ try {
1085
+ const output = await deps.usageTrackingDriver.list({
1086
+ limit: parseLimit(url.searchParams.get("limit")),
1087
+ });
1088
+ res.writeHead(200, { "Content-Type": "application/json" });
1089
+ res.end(JSON.stringify({ output }));
1090
+ } catch (error) {
1091
+ sendJson(res, 500, { error: toErrorMessage(error) });
1092
+ }
1093
+ return;
1094
+ }
1095
+
1096
+ if (req.method === "GET" && url.pathname === "/usagePi") {
1097
+ try {
1098
+ const timestamp = (url.searchParams.get("timestamp") || "").trim();
1099
+ if (!timestamp) {
1100
+ sendJson(res, 400, {
1101
+ error: "Missing required 'timestamp' query parameter.",
1102
+ });
1103
+ return;
1104
+ }
1105
+
474
1106
  const usageDriver = deps.usageTrackingDriver;
475
1107
  const limit = parseLimit(url.searchParams.get("limit"));
476
1108
  const allMatching = await usageDriver.list();
@@ -503,13 +1135,13 @@ export function createDaemonRequestHandler(deps: {
503
1135
  }),
504
1136
  );
505
1137
  } catch (error) {
506
- res.writeHead(500, { "Content-Type": "application/json" });
507
- res.end(JSON.stringify({ error: String(error) }));
1138
+ sendJson(res, 500, { error: toErrorMessage(error) });
508
1139
  }
509
1140
  return;
510
1141
  }
511
1142
 
512
- if (req.method !== "POST") {
1143
+ // Allow client management endpoints through
1144
+ if (req.method !== "POST" && !url.pathname.startsWith("/clients")) {
513
1145
  res.writeHead(405, { "Content-Type": "application/json" });
514
1146
  res.end(JSON.stringify({ error: "Only POST is supported." }));
515
1147
  return;
@@ -517,16 +1149,12 @@ export function createDaemonRequestHandler(deps: {
517
1149
 
518
1150
  let requestBody: unknown = {};
519
1151
  try {
520
- const bodyText = await readBody(req);
521
- requestBody = bodyText ? JSON.parse(bodyText) : {};
1152
+ requestBody = await readJsonBody(req);
522
1153
  } catch (error) {
523
- res.writeHead(400, { "Content-Type": "application/json" });
524
- res.end(
525
- JSON.stringify({
526
- error: "Invalid JSON body.",
527
- details: error instanceof Error ? error.message : String(error),
528
- }),
529
- );
1154
+ sendJson(res, 400, {
1155
+ error: "Invalid JSON body.",
1156
+ details: toErrorMessage(error),
1157
+ });
530
1158
  return;
531
1159
  }
532
1160
 
@@ -534,8 +1162,7 @@ export function createDaemonRequestHandler(deps: {
534
1162
  const modelId = typeof bodyObj.model === "string" ? bodyObj.model : "";
535
1163
 
536
1164
  if (!modelId) {
537
- res.writeHead(400, { "Content-Type": "application/json" });
538
- res.end(JSON.stringify({ error: "Missing required 'model' field." }));
1165
+ sendJson(res, 400, { error: "Missing required 'model' field." });
539
1166
  return;
540
1167
  }
541
1168
 
@@ -573,6 +1200,7 @@ export function createDaemonRequestHandler(deps: {
573
1200
  mode: deps.mode,
574
1201
  usageTrackingDriver: deps.usageTrackingDriver,
575
1202
  sdkStore: deps.store,
1203
+ providerManager: deps.providerManager,
576
1204
  res,
577
1205
  });
578
1206
  return;