@tokenbuddy/tokenbuddy 1.0.4 → 1.0.6
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/dist/src/buyer-store.d.ts +20 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +73 -1
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +390 -62
- package/dist/src/cli.js.map +1 -1
- package/dist/src/daemon.d.ts +6 -5
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +298 -92
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/doctor-diagnostics.d.ts +97 -0
- package/dist/src/doctor-diagnostics.d.ts.map +1 -0
- package/dist/src/doctor-diagnostics.js +547 -0
- package/dist/src/doctor-diagnostics.js.map +1 -0
- package/dist/src/init-payment-options.d.ts +34 -0
- package/dist/src/init-payment-options.d.ts.map +1 -0
- package/dist/src/init-payment-options.js +90 -0
- package/dist/src/init-payment-options.js.map +1 -0
- package/dist/src/provider-install.d.ts +37 -2
- package/dist/src/provider-install.d.ts.map +1 -1
- package/dist/src/provider-install.js +317 -67
- package/dist/src/provider-install.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +79 -0
- package/dist/src/seller-catalog.d.ts.map +1 -0
- package/dist/src/seller-catalog.js +126 -0
- package/dist/src/seller-catalog.js.map +1 -0
- package/dist/src/tb-proxyd.js +13 -2
- package/dist/src/tb-proxyd.js.map +1 -1
- package/package.json +4 -4
- package/src/buyer-store.ts +113 -1
- package/src/cli.ts +490 -67
- package/src/daemon.ts +346 -117
- package/src/doctor-diagnostics.ts +850 -0
- package/src/init-payment-options.ts +131 -0
- package/src/provider-install.ts +426 -76
- package/src/seller-catalog.ts +222 -0
- package/src/tb-proxyd.ts +14 -2
- package/tests/e2e.test.ts +9 -0
- package/tests/tokenbuddy.test.ts +628 -19
- package/bin/tb-proxyd.js +0 -2
- package/bin/tb.js +0 -3
package/src/daemon.ts
CHANGED
|
@@ -9,8 +9,22 @@ import {
|
|
|
9
9
|
applyProviderInstall,
|
|
10
10
|
detectProviders,
|
|
11
11
|
previewProviderInstall,
|
|
12
|
-
rollbackProviderInstall
|
|
12
|
+
rollbackProviderInstall,
|
|
13
|
+
type ClaudeCodeModelMappingConfig,
|
|
13
14
|
} from "./provider-install.js";
|
|
15
|
+
import {
|
|
16
|
+
discoverSellerBackedModels,
|
|
17
|
+
fetchSellerManifest,
|
|
18
|
+
fetchSellerRegistry,
|
|
19
|
+
manifestModelIds,
|
|
20
|
+
manifestPaymentMethods,
|
|
21
|
+
manifestProtocols,
|
|
22
|
+
normalizeSellerUrl,
|
|
23
|
+
type RegistrySeller,
|
|
24
|
+
type SellerManifest,
|
|
25
|
+
type SellerRegistryDocument,
|
|
26
|
+
type SellerRoutingPreference,
|
|
27
|
+
} from "./seller-catalog.js";
|
|
14
28
|
|
|
15
29
|
const logger = createModuleLogger("tb-proxyd");
|
|
16
30
|
const PROXY_JSON_BODY_LIMIT = "10mb";
|
|
@@ -21,30 +35,7 @@ export interface DaemonConfig {
|
|
|
21
35
|
dbPath: string;
|
|
22
36
|
sellerRegistryUrl: string;
|
|
23
37
|
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 }>;
|
|
38
|
+
selectedSellerId?: string;
|
|
48
39
|
}
|
|
49
40
|
|
|
50
41
|
interface SellerRoute {
|
|
@@ -61,6 +52,225 @@ interface UsageSummary {
|
|
|
61
52
|
billedMicros: number;
|
|
62
53
|
}
|
|
63
54
|
|
|
55
|
+
interface ResponsesStreamState {
|
|
56
|
+
itemId: string;
|
|
57
|
+
text: string;
|
|
58
|
+
contentPartStarted: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class ResponsesStreamNormalizer {
|
|
62
|
+
private pending = "";
|
|
63
|
+
private readonly state = new Map<string, ResponsesStreamState>();
|
|
64
|
+
|
|
65
|
+
public push(chunk: string): string {
|
|
66
|
+
this.pending += chunk;
|
|
67
|
+
const blocks = this.pending.split("\n\n");
|
|
68
|
+
this.pending = blocks.pop() || "";
|
|
69
|
+
return blocks
|
|
70
|
+
.map((block) => this.normalizeBlock(block))
|
|
71
|
+
.filter((block) => block.length > 0)
|
|
72
|
+
.join("\n\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public finish(): string {
|
|
76
|
+
if (!this.pending.trim()) {
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
const block = this.normalizeBlock(this.pending);
|
|
80
|
+
this.pending = "";
|
|
81
|
+
return block;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private normalizeBlock(block: string): string {
|
|
85
|
+
if (!block.trim()) {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
// Each \n\n separates an event in SSE format
|
|
89
|
+
const subBlocks = block.split("\n\n");
|
|
90
|
+
const output: string[] = [];
|
|
91
|
+
|
|
92
|
+
for (const sub of subBlocks) {
|
|
93
|
+
if (!sub.trim() || sub.trim() === "data: [DONE]") {
|
|
94
|
+
if (sub.trim()) output.push(sub);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lines = sub.split("\n");
|
|
99
|
+
const eventLine = lines.find((l) => l.startsWith("event:"));
|
|
100
|
+
const dataLine = lines.find((l) => l.startsWith("data:"));
|
|
101
|
+
if (!dataLine) {
|
|
102
|
+
output.push(sub);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const rawData = dataLine.replace(/^data:\s?/, "");
|
|
106
|
+
if (rawData === "[DONE]") {
|
|
107
|
+
output.push(sub);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let payload: any;
|
|
112
|
+
try {
|
|
113
|
+
payload = JSON.parse(rawData);
|
|
114
|
+
} catch {
|
|
115
|
+
output.push(sub);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const eventName =
|
|
120
|
+
(eventLine?.replace(/^event:\s?/, "") || payload?.type) as string;
|
|
121
|
+
if (!eventName || !eventName.startsWith("response.")) {
|
|
122
|
+
output.push(sub);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// When upstream already sends content_part.added, record it in state
|
|
127
|
+
if (
|
|
128
|
+
eventName === "response.content_part.added" &&
|
|
129
|
+
payload?.item_id
|
|
130
|
+
) {
|
|
131
|
+
const current = this.state.get(payload.item_id as string);
|
|
132
|
+
if (current) current.contentPartStarted = true;
|
|
133
|
+
output.push(sub);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// response.output_item.added: inject content_part.added only if upstream hasn't
|
|
138
|
+
if (
|
|
139
|
+
eventName === "response.output_item.added" &&
|
|
140
|
+
payload?.item?.type === "message" &&
|
|
141
|
+
payload?.item?.id
|
|
142
|
+
) {
|
|
143
|
+
const itemId = payload.item.id as string;
|
|
144
|
+
const current = this.getState(itemId);
|
|
145
|
+
const item = { ...payload.item };
|
|
146
|
+
item.content = [{ type: "output_text", text: "", annotations: [] }];
|
|
147
|
+
output.push(this.serializeEvent(eventName, {
|
|
148
|
+
...payload,
|
|
149
|
+
output_index: payload.output_index ?? 0,
|
|
150
|
+
item
|
|
151
|
+
}));
|
|
152
|
+
if (!current.contentPartStarted) {
|
|
153
|
+
current.contentPartStarted = true;
|
|
154
|
+
output.push(this.serializeEvent("response.content_part.added", {
|
|
155
|
+
type: "response.content_part.added",
|
|
156
|
+
item_id: itemId,
|
|
157
|
+
output_index: payload.output_index ?? 0,
|
|
158
|
+
content_index: 0,
|
|
159
|
+
part: { type: "output_text", text: "", annotations: [] }
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// response.output_text.delta: inject content_part.added if missing
|
|
166
|
+
if (eventName === "response.output_text.delta" && payload?.item_id) {
|
|
167
|
+
const itemId = payload.item_id as string;
|
|
168
|
+
const current = this.getState(itemId);
|
|
169
|
+
if (!current.contentPartStarted) {
|
|
170
|
+
current.contentPartStarted = true;
|
|
171
|
+
output.push(this.serializeEvent("response.content_part.added", {
|
|
172
|
+
type: "response.content_part.added",
|
|
173
|
+
item_id: itemId,
|
|
174
|
+
output_index: payload.output_index ?? 0,
|
|
175
|
+
content_index: payload.content_index ?? 0,
|
|
176
|
+
part: { type: "output_text", text: "", annotations: [] }
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
const deltaText =
|
|
180
|
+
typeof payload.delta === "string"
|
|
181
|
+
? payload.delta
|
|
182
|
+
: typeof payload.delta?.text === "string"
|
|
183
|
+
? payload.delta.text
|
|
184
|
+
: "";
|
|
185
|
+
current.text += deltaText;
|
|
186
|
+
output.push(this.serializeEvent(eventName, {
|
|
187
|
+
...payload,
|
|
188
|
+
output_index: payload.output_index ?? 0,
|
|
189
|
+
content_index: payload.content_index ?? 0
|
|
190
|
+
}));
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// response.output_text.done: also emit content_part.done
|
|
195
|
+
if (eventName === "response.output_text.done" && payload?.item_id) {
|
|
196
|
+
const itemId = payload.item_id as string;
|
|
197
|
+
const current = this.getState(itemId);
|
|
198
|
+
output.push(this.serializeEvent(eventName, {
|
|
199
|
+
...payload,
|
|
200
|
+
output_index: payload.output_index ?? 0,
|
|
201
|
+
content_index: payload.content_index ?? 0
|
|
202
|
+
}));
|
|
203
|
+
output.push(this.serializeEvent("response.content_part.done", {
|
|
204
|
+
type: "response.content_part.done",
|
|
205
|
+
item_id: itemId,
|
|
206
|
+
output_index: payload.output_index ?? 0,
|
|
207
|
+
content_index: payload.content_index ?? 0,
|
|
208
|
+
part: { type: "output_text", text: current.text, annotations: [] }
|
|
209
|
+
}));
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// response.output_item.done: normalize content to output_text type
|
|
214
|
+
if (
|
|
215
|
+
eventName === "response.output_item.done" &&
|
|
216
|
+
payload?.item?.type === "message" &&
|
|
217
|
+
payload?.item?.id
|
|
218
|
+
) {
|
|
219
|
+
const itemId = payload.item.id as string;
|
|
220
|
+
const current = this.getState(itemId);
|
|
221
|
+
const item = {
|
|
222
|
+
...payload.item,
|
|
223
|
+
content: [{ type: "output_text", text: current.text, annotations: [] }]
|
|
224
|
+
};
|
|
225
|
+
output.push(this.serializeEvent(eventName, {
|
|
226
|
+
...payload,
|
|
227
|
+
output_index: payload.output_index ?? 0,
|
|
228
|
+
item
|
|
229
|
+
}));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// response.completed: patch output if empty
|
|
234
|
+
if (eventName === "response.completed" && payload?.response) {
|
|
235
|
+
const response = { ...payload.response };
|
|
236
|
+
if (!Array.isArray(response.output) || response.output.length === 0) {
|
|
237
|
+
const first = this.state.values().next()
|
|
238
|
+
.value as ResponsesStreamState | undefined;
|
|
239
|
+
if (first) {
|
|
240
|
+
response.output = [{
|
|
241
|
+
id: first.itemId,
|
|
242
|
+
type: "message",
|
|
243
|
+
status: "completed",
|
|
244
|
+
role: "assistant",
|
|
245
|
+
content: [{ type: "output_text", text: first.text, annotations: [] }]
|
|
246
|
+
}];
|
|
247
|
+
response.output_text = first.text;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
output.push(this.serializeEvent(eventName, { ...payload, response }));
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// All other events: pass through unchanged
|
|
255
|
+
output.push(sub);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return output.join("\n\n");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private getState(itemId: string): ResponsesStreamState {
|
|
262
|
+
const current = this.state.get(itemId);
|
|
263
|
+
if (current) return current;
|
|
264
|
+
const created = { itemId, text: "", contentPartStarted: false };
|
|
265
|
+
this.state.set(itemId, created);
|
|
266
|
+
return created;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private serializeEvent(name: string, data: any): string {
|
|
270
|
+
return `event: ${name}\ndata: ${JSON.stringify(data)}`;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
64
274
|
interface PurchaseCreateResponse {
|
|
65
275
|
purchaseId?: string;
|
|
66
276
|
purchase_id?: string;
|
|
@@ -91,13 +301,22 @@ export class TokenbuddyDaemon {
|
|
|
91
301
|
private controlServer?: any;
|
|
92
302
|
private proxyServer?: any;
|
|
93
303
|
private selectionMode: "auto" | "manual";
|
|
304
|
+
private selectedSellerId?: string;
|
|
94
305
|
|
|
95
306
|
private activePurchases = new Map<string, Promise<string>>();
|
|
96
307
|
|
|
97
308
|
constructor(config: DaemonConfig) {
|
|
98
|
-
this.config = config;
|
|
99
|
-
this.selectionMode = config.selectionMode || "auto";
|
|
100
309
|
this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
|
|
310
|
+
const routingPreference =
|
|
311
|
+
this.tokenStore.getDaemonRuntimeConfig<SellerRoutingPreference>("routing")
|
|
312
|
+
?.config;
|
|
313
|
+
this.config = config;
|
|
314
|
+
this.selectionMode =
|
|
315
|
+
config.selectionMode ||
|
|
316
|
+
(routingPreference?.mode === "fixed" ? "manual" : routingPreference?.mode) ||
|
|
317
|
+
"auto";
|
|
318
|
+
this.selectedSellerId =
|
|
319
|
+
config.selectedSellerId || routingPreference?.sellerId;
|
|
101
320
|
}
|
|
102
321
|
|
|
103
322
|
private activeControlPort(): number {
|
|
@@ -111,43 +330,25 @@ export class TokenbuddyDaemon {
|
|
|
111
330
|
}
|
|
112
331
|
|
|
113
332
|
private async fetchRegistry(): Promise<SellerRegistryDocument> {
|
|
114
|
-
|
|
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;
|
|
333
|
+
return await fetchSellerRegistry(this.config.sellerRegistryUrl);
|
|
132
334
|
}
|
|
133
335
|
|
|
134
336
|
private runtimeSummary() {
|
|
337
|
+
const sellerRoutingMode = this.selectedSellerId ? "fixed" : this.selectionMode;
|
|
135
338
|
return {
|
|
136
339
|
status: "running",
|
|
137
340
|
pid: process.pid,
|
|
138
341
|
controlPort: this.activeControlPort(),
|
|
139
342
|
proxyPort: this.activeProxyPort(),
|
|
140
343
|
selectionMode: this.selectionMode,
|
|
344
|
+
sellerRoutingMode,
|
|
345
|
+
selectedSellerId: this.selectedSellerId,
|
|
141
346
|
dbPath: this.config.dbPath,
|
|
142
347
|
sellerRegistryUrl: this.config.sellerRegistryUrl,
|
|
143
348
|
store: this.tokenStore.summary()
|
|
144
349
|
};
|
|
145
350
|
}
|
|
146
351
|
|
|
147
|
-
private normalizeSellerUrl(seller: RegistrySeller): string {
|
|
148
|
-
return seller.url.replace(/\/+$/, "");
|
|
149
|
-
}
|
|
150
|
-
|
|
151
352
|
private endpointProtocol(endpoint: string): string | undefined {
|
|
152
353
|
if (endpoint === "/v1/chat/completions") {
|
|
153
354
|
return "chat_completions";
|
|
@@ -173,19 +374,63 @@ export class TokenbuddyDaemon {
|
|
|
173
374
|
return undefined;
|
|
174
375
|
}
|
|
175
376
|
|
|
176
|
-
private
|
|
177
|
-
const
|
|
178
|
-
return
|
|
179
|
-
?
|
|
180
|
-
:
|
|
377
|
+
private stripLocalClaudeOneMMarker(modelId: string): string {
|
|
378
|
+
const trimmed = modelId.trimEnd();
|
|
379
|
+
return trimmed.toLowerCase().endsWith("[1m]")
|
|
380
|
+
? trimmed.slice(0, -4).trimEnd()
|
|
381
|
+
: trimmed;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private resolveClaudeRoleModel(modelId: string): string {
|
|
385
|
+
const runtimeConfig = this.tokenStore.getProviderRuntimeConfig<ClaudeCodeModelMappingConfig>("claude-code")?.config;
|
|
386
|
+
const stripped = this.stripLocalClaudeOneMMarker(modelId);
|
|
387
|
+
if (!runtimeConfig || runtimeConfig.selectionKind !== "claude-role-mapping") {
|
|
388
|
+
return stripped;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const lowered = stripped.toLowerCase();
|
|
392
|
+
if (lowered === "haiku" || lowered.startsWith("claude-haiku")) {
|
|
393
|
+
return runtimeConfig.roles.haiku?.upstreamModel || runtimeConfig.fallbackModel || stripped;
|
|
394
|
+
}
|
|
395
|
+
if (lowered === "sonnet" || lowered.startsWith("claude-sonnet")) {
|
|
396
|
+
return runtimeConfig.roles.sonnet?.upstreamModel || runtimeConfig.fallbackModel || stripped;
|
|
397
|
+
}
|
|
398
|
+
if (lowered === "opus" || lowered.startsWith("claude-opus")) {
|
|
399
|
+
return runtimeConfig.roles.opus?.upstreamModel || runtimeConfig.fallbackModel || stripped;
|
|
400
|
+
}
|
|
401
|
+
return runtimeConfig.fallbackModel || stripped;
|
|
181
402
|
}
|
|
182
403
|
|
|
183
|
-
private
|
|
184
|
-
|
|
404
|
+
private resolveRouteModelId(endpoint: string, body: unknown): { requestedModelId?: string; resolvedModelId?: string } {
|
|
405
|
+
const requestedModelId = this.extractModelId(endpoint, body);
|
|
406
|
+
if (!requestedModelId) {
|
|
407
|
+
return {};
|
|
408
|
+
}
|
|
409
|
+
if (this.endpointProtocol(endpoint) === "messages") {
|
|
410
|
+
return {
|
|
411
|
+
requestedModelId,
|
|
412
|
+
resolvedModelId: this.resolveClaudeRoleModel(requestedModelId)
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
requestedModelId,
|
|
417
|
+
resolvedModelId: requestedModelId
|
|
418
|
+
};
|
|
185
419
|
}
|
|
186
420
|
|
|
187
|
-
private
|
|
188
|
-
|
|
421
|
+
private applyResolvedModelToBody(
|
|
422
|
+
endpoint: string,
|
|
423
|
+
body: Record<string, unknown>,
|
|
424
|
+
resolvedModelId: string
|
|
425
|
+
): Record<string, unknown> {
|
|
426
|
+
const nextBody: Record<string, unknown> = { ...body };
|
|
427
|
+
if ("model" in nextBody) {
|
|
428
|
+
nextBody.model = resolvedModelId;
|
|
429
|
+
}
|
|
430
|
+
if (endpoint === "/v1/responses" && "model_id" in nextBody) {
|
|
431
|
+
nextBody.model_id = resolvedModelId;
|
|
432
|
+
}
|
|
433
|
+
return nextBody;
|
|
189
434
|
}
|
|
190
435
|
|
|
191
436
|
private defaultPaymentMethod(): string | undefined {
|
|
@@ -206,13 +451,16 @@ export class TokenbuddyDaemon {
|
|
|
206
451
|
const registry = await this.fetchRegistry();
|
|
207
452
|
const defaultSellers = registry.sellers.filter((seller) => seller.id === registry.defaultSeller);
|
|
208
453
|
const backupSellers = registry.sellers.filter((seller) => seller.id !== registry.defaultSeller);
|
|
209
|
-
const
|
|
454
|
+
const manualSellers = this.selectedSellerId
|
|
455
|
+
? registry.sellers.filter((seller) => seller.id === this.selectedSellerId)
|
|
456
|
+
: defaultSellers;
|
|
457
|
+
const sellers = this.selectionMode === "manual" ? manualSellers : [...defaultSellers, ...backupSellers];
|
|
210
458
|
|
|
211
459
|
const routes: SellerRoute[] = [];
|
|
212
460
|
for (const seller of sellers) {
|
|
213
461
|
let manifest: SellerManifest;
|
|
214
462
|
try {
|
|
215
|
-
manifest = await
|
|
463
|
+
manifest = await fetchSellerManifest(seller);
|
|
216
464
|
} catch (error: unknown) {
|
|
217
465
|
logger.warn("route.manifest.failed", "seller manifest unavailable during route selection", {
|
|
218
466
|
sellerKey: seller.id,
|
|
@@ -223,9 +471,9 @@ export class TokenbuddyDaemon {
|
|
|
223
471
|
continue;
|
|
224
472
|
}
|
|
225
473
|
|
|
226
|
-
const protocols =
|
|
227
|
-
const paymentMethods =
|
|
228
|
-
const modelIds =
|
|
474
|
+
const protocols = manifestProtocols(manifest, seller);
|
|
475
|
+
const paymentMethods = manifestPaymentMethods(manifest, seller);
|
|
476
|
+
const modelIds = manifestModelIds(manifest);
|
|
229
477
|
if (!protocols.includes(protocol) || !paymentMethods.includes(paymentMethod) || !modelIds.includes(modelId)) {
|
|
230
478
|
continue;
|
|
231
479
|
}
|
|
@@ -303,51 +551,10 @@ export class TokenbuddyDaemon {
|
|
|
303
551
|
models: Array<{ id: string; sellerId: string; sellerName?: string; sellerUrl: string; supportedProtocols: string[]; paymentMethods: string[] }>;
|
|
304
552
|
sellers: Array<{ id: string; name?: string; url: string; status: string; manifestSellerId?: string; errorMessage?: string }>;
|
|
305
553
|
}> {
|
|
306
|
-
const
|
|
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
|
-
}));
|
|
554
|
+
const catalog = await discoverSellerBackedModels(this.config.sellerRegistryUrl);
|
|
348
555
|
return {
|
|
349
|
-
models:
|
|
350
|
-
sellers:
|
|
556
|
+
models: catalog.models,
|
|
557
|
+
sellers: catalog.sellers
|
|
351
558
|
};
|
|
352
559
|
}
|
|
353
560
|
|
|
@@ -409,7 +616,7 @@ export class TokenbuddyDaemon {
|
|
|
409
616
|
|
|
410
617
|
private async getOrPurchaseToken(route: SellerRoute): Promise<string> {
|
|
411
618
|
const sellerKey = route.seller.id;
|
|
412
|
-
const sellerUrl =
|
|
619
|
+
const sellerUrl = normalizeSellerUrl(route.seller);
|
|
413
620
|
const { modelId, paymentMethod } = route;
|
|
414
621
|
const cached = this.tokenStore.getToken(sellerKey);
|
|
415
622
|
const rebuyMinBalanceMicros = this.tokenRebuyMinBalanceMicros();
|
|
@@ -600,7 +807,7 @@ export class TokenbuddyDaemon {
|
|
|
600
807
|
const timeoutMs = this.clawtipProofTimeoutMs();
|
|
601
808
|
const payload = JSON.stringify({
|
|
602
809
|
sellerKey: route.seller.id,
|
|
603
|
-
sellerUrl:
|
|
810
|
+
sellerUrl: normalizeSellerUrl(route.seller),
|
|
604
811
|
modelId: route.modelId,
|
|
605
812
|
purchase: createData,
|
|
606
813
|
paymentInstructions: createData.paymentInstructions || createData.payment_instructions,
|
|
@@ -686,7 +893,7 @@ export class TokenbuddyDaemon {
|
|
|
686
893
|
private copyUpstreamHeaders(upstreamResponse: globalThis.Response, res: Response): void {
|
|
687
894
|
upstreamResponse.headers.forEach((value, key) => {
|
|
688
895
|
const lowerKey = key.toLowerCase();
|
|
689
|
-
if (["connection", "keep-alive", "transfer-encoding", "upgrade"].includes(lowerKey)) {
|
|
896
|
+
if (["connection", "content-encoding", "content-length", "keep-alive", "transfer-encoding", "upgrade"].includes(lowerKey)) {
|
|
690
897
|
return;
|
|
691
898
|
}
|
|
692
899
|
res.setHeader(key, value);
|
|
@@ -696,7 +903,8 @@ export class TokenbuddyDaemon {
|
|
|
696
903
|
private async forwardProxyRequest(endpoint: string, req: Request, res: Response): Promise<void> {
|
|
697
904
|
const startedAt = Date.now();
|
|
698
905
|
const body = req.body || {};
|
|
699
|
-
const
|
|
906
|
+
const { requestedModelId, resolvedModelId } = this.resolveRouteModelId(endpoint, body);
|
|
907
|
+
const modelId = resolvedModelId;
|
|
700
908
|
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
909
|
const idempotencyKey = req.header("idempotency-key") || `idem_${crypto.randomBytes(12).toString("hex")}`;
|
|
702
910
|
|
|
@@ -718,15 +926,16 @@ export class TokenbuddyDaemon {
|
|
|
718
926
|
requestId,
|
|
719
927
|
sellerKey,
|
|
720
928
|
model: modelId,
|
|
929
|
+
requestedModel: requestedModelId,
|
|
721
930
|
endpoint,
|
|
722
931
|
stream: Boolean((body as { stream?: unknown }).stream)
|
|
723
932
|
});
|
|
724
933
|
const token = await this.getOrPurchaseToken(route);
|
|
725
|
-
const sellerUrl =
|
|
726
|
-
const upstreamBody = {
|
|
934
|
+
const sellerUrl = normalizeSellerUrl(route.seller);
|
|
935
|
+
const upstreamBody = this.applyResolvedModelToBody(endpoint, {
|
|
727
936
|
...(body as Record<string, unknown>),
|
|
728
937
|
requestId
|
|
729
|
-
};
|
|
938
|
+
}, modelId);
|
|
730
939
|
|
|
731
940
|
logger.info("proxy.upstream_fetch.started", "proxy upstream fetch started", {
|
|
732
941
|
requestId,
|
|
@@ -786,13 +995,29 @@ export class TokenbuddyDaemon {
|
|
|
786
995
|
return;
|
|
787
996
|
}
|
|
788
997
|
let bytes = 0;
|
|
998
|
+
const decoder = new TextDecoder();
|
|
999
|
+
const responsesStreamNormalizer = new ResponsesStreamNormalizer();
|
|
789
1000
|
while (true) {
|
|
790
1001
|
const { done, value } = await reader.read();
|
|
791
1002
|
if (done) {
|
|
792
1003
|
break;
|
|
793
1004
|
}
|
|
794
1005
|
bytes += value.byteLength;
|
|
795
|
-
|
|
1006
|
+
if (endpoint === "/v1/responses") {
|
|
1007
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
1008
|
+
const normalized = responsesStreamNormalizer.push(chunk);
|
|
1009
|
+
if (normalized.length > 0) {
|
|
1010
|
+
res.write(`${normalized}\n\n`);
|
|
1011
|
+
}
|
|
1012
|
+
} else {
|
|
1013
|
+
res.write(Buffer.from(value));
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (endpoint === "/v1/responses") {
|
|
1017
|
+
const trailing = responsesStreamNormalizer.finish();
|
|
1018
|
+
if (trailing.length > 0) {
|
|
1019
|
+
res.write(`${trailing}\n\n`);
|
|
1020
|
+
}
|
|
796
1021
|
}
|
|
797
1022
|
res.end();
|
|
798
1023
|
const billedMicros = Math.max(1, bytes);
|
|
@@ -1026,7 +1251,9 @@ export class TokenbuddyDaemon {
|
|
|
1026
1251
|
const changes = previewProviderInstall({
|
|
1027
1252
|
providers: Array.isArray(req.body?.providers) ? req.body.providers : [],
|
|
1028
1253
|
proxyUrl: String(req.body?.proxyUrl || ""),
|
|
1029
|
-
model:
|
|
1254
|
+
model: typeof req.body?.model === "string" ? req.body.model : undefined,
|
|
1255
|
+
providerSelections: req.body?.providerSelections,
|
|
1256
|
+
sellerRouting: req.body?.sellerRouting,
|
|
1030
1257
|
home: typeof req.body?.home === "string" ? req.body.home : undefined
|
|
1031
1258
|
});
|
|
1032
1259
|
logger.info("provider.install.previewed", "provider install previewed", {
|
|
@@ -1053,7 +1280,9 @@ export class TokenbuddyDaemon {
|
|
|
1053
1280
|
const applied = applyProviderInstall({
|
|
1054
1281
|
providers: Array.isArray(req.body?.providers) ? req.body.providers : [],
|
|
1055
1282
|
proxyUrl: String(req.body?.proxyUrl || ""),
|
|
1056
|
-
model:
|
|
1283
|
+
model: typeof req.body?.model === "string" ? req.body.model : undefined,
|
|
1284
|
+
providerSelections: req.body?.providerSelections,
|
|
1285
|
+
sellerRouting: req.body?.sellerRouting,
|
|
1057
1286
|
home: typeof req.body?.home === "string" ? req.body.home : undefined
|
|
1058
1287
|
}, this.tokenStore);
|
|
1059
1288
|
logger.info("provider.install.applied", "provider install applied", {
|