@tokenbuddy/tokenbuddy 1.0.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.
Files changed (43) hide show
  1. package/bin/tb-proxyd.js +2 -0
  2. package/bin/tb.js +3 -0
  3. package/bin/tokenbuddy-proxyd.js +2 -0
  4. package/bin/tokenbuddy.js +3 -0
  5. package/dist/src/buyer-store.d.ts +118 -0
  6. package/dist/src/buyer-store.d.ts.map +1 -0
  7. package/dist/src/buyer-store.js +296 -0
  8. package/dist/src/buyer-store.js.map +1 -0
  9. package/dist/src/cli.d.ts +3 -0
  10. package/dist/src/cli.d.ts.map +1 -0
  11. package/dist/src/cli.js +648 -0
  12. package/dist/src/cli.js.map +1 -0
  13. package/dist/src/daemon.d.ts +48 -0
  14. package/dist/src/daemon.d.ts.map +1 -0
  15. package/dist/src/daemon.js +998 -0
  16. package/dist/src/daemon.js.map +1 -0
  17. package/dist/src/index.d.ts +2 -0
  18. package/dist/src/index.d.ts.map +1 -0
  19. package/dist/src/index.js +12 -0
  20. package/dist/src/index.js.map +1 -0
  21. package/dist/src/provider-install.d.ts +44 -0
  22. package/dist/src/provider-install.d.ts.map +1 -0
  23. package/dist/src/provider-install.js +286 -0
  24. package/dist/src/provider-install.js.map +1 -0
  25. package/dist/src/tb-proxyd.d.ts +2 -0
  26. package/dist/src/tb-proxyd.d.ts.map +1 -0
  27. package/dist/src/tb-proxyd.js +54 -0
  28. package/dist/src/tb-proxyd.js.map +1 -0
  29. package/dist/src/terminal-detect.d.ts +29 -0
  30. package/dist/src/terminal-detect.d.ts.map +1 -0
  31. package/dist/src/terminal-detect.js +209 -0
  32. package/dist/src/terminal-detect.js.map +1 -0
  33. package/package.json +29 -0
  34. package/src/buyer-store.ts +536 -0
  35. package/src/cli.ts +732 -0
  36. package/src/daemon.ts +1158 -0
  37. package/src/index.ts +12 -0
  38. package/src/provider-install.ts +363 -0
  39. package/src/tb-proxyd.ts +60 -0
  40. package/src/terminal-detect.ts +225 -0
  41. package/tests/e2e.test.ts +264 -0
  42. package/tests/tokenbuddy.test.ts +1186 -0
  43. package/tsconfig.json +8 -0
package/src/daemon.ts ADDED
@@ -0,0 +1,1158 @@
1
+ import express, { Request, Response } from "express";
2
+ import * as crypto from "crypto";
3
+ import { spawn } from "child_process";
4
+ import * as fs from "fs";
5
+ import { AddressInfo } from "net";
6
+ import { createModuleLogger } from "@tokenbuddy/logging";
7
+ import { BuyerStore } from "./buyer-store.js";
8
+ import {
9
+ applyProviderInstall,
10
+ detectProviders,
11
+ previewProviderInstall,
12
+ rollbackProviderInstall
13
+ } from "./provider-install.js";
14
+
15
+ const logger = createModuleLogger("tb-proxyd");
16
+ const PROXY_JSON_BODY_LIMIT = "10mb";
17
+
18
+ export interface DaemonConfig {
19
+ controlPort: number;
20
+ proxyPort: number;
21
+ dbPath: string;
22
+ sellerRegistryUrl: string;
23
+ selectionMode?: "auto" | "manual";
24
+ }
25
+
26
+ interface RegistrySeller {
27
+ id: string;
28
+ name?: string;
29
+ url: string;
30
+ supportedProtocols?: string[];
31
+ paymentMethods?: string[];
32
+ }
33
+
34
+ interface SellerRegistryDocument {
35
+ version: number;
36
+ defaultSeller?: string;
37
+ sellers: RegistrySeller[];
38
+ }
39
+
40
+ interface SellerManifest {
41
+ sellerId?: string;
42
+ seller_id?: string;
43
+ supportedProtocols?: string[];
44
+ supported_protocols?: string[];
45
+ paymentMethods?: string[];
46
+ payment_methods?: string[];
47
+ models?: Array<{ id: string; [key: string]: unknown }>;
48
+ }
49
+
50
+ interface SellerRoute {
51
+ seller: RegistrySeller;
52
+ manifest: SellerManifest;
53
+ protocol: string;
54
+ modelId: string;
55
+ paymentMethod: string;
56
+ }
57
+
58
+ interface UsageSummary {
59
+ promptTokens: number;
60
+ completionTokens: number;
61
+ billedMicros: number;
62
+ }
63
+
64
+ interface PurchaseCreateResponse {
65
+ purchaseId?: string;
66
+ purchase_id?: string;
67
+ status?: string;
68
+ creditMicros?: number;
69
+ credit_micros?: number;
70
+ currency?: string;
71
+ paymentReference?: string;
72
+ payment_reference?: string;
73
+ expiresAt?: string;
74
+ expires_at?: string;
75
+ quote?: unknown;
76
+ paymentInstructions?: unknown;
77
+ payment_instructions?: unknown;
78
+ error?: { message?: string };
79
+ }
80
+
81
+ interface PurchaseCompleteResponse extends PurchaseCreateResponse {
82
+ accessToken?: string;
83
+ access_token?: string;
84
+ tokenClass?: string;
85
+ token_class?: string;
86
+ }
87
+
88
+ export class TokenbuddyDaemon {
89
+ private config: DaemonConfig;
90
+ private tokenStore: BuyerStore;
91
+ private controlServer?: any;
92
+ private proxyServer?: any;
93
+ private selectionMode: "auto" | "manual";
94
+
95
+ private activePurchases = new Map<string, Promise<string>>();
96
+
97
+ constructor(config: DaemonConfig) {
98
+ this.config = config;
99
+ this.selectionMode = config.selectionMode || "auto";
100
+ this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
101
+ }
102
+
103
+ private activeControlPort(): number {
104
+ const address = this.controlServer?.address?.() as AddressInfo | string | null | undefined;
105
+ return typeof address === "object" && address ? address.port : this.config.controlPort;
106
+ }
107
+
108
+ private activeProxyPort(): number {
109
+ const address = this.proxyServer?.address?.() as AddressInfo | string | null | undefined;
110
+ return typeof address === "object" && address ? address.port : this.config.proxyPort;
111
+ }
112
+
113
+ private async fetchRegistry(): Promise<SellerRegistryDocument> {
114
+ const response = await fetch(this.config.sellerRegistryUrl);
115
+ if (!response.ok) {
116
+ throw new Error(`registry returned ${response.status}`);
117
+ }
118
+ const data = await response.json() as SellerRegistryDocument;
119
+ if (!data || !Array.isArray(data.sellers)) {
120
+ throw new Error("registry response missing sellers");
121
+ }
122
+ return data;
123
+ }
124
+
125
+ private async fetchSellerManifest(seller: RegistrySeller): Promise<SellerManifest> {
126
+ const baseUrl = seller.url.replace(/\/+$/, "");
127
+ const response = await fetch(`${baseUrl}/manifest`);
128
+ if (!response.ok) {
129
+ throw new Error(`manifest returned ${response.status}`);
130
+ }
131
+ return await response.json() as SellerManifest;
132
+ }
133
+
134
+ private runtimeSummary() {
135
+ return {
136
+ status: "running",
137
+ pid: process.pid,
138
+ controlPort: this.activeControlPort(),
139
+ proxyPort: this.activeProxyPort(),
140
+ selectionMode: this.selectionMode,
141
+ dbPath: this.config.dbPath,
142
+ sellerRegistryUrl: this.config.sellerRegistryUrl,
143
+ store: this.tokenStore.summary()
144
+ };
145
+ }
146
+
147
+ private normalizeSellerUrl(seller: RegistrySeller): string {
148
+ return seller.url.replace(/\/+$/, "");
149
+ }
150
+
151
+ private endpointProtocol(endpoint: string): string | undefined {
152
+ if (endpoint === "/v1/chat/completions") {
153
+ return "chat_completions";
154
+ }
155
+ if (endpoint === "/v1/responses") {
156
+ return "responses";
157
+ }
158
+ if (endpoint === "/v1/messages" || endpoint === "/messages") {
159
+ return "messages";
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ private extractModelId(endpoint: string, body: unknown): string | undefined {
165
+ if (body && typeof body === "object" && "model" in body) {
166
+ const model = (body as { model?: unknown }).model;
167
+ return typeof model === "string" && model.trim() ? model.trim() : undefined;
168
+ }
169
+ if (endpoint === "/v1/responses" && body && typeof body === "object" && "model_id" in body) {
170
+ const model = (body as { model_id?: unknown }).model_id;
171
+ return typeof model === "string" && model.trim() ? model.trim() : undefined;
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ private manifestProtocols(manifest: SellerManifest, seller: RegistrySeller): string[] {
177
+ const protocols = manifest.supportedProtocols || manifest.supported_protocols || seller.supportedProtocols || [];
178
+ return protocols.includes("anthropic_messages") && !protocols.includes("messages")
179
+ ? [...protocols, "messages"]
180
+ : protocols;
181
+ }
182
+
183
+ private manifestPaymentMethods(manifest: SellerManifest, seller: RegistrySeller): string[] {
184
+ return manifest.paymentMethods || manifest.payment_methods || seller.paymentMethods || [];
185
+ }
186
+
187
+ private manifestModelIds(manifest: SellerManifest): string[] {
188
+ return (manifest.models || []).map((model) => model.id).filter((id) => typeof id === "string" && id.length > 0);
189
+ }
190
+
191
+ private defaultPaymentMethod(): string | undefined {
192
+ const payments = this.tokenStore.listPayments().filter((payment) => payment.enabled);
193
+ return payments.find((payment) => payment.isDefault)?.method || payments.find((payment) => payment.method === "mock")?.method;
194
+ }
195
+
196
+ private async selectSellerRoutes(endpoint: string, modelId: string): Promise<SellerRoute[]> {
197
+ const protocol = this.endpointProtocol(endpoint);
198
+ if (!protocol) {
199
+ throw new Error(`unsupported proxy endpoint: ${endpoint}`);
200
+ }
201
+ const paymentMethod = this.defaultPaymentMethod();
202
+ if (!paymentMethod || !["mock", "clawtip"].includes(paymentMethod)) {
203
+ throw new Error("mock or clawtip payment method is not configured as an enabled buyer payment method");
204
+ }
205
+
206
+ const registry = await this.fetchRegistry();
207
+ const defaultSellers = registry.sellers.filter((seller) => seller.id === registry.defaultSeller);
208
+ const backupSellers = registry.sellers.filter((seller) => seller.id !== registry.defaultSeller);
209
+ const sellers = this.selectionMode === "manual" ? defaultSellers : [...defaultSellers, ...backupSellers];
210
+
211
+ const routes: SellerRoute[] = [];
212
+ for (const seller of sellers) {
213
+ let manifest: SellerManifest;
214
+ try {
215
+ manifest = await this.fetchSellerManifest(seller);
216
+ } catch (error: unknown) {
217
+ logger.warn("route.manifest.failed", "seller manifest unavailable during route selection", {
218
+ sellerKey: seller.id,
219
+ model: modelId,
220
+ endpoint,
221
+ errorMessage: error instanceof Error ? error.message : String(error)
222
+ });
223
+ continue;
224
+ }
225
+
226
+ const protocols = this.manifestProtocols(manifest, seller);
227
+ const paymentMethods = this.manifestPaymentMethods(manifest, seller);
228
+ const modelIds = this.manifestModelIds(manifest);
229
+ if (!protocols.includes(protocol) || !paymentMethods.includes(paymentMethod) || !modelIds.includes(modelId)) {
230
+ continue;
231
+ }
232
+
233
+ routes.push({
234
+ seller,
235
+ manifest,
236
+ protocol,
237
+ modelId,
238
+ paymentMethod
239
+ });
240
+ }
241
+
242
+ if (routes.length === 0) {
243
+ throw new Error(`no compatible seller for ${endpoint} model ${modelId}`);
244
+ }
245
+
246
+ logger.info("route.candidates.prewarmed", "seller route candidates prewarmed", {
247
+ model: modelId,
248
+ endpoint,
249
+ protocol,
250
+ paymentMethod,
251
+ selectionMode: this.selectionMode,
252
+ sellerCount: routes.length,
253
+ sellers: routes.map((route) => route.seller.id)
254
+ });
255
+ return routes;
256
+ }
257
+
258
+ private logRouteSelected(route: SellerRoute, endpoint: string, routeIndex: number): void {
259
+ logger.info("route.selected", "seller route selected", {
260
+ sellerKey: route.seller.id,
261
+ model: route.modelId,
262
+ endpoint,
263
+ protocol: route.protocol,
264
+ paymentMethod: route.paymentMethod,
265
+ routeIndex,
266
+ backup: routeIndex > 0
267
+ });
268
+ }
269
+
270
+ private shouldFailoverStatus(status: number): boolean {
271
+ return status === 429 || status >= 500;
272
+ }
273
+
274
+ private logFailover(
275
+ route: SellerRoute,
276
+ endpoint: string,
277
+ routeIndex: number,
278
+ reason: string,
279
+ status?: number
280
+ ): void {
281
+ logger.warn("route.failover.triggered", "seller route failed over to backup candidate", {
282
+ sellerKey: route.seller.id,
283
+ model: route.modelId,
284
+ endpoint,
285
+ routeIndex,
286
+ reason,
287
+ status
288
+ });
289
+ }
290
+
291
+ private failoverErrorMessage(error: unknown): string {
292
+ return error instanceof Error ? error.message : String(error);
293
+ }
294
+
295
+ private async selectSeller(endpoint: string, modelId: string): Promise<SellerRoute> {
296
+ const routes = await this.selectSellerRoutes(endpoint, modelId);
297
+ const route = routes[0];
298
+ this.logRouteSelected(route, endpoint, 0);
299
+ return route;
300
+ }
301
+
302
+ private async listSellerBackedModels(): Promise<{
303
+ models: Array<{ id: string; sellerId: string; sellerName?: string; sellerUrl: string; supportedProtocols: string[]; paymentMethods: string[] }>;
304
+ sellers: Array<{ id: string; name?: string; url: string; status: string; manifestSellerId?: string; errorMessage?: string }>;
305
+ }> {
306
+ const registry = await this.fetchRegistry();
307
+ const sellerResults = await Promise.all(registry.sellers.map(async (seller) => {
308
+ try {
309
+ const manifest = await this.fetchSellerManifest(seller);
310
+ const protocols = this.manifestProtocols(manifest, seller);
311
+ const paymentMethods = this.manifestPaymentMethods(manifest, seller);
312
+ const models = (manifest.models || []).map((model) => ({
313
+ id: model.id,
314
+ sellerId: seller.id,
315
+ sellerName: seller.name,
316
+ sellerUrl: seller.url,
317
+ supportedProtocols: protocols,
318
+ paymentMethods
319
+ }));
320
+ return {
321
+ seller: {
322
+ id: seller.id,
323
+ name: seller.name,
324
+ url: seller.url,
325
+ status: "ok",
326
+ manifestSellerId: manifest.sellerId || manifest.seller_id || seller.id
327
+ },
328
+ models
329
+ };
330
+ } catch (error: unknown) {
331
+ const errorMessage = error instanceof Error ? error.message : String(error);
332
+ logger.warn("models.refresh.seller_failed", "seller manifest refresh failed", {
333
+ sellerId: seller.id,
334
+ errorMessage
335
+ });
336
+ return {
337
+ seller: {
338
+ id: seller.id,
339
+ name: seller.name,
340
+ url: seller.url,
341
+ status: "failed",
342
+ errorMessage
343
+ },
344
+ models: []
345
+ };
346
+ }
347
+ }));
348
+ return {
349
+ models: sellerResults.flatMap((result) => result.models),
350
+ sellers: sellerResults.map((result) => result.seller)
351
+ };
352
+ }
353
+
354
+ private readUsage(bodyText: string): UsageSummary {
355
+ const fallback: UsageSummary = {
356
+ promptTokens: 0,
357
+ completionTokens: 0,
358
+ billedMicros: 0
359
+ };
360
+ if (!bodyText.trim()) {
361
+ return fallback;
362
+ }
363
+ try {
364
+ const data = JSON.parse(bodyText) as {
365
+ usage?: {
366
+ prompt_tokens?: number;
367
+ completion_tokens?: number;
368
+ input_tokens?: number;
369
+ output_tokens?: number;
370
+ };
371
+ };
372
+ const promptTokens = data.usage?.prompt_tokens ?? data.usage?.input_tokens ?? 0;
373
+ const completionTokens = data.usage?.completion_tokens ?? data.usage?.output_tokens ?? 0;
374
+ return {
375
+ promptTokens,
376
+ completionTokens,
377
+ billedMicros: (promptTokens + completionTokens) * 4
378
+ };
379
+ } catch {
380
+ return fallback;
381
+ }
382
+ }
383
+
384
+ private inferPromptForHash(body: unknown): string | undefined {
385
+ if (!body || typeof body !== "object") {
386
+ return undefined;
387
+ }
388
+ const compact = JSON.stringify(body);
389
+ return compact.length > 0 ? compact : undefined;
390
+ }
391
+
392
+ private autoPurchaseAmountUsdMicros(): number {
393
+ const raw = process.env.TB_PROXYD_AUTO_PURCHASE_AMOUNT_USD_MICROS;
394
+ const parsed = raw ? Number(raw) : 2000000;
395
+ if (!Number.isInteger(parsed) || parsed < 1) {
396
+ return 2000000;
397
+ }
398
+ return parsed;
399
+ }
400
+
401
+ private tokenRebuyMinBalanceMicros(): number {
402
+ const raw = process.env.TB_PROXYD_TOKEN_REBUY_MIN_BALANCE_MICROS;
403
+ const parsed = raw ? Number(raw) : 200000;
404
+ if (!Number.isInteger(parsed) || parsed < 0) {
405
+ return 200000;
406
+ }
407
+ return parsed;
408
+ }
409
+
410
+ private async getOrPurchaseToken(route: SellerRoute): Promise<string> {
411
+ const sellerKey = route.seller.id;
412
+ const sellerUrl = this.normalizeSellerUrl(route.seller);
413
+ const { modelId, paymentMethod } = route;
414
+ const cached = this.tokenStore.getToken(sellerKey);
415
+ const rebuyMinBalanceMicros = this.tokenRebuyMinBalanceMicros();
416
+ if (cached && cached.balanceMicros > rebuyMinBalanceMicros) {
417
+ logger.info("token.cache.hit", "seller token cache hit", {
418
+ sellerKey,
419
+ model: modelId,
420
+ balanceMicros: cached.balanceMicros,
421
+ rebuyMinBalanceMicros
422
+ });
423
+ return cached.token;
424
+ }
425
+ logger.info("token.cache.miss", "seller token cache miss", {
426
+ sellerKey,
427
+ model: modelId,
428
+ balanceMicros: cached?.balanceMicros || 0,
429
+ rebuyMinBalanceMicros
430
+ });
431
+
432
+ const purchaseKey = `${sellerKey}:${modelId}:${paymentMethod}`;
433
+ const purchasePromise = this.activePurchases.get(purchaseKey);
434
+ if (purchasePromise) {
435
+ logger.info("purchase.lock.awaited", "parallel request awaiting active purchase", {
436
+ sellerKey,
437
+ model: modelId
438
+ });
439
+ return purchasePromise;
440
+ }
441
+
442
+ const purchaseTask = (async () => {
443
+ const startedAt = Date.now();
444
+ const amountUsdMicros = this.autoPurchaseAmountUsdMicros();
445
+ logger.info("purchase.token.started", "seller token purchase started", {
446
+ sellerKey,
447
+ model: modelId,
448
+ paymentMethod,
449
+ amountUsdMicros
450
+ });
451
+ try {
452
+ // 1. purchase/create
453
+ const createRes = await fetch(`${sellerUrl}/purchase/create`, {
454
+ method: "POST",
455
+ headers: { "Content-Type": "application/json" },
456
+ body: JSON.stringify({
457
+ amountUsdMicros,
458
+ currency: "USD",
459
+ requestKey: `pur_req_${crypto.randomBytes(8).toString("hex")}`,
460
+ paymentMethod,
461
+ modelId
462
+ })
463
+ });
464
+ const createData = await createRes.json() as PurchaseCreateResponse;
465
+ if (!createRes.ok) {
466
+ logger.warn("purchase.create.failed", "seller purchase create failed", {
467
+ sellerKey,
468
+ model: modelId,
469
+ status: createRes.status,
470
+ errorMessage: createData.error?.message || "purchase/create failed",
471
+ durationMs: Date.now() - startedAt
472
+ });
473
+ throw new Error(createData.error?.message || "purchase/create failed");
474
+ }
475
+ const purchaseId = createData.purchaseId || createData.purchase_id;
476
+ if (!purchaseId) {
477
+ throw new Error("purchase/create response missing purchaseId");
478
+ }
479
+ this.tokenStore.upsertPendingPurchase({
480
+ purchaseId,
481
+ sellerKey,
482
+ modelId,
483
+ paymentMethod,
484
+ amountUsdMicros,
485
+ status: createData.status || "pending",
486
+ paymentReference: createData.paymentReference || createData.payment_reference,
487
+ expiresAt: createData.expiresAt || createData.expires_at
488
+ });
489
+ logger.info("purchase.create.succeeded", "seller purchase created", {
490
+ sellerKey,
491
+ model: modelId,
492
+ purchaseId,
493
+ status: createRes.status
494
+ });
495
+
496
+ const paymentProof = await this.resolvePaymentProof(route, createData);
497
+ const completeRes = await fetch(`${sellerUrl}/purchase/complete`, {
498
+ method: "POST",
499
+ headers: { "Content-Type": "application/json" },
500
+ body: JSON.stringify({
501
+ purchaseId,
502
+ paymentProof,
503
+ requestKey: `comp_req_${crypto.randomBytes(8).toString("hex")}`,
504
+ paymentMethod
505
+ })
506
+ });
507
+ const completeData = await completeRes.json() as PurchaseCompleteResponse;
508
+ if (!completeRes.ok) {
509
+ logger.warn("purchase.complete.failed", "seller purchase complete failed", {
510
+ sellerKey,
511
+ model: modelId,
512
+ purchaseId,
513
+ status: completeRes.status,
514
+ errorMessage: completeData.error?.message || "purchase/complete failed",
515
+ durationMs: Date.now() - startedAt
516
+ });
517
+ throw new Error(completeData.error?.message || "purchase/complete failed");
518
+ }
519
+
520
+ const token = completeData.accessToken || completeData.access_token;
521
+ if (!token) {
522
+ throw new Error("purchase/complete response missing accessToken");
523
+ }
524
+ const tokenClass = completeData.tokenClass || completeData.token_class || `model:${modelId}`;
525
+ const creditMicros = completeData.creditMicros ?? completeData.credit_micros ?? createData.creditMicros ?? createData.credit_micros ?? 0;
526
+ const currency = completeData.currency || createData.currency || "USD";
527
+ const expiresAt = new Date(Date.now() + 86400 * 1000).toISOString();
528
+
529
+ this.tokenStore.saveToken(sellerKey, token, tokenClass, creditMicros, expiresAt);
530
+ this.tokenStore.recordPurchaseLedger({
531
+ purchaseId,
532
+ sellerKey,
533
+ modelId,
534
+ paymentMethod,
535
+ status: completeData.status || "funded",
536
+ creditMicros,
537
+ currency,
538
+ paymentReference: completeData.paymentReference || completeData.payment_reference,
539
+ completedAt: new Date().toISOString()
540
+ });
541
+ logger.info("purchase.token.succeeded", "seller token purchased", {
542
+ sellerKey,
543
+ model: modelId,
544
+ purchaseId,
545
+ tokenClass,
546
+ creditMicros,
547
+ durationMs: Date.now() - startedAt
548
+ });
549
+
550
+ return token;
551
+ } catch (error: unknown) {
552
+ logger.error("purchase.token.failed", "seller token purchase failed", {
553
+ sellerKey,
554
+ model: modelId,
555
+ errorMessage: error instanceof Error ? error.message : String(error),
556
+ durationMs: Date.now() - startedAt
557
+ });
558
+ throw error;
559
+ } finally {
560
+ this.activePurchases.delete(purchaseKey);
561
+ }
562
+ })();
563
+
564
+ this.activePurchases.set(purchaseKey, purchaseTask);
565
+ return purchaseTask;
566
+ }
567
+
568
+ private async resolvePaymentProof(route: SellerRoute, createData: PurchaseCreateResponse): Promise<string> {
569
+ if (route.paymentMethod === "mock") {
570
+ return "mock-proof-data";
571
+ }
572
+
573
+ if (route.paymentMethod !== "clawtip") {
574
+ throw new Error(`unsupported payment method for auto purchase: ${route.paymentMethod}`);
575
+ }
576
+
577
+ const proofCommand = process.env.TB_PROXYD_CLAWTIP_PROOF_COMMAND;
578
+ if (proofCommand?.trim()) {
579
+ return await this.runClawtipProofCommand(route, createData, proofCommand.trim());
580
+ }
581
+
582
+ const proofFile = process.env.TOKENBUDDY_CLAWTIP_PAYMENT_PROOF_FILE || process.env.TOKENBUDDY_CLAWTIP_PROOF_FILE;
583
+ if (proofFile?.trim()) {
584
+ return fs.readFileSync(proofFile.trim(), "utf8").trim();
585
+ }
586
+
587
+ const proof = process.env.TOKENBUDDY_CLAWTIP_PAYMENT_PROOF;
588
+ if (proof?.trim()) {
589
+ return proof.trim();
590
+ }
591
+
592
+ throw new Error("clawtip auto purchase requires TB_PROXYD_CLAWTIP_PROOF_COMMAND or a ClawTip proof env/file");
593
+ }
594
+
595
+ private runClawtipProofCommand(
596
+ route: SellerRoute,
597
+ createData: PurchaseCreateResponse,
598
+ commandPath: string
599
+ ): Promise<string> {
600
+ const timeoutMs = this.clawtipProofTimeoutMs();
601
+ const payload = JSON.stringify({
602
+ sellerKey: route.seller.id,
603
+ sellerUrl: this.normalizeSellerUrl(route.seller),
604
+ modelId: route.modelId,
605
+ purchase: createData,
606
+ paymentInstructions: createData.paymentInstructions || createData.payment_instructions,
607
+ quote: createData.quote
608
+ });
609
+
610
+ logger.info("purchase.clawtip_proof.started", "clawtip proof provider started", {
611
+ sellerKey: route.seller.id,
612
+ model: route.modelId,
613
+ timeoutMs
614
+ });
615
+
616
+ return new Promise((resolve, reject) => {
617
+ const child = spawn(commandPath, [], {
618
+ stdio: ["pipe", "pipe", "pipe"],
619
+ env: {
620
+ ...process.env,
621
+ TB_PROXYD_CLAWTIP_SELLER_KEY: route.seller.id,
622
+ TB_PROXYD_CLAWTIP_MODEL_ID: route.modelId
623
+ }
624
+ });
625
+ let stdout = "";
626
+ let stderr = "";
627
+ let settled = false;
628
+ const startedAt = Date.now();
629
+ const timer = setTimeout(() => {
630
+ if (settled) {
631
+ return;
632
+ }
633
+ settled = true;
634
+ child.kill("SIGTERM");
635
+ reject(new Error("clawtip proof provider timed out"));
636
+ }, timeoutMs);
637
+
638
+ child.stdout.on("data", (chunk: Buffer) => {
639
+ stdout += chunk.toString("utf8");
640
+ if (stdout.length > 1024 * 1024) {
641
+ child.kill("SIGTERM");
642
+ }
643
+ });
644
+ child.stderr.on("data", (chunk: Buffer) => {
645
+ stderr += chunk.toString("utf8").slice(0, 2048);
646
+ });
647
+ child.on("error", (error) => {
648
+ if (settled) {
649
+ return;
650
+ }
651
+ settled = true;
652
+ clearTimeout(timer);
653
+ reject(error);
654
+ });
655
+ child.on("close", (code) => {
656
+ if (settled) {
657
+ return;
658
+ }
659
+ settled = true;
660
+ clearTimeout(timer);
661
+ const proof = stdout.trim();
662
+ if (code !== 0 || !proof) {
663
+ reject(new Error(`clawtip proof provider failed with exit ${code}: ${stderr.trim() || "empty proof"}`));
664
+ return;
665
+ }
666
+ logger.info("purchase.clawtip_proof.succeeded", "clawtip proof provider succeeded", {
667
+ sellerKey: route.seller.id,
668
+ model: route.modelId,
669
+ durationMs: Date.now() - startedAt
670
+ });
671
+ resolve(proof);
672
+ });
673
+ child.stdin.end(payload);
674
+ });
675
+ }
676
+
677
+ private clawtipProofTimeoutMs(): number {
678
+ const raw = process.env.TB_PROXYD_CLAWTIP_PROOF_TIMEOUT_MS;
679
+ const parsed = raw ? Number(raw) : 120000;
680
+ if (!Number.isInteger(parsed) || parsed < 1000 || parsed > 600000) {
681
+ return 120000;
682
+ }
683
+ return parsed;
684
+ }
685
+
686
+ private copyUpstreamHeaders(upstreamResponse: globalThis.Response, res: Response): void {
687
+ upstreamResponse.headers.forEach((value, key) => {
688
+ const lowerKey = key.toLowerCase();
689
+ if (["connection", "keep-alive", "transfer-encoding", "upgrade"].includes(lowerKey)) {
690
+ return;
691
+ }
692
+ res.setHeader(key, value);
693
+ });
694
+ }
695
+
696
+ private async forwardProxyRequest(endpoint: string, req: Request, res: Response): Promise<void> {
697
+ const startedAt = Date.now();
698
+ const body = req.body || {};
699
+ const modelId = this.extractModelId(endpoint, body);
700
+ const requestId = req.header("x-request-id") || (body && typeof body === "object" ? (body as { requestId?: string }).requestId : undefined) || `proxy_req_${crypto.randomBytes(8).toString("hex")}`;
701
+ const idempotencyKey = req.header("idempotency-key") || `idem_${crypto.randomBytes(12).toString("hex")}`;
702
+
703
+ if (!modelId) {
704
+ res.status(400).json({ error: { code: "model_required", message: "request body must include model" } });
705
+ return;
706
+ }
707
+
708
+ try {
709
+ const routes = await this.selectSellerRoutes(endpoint, modelId);
710
+ let lastError: unknown;
711
+
712
+ for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
713
+ const route = routes[routeIndex];
714
+ const sellerKey = route.seller.id;
715
+ this.logRouteSelected(route, endpoint, routeIndex);
716
+ try {
717
+ logger.info("proxy.request.started", "proxy request started", {
718
+ requestId,
719
+ sellerKey,
720
+ model: modelId,
721
+ endpoint,
722
+ stream: Boolean((body as { stream?: unknown }).stream)
723
+ });
724
+ const token = await this.getOrPurchaseToken(route);
725
+ const sellerUrl = this.normalizeSellerUrl(route.seller);
726
+ const upstreamBody = {
727
+ ...(body as Record<string, unknown>),
728
+ requestId
729
+ };
730
+
731
+ logger.info("proxy.upstream_fetch.started", "proxy upstream fetch started", {
732
+ requestId,
733
+ sellerKey,
734
+ model: modelId,
735
+ endpoint,
736
+ stream: Boolean((body as { stream?: unknown }).stream)
737
+ });
738
+ const upstreamResponse = await fetch(`${sellerUrl}${endpoint}`, {
739
+ method: "POST",
740
+ headers: {
741
+ "Content-Type": "application/json",
742
+ "Authorization": `Bearer ${token}`,
743
+ "X-Request-Id": requestId,
744
+ "Idempotency-Key": idempotencyKey
745
+ },
746
+ body: JSON.stringify(upstreamBody)
747
+ });
748
+
749
+ if (!upstreamResponse.ok) {
750
+ const errorBody = await upstreamResponse.text();
751
+ logger.warn("proxy.upstream_fetch.failed", "proxy upstream fetch returned non-ok status", {
752
+ requestId,
753
+ sellerKey,
754
+ model: modelId,
755
+ endpoint,
756
+ status: upstreamResponse.status,
757
+ durationMs: Date.now() - startedAt
758
+ });
759
+ if (this.shouldFailoverStatus(upstreamResponse.status) && routeIndex < routes.length - 1) {
760
+ lastError = new Error(`seller ${sellerKey} returned ${upstreamResponse.status}`);
761
+ this.logFailover(route, endpoint, routeIndex, "upstream_status", upstreamResponse.status);
762
+ continue;
763
+ }
764
+ this.copyUpstreamHeaders(upstreamResponse, res);
765
+ res.status(upstreamResponse.status);
766
+ res.send(errorBody);
767
+ return;
768
+ }
769
+
770
+ this.copyUpstreamHeaders(upstreamResponse, res);
771
+ res.status(upstreamResponse.status);
772
+ logger.info("proxy.upstream_fetch.succeeded", "proxy upstream fetch succeeded", {
773
+ requestId,
774
+ sellerKey,
775
+ model: modelId,
776
+ endpoint,
777
+ status: upstreamResponse.status,
778
+ stream: Boolean((body as { stream?: unknown }).stream)
779
+ });
780
+
781
+ const contentType = upstreamResponse.headers.get("content-type") || "";
782
+ if (contentType.includes("text/event-stream") || Boolean((body as { stream?: unknown }).stream)) {
783
+ const reader = upstreamResponse.body?.getReader();
784
+ if (!reader) {
785
+ res.end();
786
+ return;
787
+ }
788
+ let bytes = 0;
789
+ while (true) {
790
+ const { done, value } = await reader.read();
791
+ if (done) {
792
+ break;
793
+ }
794
+ bytes += value.byteLength;
795
+ res.write(Buffer.from(value));
796
+ }
797
+ res.end();
798
+ const billedMicros = Math.max(1, bytes);
799
+ this.tokenStore.deductBalance(sellerKey, billedMicros);
800
+ this.tokenStore.recordInferenceLedger({
801
+ requestId,
802
+ sellerKey,
803
+ modelId,
804
+ endpoint,
805
+ status: "settled",
806
+ promptTokens: 0,
807
+ completionTokens: 0,
808
+ billedMicros,
809
+ prompt: this.inferPromptForHash(body)
810
+ });
811
+ logger.info("inference.ledger.recorded", "safe inference ledger recorded", {
812
+ requestId,
813
+ sellerKey,
814
+ model: modelId,
815
+ endpoint,
816
+ status: "settled",
817
+ billedMicros
818
+ });
819
+ return;
820
+ }
821
+
822
+ const responseBody = await upstreamResponse.text();
823
+ res.send(responseBody);
824
+ const usage = this.readUsage(responseBody);
825
+ this.tokenStore.deductBalance(sellerKey, usage.billedMicros);
826
+ this.tokenStore.recordInferenceLedger({
827
+ requestId,
828
+ sellerKey,
829
+ modelId,
830
+ endpoint,
831
+ status: "settled",
832
+ promptTokens: usage.promptTokens,
833
+ completionTokens: usage.completionTokens,
834
+ billedMicros: usage.billedMicros,
835
+ prompt: this.inferPromptForHash(body),
836
+ response: responseBody
837
+ });
838
+ logger.info("inference.ledger.recorded", "safe inference ledger recorded", {
839
+ requestId,
840
+ sellerKey,
841
+ model: modelId,
842
+ endpoint,
843
+ status: "settled",
844
+ promptTokens: usage.promptTokens,
845
+ completionTokens: usage.completionTokens,
846
+ billedMicros: usage.billedMicros
847
+ });
848
+ return;
849
+ } catch (routeError: unknown) {
850
+ lastError = routeError;
851
+ logger.warn("proxy.route.failed", "seller route failed before response", {
852
+ requestId,
853
+ sellerKey,
854
+ model: modelId,
855
+ endpoint,
856
+ errorMessage: this.failoverErrorMessage(routeError),
857
+ durationMs: Date.now() - startedAt
858
+ });
859
+ if (!res.headersSent && routeIndex < routes.length - 1) {
860
+ this.logFailover(route, endpoint, routeIndex, "exception");
861
+ continue;
862
+ }
863
+ throw routeError;
864
+ }
865
+ }
866
+
867
+ throw lastError instanceof Error ? lastError : new Error("all seller routes failed");
868
+ } catch (error: unknown) {
869
+ logger.error("proxy.request.failed", "proxy request failed", {
870
+ requestId,
871
+ model: modelId,
872
+ endpoint,
873
+ errorMessage: error instanceof Error ? error.message : String(error),
874
+ durationMs: Date.now() - startedAt
875
+ });
876
+ if (!res.headersSent) {
877
+ res.status(502).json({
878
+ error: {
879
+ code: "proxy_request_failed",
880
+ message: error instanceof Error ? error.message : String(error)
881
+ }
882
+ });
883
+ }
884
+ }
885
+ }
886
+
887
+ public start() {
888
+ // 1. Control Plane Server (17820)
889
+ const controlApp = express();
890
+ controlApp.use(express.json());
891
+
892
+ controlApp.get("/health", (req, res) => {
893
+ logger.info("control.health.requested", "control health requested", {
894
+ controlPort: this.activeControlPort(),
895
+ proxyPort: this.activeProxyPort()
896
+ });
897
+ res.status(200).json({
898
+ status: "ok",
899
+ controlPort: this.activeControlPort(),
900
+ proxyPort: this.activeProxyPort(),
901
+ store: {
902
+ journalMode: this.tokenStore.journalMode()
903
+ }
904
+ });
905
+ });
906
+
907
+ controlApp.get("/status", (req, res) => {
908
+ logger.info("control.status.requested", "control status requested", {
909
+ controlPort: this.activeControlPort(),
910
+ proxyPort: this.activeProxyPort()
911
+ });
912
+ res.status(200).json({
913
+ ...this.runtimeSummary()
914
+ });
915
+ });
916
+
917
+ controlApp.get("/payments", (req, res) => {
918
+ logger.info("control.payments.requested", "control payments requested", {});
919
+ res.status(200).json({
920
+ payments: this.tokenStore.listPayments()
921
+ });
922
+ });
923
+
924
+ controlApp.get("/ledger/purchases", (req, res) => {
925
+ logger.info("control.ledger.requested", "control purchase ledger requested", {
926
+ ledger: "purchases"
927
+ });
928
+ res.status(200).json({
929
+ purchases: this.tokenStore.listPurchaseLedger()
930
+ });
931
+ });
932
+
933
+ controlApp.get("/ledger/inferences", (req, res) => {
934
+ logger.info("control.ledger.requested", "control inference ledger requested", {
935
+ ledger: "inferences"
936
+ });
937
+ res.status(200).json({
938
+ inferences: this.tokenStore.listInferenceLedger()
939
+ });
940
+ });
941
+
942
+ controlApp.get("/sellers", async (req, res) => {
943
+ try {
944
+ const registry = await this.fetchRegistry();
945
+ logger.info("sellers.refresh.succeeded", "seller registry refreshed", {
946
+ sellerCount: registry.sellers.length
947
+ });
948
+ res.status(200).json({
949
+ registryUrl: this.config.sellerRegistryUrl,
950
+ version: registry.version,
951
+ defaultSeller: registry.defaultSeller,
952
+ sellers: registry.sellers.map((seller) => ({
953
+ id: seller.id,
954
+ name: seller.name,
955
+ url: seller.url,
956
+ supportedProtocols: seller.supportedProtocols || [],
957
+ paymentMethods: seller.paymentMethods || [],
958
+ status: "configured"
959
+ }))
960
+ });
961
+ } catch (error: unknown) {
962
+ const errorMessage = error instanceof Error ? error.message : String(error);
963
+ logger.warn("sellers.refresh.failed", "seller registry refresh failed", {
964
+ registryUrl: this.config.sellerRegistryUrl,
965
+ errorMessage
966
+ });
967
+ res.status(502).json({
968
+ error: {
969
+ code: "registry_unavailable",
970
+ message: errorMessage
971
+ }
972
+ });
973
+ }
974
+ });
975
+
976
+ controlApp.get("/models", async (req, res) => {
977
+ try {
978
+ const { models, sellers } = await this.listSellerBackedModels();
979
+ logger.info("models.refresh.succeeded", "seller models refreshed", {
980
+ sellerCount: sellers.length,
981
+ modelCount: models.length
982
+ });
983
+ res.status(200).json({
984
+ object: "list",
985
+ registryUrl: this.config.sellerRegistryUrl,
986
+ data: models,
987
+ sellers
988
+ });
989
+ } catch (error: unknown) {
990
+ const errorMessage = error instanceof Error ? error.message : String(error);
991
+ logger.warn("models.refresh.failed", "model refresh failed", {
992
+ registryUrl: this.config.sellerRegistryUrl,
993
+ errorMessage
994
+ });
995
+ res.status(502).json({
996
+ error: {
997
+ code: "models_unavailable",
998
+ message: errorMessage
999
+ }
1000
+ });
1001
+ }
1002
+ });
1003
+
1004
+ controlApp.post("/providers/detect", (req, res) => {
1005
+ try {
1006
+ const providers = detectProviders({ home: typeof req.body?.home === "string" ? req.body.home : undefined });
1007
+ logger.info("provider.detect.succeeded", "provider detection succeeded", {
1008
+ providerCount: providers.length,
1009
+ detectedCount: providers.filter((provider) => provider.detected).length
1010
+ });
1011
+ res.status(200).json({ providers });
1012
+ } catch (error: unknown) {
1013
+ const errorMessage = error instanceof Error ? error.message : String(error);
1014
+ logger.warn("provider.detect.failed", "provider detection failed", { errorMessage });
1015
+ res.status(400).json({
1016
+ error: {
1017
+ code: "provider_detect_failed",
1018
+ message: errorMessage
1019
+ }
1020
+ });
1021
+ }
1022
+ });
1023
+
1024
+ controlApp.post("/providers/install/preview", (req, res) => {
1025
+ try {
1026
+ const changes = previewProviderInstall({
1027
+ providers: Array.isArray(req.body?.providers) ? req.body.providers : [],
1028
+ proxyUrl: String(req.body?.proxyUrl || ""),
1029
+ model: String(req.body?.model || ""),
1030
+ home: typeof req.body?.home === "string" ? req.body.home : undefined
1031
+ });
1032
+ logger.info("provider.install.previewed", "provider install previewed", {
1033
+ providerCount: new Set(changes.map((change) => change.providerId)).size,
1034
+ changeCount: changes.length
1035
+ });
1036
+ res.status(200).json({
1037
+ changes: changes.map(({ content, ...change }) => change)
1038
+ });
1039
+ } catch (error: unknown) {
1040
+ const errorMessage = error instanceof Error ? error.message : String(error);
1041
+ logger.warn("provider.install.preview_failed", "provider install preview failed", { errorMessage });
1042
+ res.status(400).json({
1043
+ error: {
1044
+ code: "provider_install_preview_failed",
1045
+ message: errorMessage
1046
+ }
1047
+ });
1048
+ }
1049
+ });
1050
+
1051
+ controlApp.post("/providers/install/apply", (req, res) => {
1052
+ try {
1053
+ const applied = applyProviderInstall({
1054
+ providers: Array.isArray(req.body?.providers) ? req.body.providers : [],
1055
+ proxyUrl: String(req.body?.proxyUrl || ""),
1056
+ model: String(req.body?.model || ""),
1057
+ home: typeof req.body?.home === "string" ? req.body.home : undefined
1058
+ }, this.tokenStore);
1059
+ logger.info("provider.install.applied", "provider install applied", {
1060
+ providerCount: new Set(applied.map((entry) => entry.providerId)).size,
1061
+ changeCount: applied.length
1062
+ });
1063
+ res.status(200).json({ applied });
1064
+ } catch (error: unknown) {
1065
+ const errorMessage = error instanceof Error ? error.message : String(error);
1066
+ logger.warn("provider.install.apply_failed", "provider install apply failed", { errorMessage });
1067
+ res.status(400).json({
1068
+ error: {
1069
+ code: "provider_install_apply_failed",
1070
+ message: errorMessage
1071
+ }
1072
+ });
1073
+ }
1074
+ });
1075
+
1076
+ controlApp.post("/providers/install/rollback", (req, res) => {
1077
+ try {
1078
+ const rolledBack = rollbackProviderInstall({
1079
+ providers: Array.isArray(req.body?.providers) ? req.body.providers : [],
1080
+ home: typeof req.body?.home === "string" ? req.body.home : undefined
1081
+ }, this.tokenStore);
1082
+ logger.info("provider.install.rolled_back", "provider install rolled back", {
1083
+ providerCount: new Set(rolledBack.map((entry) => entry.providerId)).size,
1084
+ changeCount: rolledBack.length
1085
+ });
1086
+ res.status(200).json({ rolledBack });
1087
+ } catch (error: unknown) {
1088
+ const errorMessage = error instanceof Error ? error.message : String(error);
1089
+ logger.warn("provider.install.rollback_failed", "provider install rollback failed", { errorMessage });
1090
+ res.status(400).json({
1091
+ error: {
1092
+ code: "provider_install_rollback_failed",
1093
+ message: errorMessage
1094
+ }
1095
+ });
1096
+ }
1097
+ });
1098
+
1099
+ this.controlServer = controlApp.listen(this.config.controlPort);
1100
+
1101
+ // 2. Proxy Plane Server (17821)
1102
+ const proxyApp = express();
1103
+ proxyApp.use(express.json({ limit: PROXY_JSON_BODY_LIMIT }));
1104
+
1105
+ proxyApp.get("/v1/models", async (req: Request, res: Response) => {
1106
+ try {
1107
+ const { models } = await this.listSellerBackedModels();
1108
+ logger.info("models.refresh.succeeded", "proxy models refreshed", {
1109
+ modelCount: models.length
1110
+ });
1111
+ res.status(200).json({
1112
+ object: "list",
1113
+ data: models.map((model) => ({
1114
+ id: model.id,
1115
+ object: "model",
1116
+ owned_by: model.sellerId,
1117
+ sellerId: model.sellerId,
1118
+ supportedProtocols: model.supportedProtocols,
1119
+ paymentMethods: model.paymentMethods
1120
+ }))
1121
+ });
1122
+ } catch (error: unknown) {
1123
+ const errorMessage = error instanceof Error ? error.message : String(error);
1124
+ logger.warn("models.refresh.failed", "proxy models refresh failed", {
1125
+ registryUrl: this.config.sellerRegistryUrl,
1126
+ errorMessage
1127
+ });
1128
+ res.status(502).json({
1129
+ error: {
1130
+ code: "models_unavailable",
1131
+ message: errorMessage
1132
+ }
1133
+ });
1134
+ }
1135
+ });
1136
+
1137
+ for (const endpoint of ["/v1/chat/completions", "/v1/responses", "/v1/messages", "/messages"]) {
1138
+ proxyApp.post(endpoint, async (req: Request, res: Response) => {
1139
+ await this.forwardProxyRequest(endpoint, req, res);
1140
+ });
1141
+ }
1142
+
1143
+ this.proxyServer = proxyApp.listen(this.config.proxyPort);
1144
+ logger.info("proxy.startup", "tb-proxyd daemon started", {
1145
+ controlPort: this.config.controlPort,
1146
+ proxyPort: this.config.proxyPort,
1147
+ dbPath: this.config.dbPath,
1148
+ sellerRegistryUrl: this.config.sellerRegistryUrl,
1149
+ selectionMode: this.selectionMode
1150
+ });
1151
+ }
1152
+
1153
+ public stop() {
1154
+ if (this.controlServer) this.controlServer.close();
1155
+ if (this.proxyServer) this.proxyServer.close();
1156
+ this.tokenStore.close();
1157
+ }
1158
+ }