@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.
- package/bin/tb-proxyd.js +2 -0
- package/bin/tb.js +3 -0
- package/bin/tokenbuddy-proxyd.js +2 -0
- package/bin/tokenbuddy.js +3 -0
- package/dist/src/buyer-store.d.ts +118 -0
- package/dist/src/buyer-store.d.ts.map +1 -0
- package/dist/src/buyer-store.js +296 -0
- package/dist/src/buyer-store.js.map +1 -0
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +648 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/daemon.d.ts +48 -0
- package/dist/src/daemon.d.ts.map +1 -0
- package/dist/src/daemon.js +998 -0
- package/dist/src/daemon.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +12 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/provider-install.d.ts +44 -0
- package/dist/src/provider-install.d.ts.map +1 -0
- package/dist/src/provider-install.js +286 -0
- package/dist/src/provider-install.js.map +1 -0
- package/dist/src/tb-proxyd.d.ts +2 -0
- package/dist/src/tb-proxyd.d.ts.map +1 -0
- package/dist/src/tb-proxyd.js +54 -0
- package/dist/src/tb-proxyd.js.map +1 -0
- package/dist/src/terminal-detect.d.ts +29 -0
- package/dist/src/terminal-detect.d.ts.map +1 -0
- package/dist/src/terminal-detect.js +209 -0
- package/dist/src/terminal-detect.js.map +1 -0
- package/package.json +29 -0
- package/src/buyer-store.ts +536 -0
- package/src/cli.ts +732 -0
- package/src/daemon.ts +1158 -0
- package/src/index.ts +12 -0
- package/src/provider-install.ts +363 -0
- package/src/tb-proxyd.ts +60 -0
- package/src/terminal-detect.ts +225 -0
- package/tests/e2e.test.ts +264 -0
- package/tests/tokenbuddy.test.ts +1186 -0
- 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
|
+
}
|