@tokenbuddy/tokenbuddy 1.0.5 → 1.0.7

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 (56) hide show
  1. package/dist/src/buyer-store.d.ts +48 -1
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +144 -17
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts +17 -0
  6. package/dist/src/cli.d.ts.map +1 -1
  7. package/dist/src/cli.js +560 -63
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/daemon.d.ts +11 -5
  10. package/dist/src/daemon.d.ts.map +1 -1
  11. package/dist/src/daemon.js +574 -161
  12. package/dist/src/daemon.js.map +1 -1
  13. package/dist/src/doctor-clawtip-wallet.d.ts +14 -0
  14. package/dist/src/doctor-clawtip-wallet.d.ts.map +1 -0
  15. package/dist/src/doctor-clawtip-wallet.js +54 -0
  16. package/dist/src/doctor-clawtip-wallet.js.map +1 -0
  17. package/dist/src/doctor-diagnostics.d.ts +99 -0
  18. package/dist/src/doctor-diagnostics.d.ts.map +1 -0
  19. package/dist/src/doctor-diagnostics.js +552 -0
  20. package/dist/src/doctor-diagnostics.js.map +1 -0
  21. package/dist/src/init-clawtip-activation.d.ts +48 -0
  22. package/dist/src/init-clawtip-activation.d.ts.map +1 -0
  23. package/dist/src/init-clawtip-activation.js +395 -0
  24. package/dist/src/init-clawtip-activation.js.map +1 -0
  25. package/dist/src/init-payment-options.d.ts +56 -0
  26. package/dist/src/init-payment-options.d.ts.map +1 -0
  27. package/dist/src/init-payment-options.js +165 -0
  28. package/dist/src/init-payment-options.js.map +1 -0
  29. package/dist/src/provider-install.d.ts +37 -2
  30. package/dist/src/provider-install.d.ts.map +1 -1
  31. package/dist/src/provider-install.js +317 -67
  32. package/dist/src/provider-install.js.map +1 -1
  33. package/dist/src/seller-catalog.d.ts +79 -0
  34. package/dist/src/seller-catalog.d.ts.map +1 -0
  35. package/dist/src/seller-catalog.js +126 -0
  36. package/dist/src/seller-catalog.js.map +1 -0
  37. package/dist/src/tb-proxyd.js +13 -2
  38. package/dist/src/tb-proxyd.js.map +1 -1
  39. package/dist/src/terminal-image.d.ts +22 -0
  40. package/dist/src/terminal-image.d.ts.map +1 -0
  41. package/dist/src/terminal-image.js +135 -0
  42. package/dist/src/terminal-image.js.map +1 -0
  43. package/package.json +1 -1
  44. package/src/buyer-store.ts +253 -18
  45. package/src/cli.ts +709 -68
  46. package/src/daemon.ts +651 -167
  47. package/src/doctor-clawtip-wallet.ts +70 -0
  48. package/src/doctor-diagnostics.ts +861 -0
  49. package/src/init-clawtip-activation.ts +487 -0
  50. package/src/init-payment-options.ts +249 -0
  51. package/src/provider-install.ts +426 -76
  52. package/src/seller-catalog.ts +222 -0
  53. package/src/tb-proxyd.ts +14 -2
  54. package/src/terminal-image.ts +187 -0
  55. package/tests/e2e.test.ts +88 -5
  56. package/tests/tokenbuddy.test.ts +1362 -27
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,330 @@ interface UsageSummary {
61
52
  billedMicros: number;
62
53
  }
63
54
 
55
+ interface SellerSettlementSummary {
56
+ requestId: string;
57
+ settledMicros: number;
58
+ settledUsdMicros?: number;
59
+ remainingCreditMicros: number;
60
+ reservedBalanceMicros?: number;
61
+ spentMicros?: number;
62
+ priceVersion?: string;
63
+ }
64
+
65
+ interface SellerBalanceSnapshot {
66
+ creditMicros: number;
67
+ reservedMicros: number;
68
+ spentMicros: number;
69
+ availableMicros: number;
70
+ }
71
+
72
+ function numericHeaderField(value: unknown): number | undefined {
73
+ if (typeof value === "number" && Number.isFinite(value)) {
74
+ return value;
75
+ }
76
+ if (typeof value === "string" && value.trim().length > 0) {
77
+ const parsed = Number(value);
78
+ return Number.isFinite(parsed) ? parsed : undefined;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ interface ResponsesStreamState {
84
+ itemId: string;
85
+ text: string;
86
+ contentPartStarted: boolean;
87
+ }
88
+
89
+ class ResponsesStreamNormalizer {
90
+ private pending = "";
91
+ private readonly state = new Map<string, ResponsesStreamState>();
92
+
93
+ public push(chunk: string): string {
94
+ this.pending += chunk;
95
+ const blocks = this.pending.split("\n\n");
96
+ this.pending = blocks.pop() || "";
97
+ return blocks
98
+ .map((block) => this.normalizeBlock(block))
99
+ .filter((block) => block.length > 0)
100
+ .join("\n\n");
101
+ }
102
+
103
+ public finish(): string {
104
+ if (!this.pending.trim()) {
105
+ return "";
106
+ }
107
+ const block = this.normalizeBlock(this.pending);
108
+ this.pending = "";
109
+ return block;
110
+ }
111
+
112
+ private normalizeBlock(block: string): string {
113
+ if (!block.trim()) {
114
+ return "";
115
+ }
116
+ // Each \n\n separates an event in SSE format
117
+ const subBlocks = block.split("\n\n");
118
+ const output: string[] = [];
119
+
120
+ for (const sub of subBlocks) {
121
+ if (!sub.trim() || sub.trim() === "data: [DONE]") {
122
+ if (sub.trim()) output.push(sub);
123
+ continue;
124
+ }
125
+
126
+ const lines = sub.split("\n");
127
+ const eventLine = lines.find((l) => l.startsWith("event:"));
128
+ const dataLine = lines.find((l) => l.startsWith("data:"));
129
+ if (!dataLine) {
130
+ output.push(sub);
131
+ continue;
132
+ }
133
+ const rawData = dataLine.replace(/^data:\s?/, "");
134
+ if (rawData === "[DONE]") {
135
+ output.push(sub);
136
+ continue;
137
+ }
138
+
139
+ let payload: any;
140
+ try {
141
+ payload = JSON.parse(rawData);
142
+ } catch {
143
+ output.push(sub);
144
+ continue;
145
+ }
146
+
147
+ const eventName =
148
+ (eventLine?.replace(/^event:\s?/, "") || payload?.type) as string;
149
+ if (!eventName || !eventName.startsWith("response.")) {
150
+ output.push(sub);
151
+ continue;
152
+ }
153
+
154
+ // When upstream already sends content_part.added, record it in state
155
+ if (
156
+ eventName === "response.content_part.added" &&
157
+ payload?.item_id
158
+ ) {
159
+ const current = this.state.get(payload.item_id as string);
160
+ if (current) current.contentPartStarted = true;
161
+ output.push(sub);
162
+ continue;
163
+ }
164
+
165
+ // response.output_item.added: inject content_part.added only if upstream hasn't
166
+ if (
167
+ eventName === "response.output_item.added" &&
168
+ payload?.item?.type === "message" &&
169
+ payload?.item?.id
170
+ ) {
171
+ const itemId = payload.item.id as string;
172
+ const current = this.getState(itemId);
173
+ const item = { ...payload.item };
174
+ item.content = [{ type: "output_text", text: "", annotations: [] }];
175
+ output.push(this.serializeEvent(eventName, {
176
+ ...payload,
177
+ output_index: payload.output_index ?? 0,
178
+ item
179
+ }));
180
+ if (!current.contentPartStarted) {
181
+ current.contentPartStarted = true;
182
+ output.push(this.serializeEvent("response.content_part.added", {
183
+ type: "response.content_part.added",
184
+ item_id: itemId,
185
+ output_index: payload.output_index ?? 0,
186
+ content_index: 0,
187
+ part: { type: "output_text", text: "", annotations: [] }
188
+ }));
189
+ }
190
+ continue;
191
+ }
192
+
193
+ // response.output_text.delta: inject content_part.added if missing
194
+ if (eventName === "response.output_text.delta" && payload?.item_id) {
195
+ const itemId = payload.item_id as string;
196
+ const current = this.getState(itemId);
197
+ if (!current.contentPartStarted) {
198
+ current.contentPartStarted = true;
199
+ output.push(this.serializeEvent("response.content_part.added", {
200
+ type: "response.content_part.added",
201
+ item_id: itemId,
202
+ output_index: payload.output_index ?? 0,
203
+ content_index: payload.content_index ?? 0,
204
+ part: { type: "output_text", text: "", annotations: [] }
205
+ }));
206
+ }
207
+ const deltaText =
208
+ typeof payload.delta === "string"
209
+ ? payload.delta
210
+ : typeof payload.delta?.text === "string"
211
+ ? payload.delta.text
212
+ : "";
213
+ current.text += deltaText;
214
+ output.push(this.serializeEvent(eventName, {
215
+ ...payload,
216
+ output_index: payload.output_index ?? 0,
217
+ content_index: payload.content_index ?? 0
218
+ }));
219
+ continue;
220
+ }
221
+
222
+ // response.output_text.done: also emit content_part.done
223
+ if (eventName === "response.output_text.done" && payload?.item_id) {
224
+ const itemId = payload.item_id as string;
225
+ const current = this.getState(itemId);
226
+ output.push(this.serializeEvent(eventName, {
227
+ ...payload,
228
+ output_index: payload.output_index ?? 0,
229
+ content_index: payload.content_index ?? 0
230
+ }));
231
+ output.push(this.serializeEvent("response.content_part.done", {
232
+ type: "response.content_part.done",
233
+ item_id: itemId,
234
+ output_index: payload.output_index ?? 0,
235
+ content_index: payload.content_index ?? 0,
236
+ part: { type: "output_text", text: current.text, annotations: [] }
237
+ }));
238
+ continue;
239
+ }
240
+
241
+ // response.output_item.done: normalize content to output_text type
242
+ if (
243
+ eventName === "response.output_item.done" &&
244
+ payload?.item?.type === "message" &&
245
+ payload?.item?.id
246
+ ) {
247
+ const itemId = payload.item.id as string;
248
+ const current = this.getState(itemId);
249
+ const item = {
250
+ ...payload.item,
251
+ content: [{ type: "output_text", text: current.text, annotations: [] }]
252
+ };
253
+ output.push(this.serializeEvent(eventName, {
254
+ ...payload,
255
+ output_index: payload.output_index ?? 0,
256
+ item
257
+ }));
258
+ continue;
259
+ }
260
+
261
+ // response.completed: patch output if empty
262
+ if (eventName === "response.completed" && payload?.response) {
263
+ const response = { ...payload.response };
264
+ if (!Array.isArray(response.output) || response.output.length === 0) {
265
+ const first = this.state.values().next()
266
+ .value as ResponsesStreamState | undefined;
267
+ if (first) {
268
+ response.output = [{
269
+ id: first.itemId,
270
+ type: "message",
271
+ status: "completed",
272
+ role: "assistant",
273
+ content: [{ type: "output_text", text: first.text, annotations: [] }]
274
+ }];
275
+ response.output_text = first.text;
276
+ }
277
+ }
278
+ output.push(this.serializeEvent(eventName, { ...payload, response }));
279
+ continue;
280
+ }
281
+
282
+ // All other events: pass through unchanged
283
+ output.push(sub);
284
+ }
285
+
286
+ return output.join("\n\n");
287
+ }
288
+
289
+ private getState(itemId: string): ResponsesStreamState {
290
+ const current = this.state.get(itemId);
291
+ if (current) return current;
292
+ const created = { itemId, text: "", contentPartStarted: false };
293
+ this.state.set(itemId, created);
294
+ return created;
295
+ }
296
+
297
+ private serializeEvent(name: string, data: any): string {
298
+ return `event: ${name}\ndata: ${JSON.stringify(data)}`;
299
+ }
300
+ }
301
+
302
+ class SellerSettlementStreamExtractor {
303
+ private pending = "";
304
+ private settlement: SellerSettlementSummary | undefined;
305
+
306
+ public push(chunk: string): string {
307
+ this.pending += chunk;
308
+ const blocks = this.pending.split("\n\n");
309
+ this.pending = blocks.pop() || "";
310
+ return blocks
311
+ .map((block) => this.processBlock(block))
312
+ .filter((block) => block.length > 0)
313
+ .join("\n\n");
314
+ }
315
+
316
+ public finish(): { downstream: string; settlement: SellerSettlementSummary | undefined } {
317
+ const downstream = this.pending.trim() ? this.processBlock(this.pending) : "";
318
+ this.pending = "";
319
+ return { downstream, settlement: this.settlement };
320
+ }
321
+
322
+ public current(): SellerSettlementSummary | undefined {
323
+ return this.settlement;
324
+ }
325
+
326
+ private processBlock(block: string): string {
327
+ if (!block.trim()) {
328
+ return "";
329
+ }
330
+ const lines = block.split("\n");
331
+ const eventLine = lines.find((line) => line.startsWith("event:"));
332
+ const eventName = eventLine?.replace(/^event:\s?/, "").trim();
333
+ if (eventName !== "tokenbuddy.settlement") {
334
+ return block;
335
+ }
336
+ const dataLine = lines.find((line) => line.startsWith("data:"));
337
+ if (!dataLine) {
338
+ return "";
339
+ }
340
+ const parsed = parseSellerSettlementObject(dataLine.replace(/^data:\s?/, ""));
341
+ if (parsed) {
342
+ this.settlement = parsed;
343
+ }
344
+ return "";
345
+ }
346
+ }
347
+
348
+ function parseSellerSettlementObject(raw: string): SellerSettlementSummary | undefined {
349
+ try {
350
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
351
+ const requestId = typeof parsed.requestId === "string"
352
+ ? parsed.requestId
353
+ : typeof parsed.request_id === "string"
354
+ ? parsed.request_id
355
+ : undefined;
356
+ const settledMicros = numericHeaderField(parsed.settledMicros ?? parsed.settled_micros);
357
+ const remainingCreditMicros = numericHeaderField(parsed.remainingCreditMicros ?? parsed.remaining_credit_micros);
358
+ if (!requestId || settledMicros === undefined || remainingCreditMicros === undefined) {
359
+ return undefined;
360
+ }
361
+ return {
362
+ requestId,
363
+ settledMicros,
364
+ settledUsdMicros: numericHeaderField(parsed.settledUsdMicros ?? parsed.settled_usd_micros),
365
+ remainingCreditMicros,
366
+ reservedBalanceMicros: numericHeaderField(parsed.reservedBalanceMicros ?? parsed.reserved_balance_micros),
367
+ spentMicros: numericHeaderField(parsed.spentMicros ?? parsed.spent_micros),
368
+ priceVersion: typeof parsed.priceVersion === "string"
369
+ ? parsed.priceVersion
370
+ : typeof parsed.price_version === "string"
371
+ ? parsed.price_version
372
+ : undefined
373
+ };
374
+ } catch {
375
+ return undefined;
376
+ }
377
+ }
378
+
64
379
  interface PurchaseCreateResponse {
65
380
  purchaseId?: string;
66
381
  purchase_id?: string;
@@ -91,13 +406,22 @@ export class TokenbuddyDaemon {
91
406
  private controlServer?: any;
92
407
  private proxyServer?: any;
93
408
  private selectionMode: "auto" | "manual";
409
+ private selectedSellerId?: string;
94
410
 
95
411
  private activePurchases = new Map<string, Promise<string>>();
96
412
 
97
413
  constructor(config: DaemonConfig) {
98
- this.config = config;
99
- this.selectionMode = config.selectionMode || "auto";
100
414
  this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
415
+ const routingPreference =
416
+ this.tokenStore.getDaemonRuntimeConfig<SellerRoutingPreference>("routing")
417
+ ?.config;
418
+ this.config = config;
419
+ this.selectionMode =
420
+ config.selectionMode ||
421
+ (routingPreference?.mode === "fixed" ? "manual" : routingPreference?.mode) ||
422
+ "auto";
423
+ this.selectedSellerId =
424
+ config.selectedSellerId || routingPreference?.sellerId;
101
425
  }
102
426
 
103
427
  private activeControlPort(): number {
@@ -111,43 +435,25 @@ export class TokenbuddyDaemon {
111
435
  }
112
436
 
113
437
  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;
438
+ return await fetchSellerRegistry(this.config.sellerRegistryUrl);
132
439
  }
133
440
 
134
441
  private runtimeSummary() {
442
+ const sellerRoutingMode = this.selectedSellerId ? "fixed" : this.selectionMode;
135
443
  return {
136
444
  status: "running",
137
445
  pid: process.pid,
138
446
  controlPort: this.activeControlPort(),
139
447
  proxyPort: this.activeProxyPort(),
140
448
  selectionMode: this.selectionMode,
449
+ sellerRoutingMode,
450
+ selectedSellerId: this.selectedSellerId,
141
451
  dbPath: this.config.dbPath,
142
452
  sellerRegistryUrl: this.config.sellerRegistryUrl,
143
453
  store: this.tokenStore.summary()
144
454
  };
145
455
  }
146
456
 
147
- private normalizeSellerUrl(seller: RegistrySeller): string {
148
- return seller.url.replace(/\/+$/, "");
149
- }
150
-
151
457
  private endpointProtocol(endpoint: string): string | undefined {
152
458
  if (endpoint === "/v1/chat/completions") {
153
459
  return "chat_completions";
@@ -173,19 +479,63 @@ export class TokenbuddyDaemon {
173
479
  return undefined;
174
480
  }
175
481
 
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;
482
+ private stripLocalClaudeOneMMarker(modelId: string): string {
483
+ const trimmed = modelId.trimEnd();
484
+ return trimmed.toLowerCase().endsWith("[1m]")
485
+ ? trimmed.slice(0, -4).trimEnd()
486
+ : trimmed;
487
+ }
488
+
489
+ private resolveClaudeRoleModel(modelId: string): string {
490
+ const runtimeConfig = this.tokenStore.getProviderRuntimeConfig<ClaudeCodeModelMappingConfig>("claude-code")?.config;
491
+ const stripped = this.stripLocalClaudeOneMMarker(modelId);
492
+ if (!runtimeConfig || runtimeConfig.selectionKind !== "claude-role-mapping") {
493
+ return stripped;
494
+ }
495
+
496
+ const lowered = stripped.toLowerCase();
497
+ if (lowered === "haiku" || lowered.startsWith("claude-haiku")) {
498
+ return runtimeConfig.roles.haiku?.upstreamModel || runtimeConfig.fallbackModel || stripped;
499
+ }
500
+ if (lowered === "sonnet" || lowered.startsWith("claude-sonnet")) {
501
+ return runtimeConfig.roles.sonnet?.upstreamModel || runtimeConfig.fallbackModel || stripped;
502
+ }
503
+ if (lowered === "opus" || lowered.startsWith("claude-opus")) {
504
+ return runtimeConfig.roles.opus?.upstreamModel || runtimeConfig.fallbackModel || stripped;
505
+ }
506
+ return runtimeConfig.fallbackModel || stripped;
181
507
  }
182
508
 
183
- private manifestPaymentMethods(manifest: SellerManifest, seller: RegistrySeller): string[] {
184
- return manifest.paymentMethods || manifest.payment_methods || seller.paymentMethods || [];
509
+ private resolveRouteModelId(endpoint: string, body: unknown): { requestedModelId?: string; resolvedModelId?: string } {
510
+ const requestedModelId = this.extractModelId(endpoint, body);
511
+ if (!requestedModelId) {
512
+ return {};
513
+ }
514
+ if (this.endpointProtocol(endpoint) === "messages") {
515
+ return {
516
+ requestedModelId,
517
+ resolvedModelId: this.resolveClaudeRoleModel(requestedModelId)
518
+ };
519
+ }
520
+ return {
521
+ requestedModelId,
522
+ resolvedModelId: requestedModelId
523
+ };
185
524
  }
186
525
 
187
- private manifestModelIds(manifest: SellerManifest): string[] {
188
- return (manifest.models || []).map((model) => model.id).filter((id) => typeof id === "string" && id.length > 0);
526
+ private applyResolvedModelToBody(
527
+ endpoint: string,
528
+ body: Record<string, unknown>,
529
+ resolvedModelId: string
530
+ ): Record<string, unknown> {
531
+ const nextBody: Record<string, unknown> = { ...body };
532
+ if ("model" in nextBody) {
533
+ nextBody.model = resolvedModelId;
534
+ }
535
+ if (endpoint === "/v1/responses" && "model_id" in nextBody) {
536
+ nextBody.model_id = resolvedModelId;
537
+ }
538
+ return nextBody;
189
539
  }
190
540
 
191
541
  private defaultPaymentMethod(): string | undefined {
@@ -206,13 +556,16 @@ export class TokenbuddyDaemon {
206
556
  const registry = await this.fetchRegistry();
207
557
  const defaultSellers = registry.sellers.filter((seller) => seller.id === registry.defaultSeller);
208
558
  const backupSellers = registry.sellers.filter((seller) => seller.id !== registry.defaultSeller);
209
- const sellers = this.selectionMode === "manual" ? defaultSellers : [...defaultSellers, ...backupSellers];
559
+ const manualSellers = this.selectedSellerId
560
+ ? registry.sellers.filter((seller) => seller.id === this.selectedSellerId)
561
+ : defaultSellers;
562
+ const sellers = this.selectionMode === "manual" ? manualSellers : [...defaultSellers, ...backupSellers];
210
563
 
211
564
  const routes: SellerRoute[] = [];
212
565
  for (const seller of sellers) {
213
566
  let manifest: SellerManifest;
214
567
  try {
215
- manifest = await this.fetchSellerManifest(seller);
568
+ manifest = await fetchSellerManifest(seller);
216
569
  } catch (error: unknown) {
217
570
  logger.warn("route.manifest.failed", "seller manifest unavailable during route selection", {
218
571
  sellerKey: seller.id,
@@ -223,9 +576,9 @@ export class TokenbuddyDaemon {
223
576
  continue;
224
577
  }
225
578
 
226
- const protocols = this.manifestProtocols(manifest, seller);
227
- const paymentMethods = this.manifestPaymentMethods(manifest, seller);
228
- const modelIds = this.manifestModelIds(manifest);
579
+ const protocols = manifestProtocols(manifest, seller);
580
+ const paymentMethods = manifestPaymentMethods(manifest, seller);
581
+ const modelIds = manifestModelIds(manifest);
229
582
  if (!protocols.includes(protocol) || !paymentMethods.includes(paymentMethod) || !modelIds.includes(modelId)) {
230
583
  continue;
231
584
  }
@@ -303,51 +656,10 @@ export class TokenbuddyDaemon {
303
656
  models: Array<{ id: string; sellerId: string; sellerName?: string; sellerUrl: string; supportedProtocols: string[]; paymentMethods: string[] }>;
304
657
  sellers: Array<{ id: string; name?: string; url: string; status: string; manifestSellerId?: string; errorMessage?: string }>;
305
658
  }> {
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
- }));
659
+ const catalog = await discoverSellerBackedModels(this.config.sellerRegistryUrl);
348
660
  return {
349
- models: sellerResults.flatMap((result) => result.models),
350
- sellers: sellerResults.map((result) => result.seller)
661
+ models: catalog.models,
662
+ sellers: catalog.sellers
351
663
  };
352
664
  }
353
665
 
@@ -381,6 +693,138 @@ export class TokenbuddyDaemon {
381
693
  }
382
694
  }
383
695
 
696
+ private parseSellerSettlementSummary(headers: Headers): SellerSettlementSummary | undefined {
697
+ const raw = headers.get("x-tokenbuddy-settlement");
698
+ if (!raw) {
699
+ return undefined;
700
+ }
701
+ return parseSellerSettlementObject(raw);
702
+ }
703
+
704
+ private recordReconciledInference(
705
+ route: SellerRoute,
706
+ endpoint: string,
707
+ requestId: string,
708
+ usage: UsageSummary,
709
+ settlement: SellerSettlementSummary | undefined,
710
+ prompt: string | undefined,
711
+ response?: string
712
+ ): void {
713
+ if (settlement) {
714
+ this.tokenStore.reconcileTokenBalance({
715
+ sellerKey: route.seller.id,
716
+ balanceMicros: settlement.remainingCreditMicros,
717
+ reservedMicros: settlement.reservedBalanceMicros ?? 0,
718
+ spentMicros: settlement.spentMicros ?? 0,
719
+ balanceSource: "seller_settlement_summary"
720
+ });
721
+ }
722
+
723
+ const settledMicros = settlement?.settledMicros;
724
+ this.tokenStore.recordInferenceLedger({
725
+ requestId: settlement?.requestId || requestId,
726
+ sellerKey: route.seller.id,
727
+ modelId: route.modelId,
728
+ endpoint,
729
+ status: settlement ? "settled" : "estimated",
730
+ promptTokens: usage.promptTokens,
731
+ completionTokens: usage.completionTokens,
732
+ billedMicros: settledMicros ?? usage.billedMicros,
733
+ estimatedMicros: usage.billedMicros,
734
+ settledMicros,
735
+ settledUsdMicros: settlement?.settledUsdMicros,
736
+ priceVersion: settlement?.priceVersion,
737
+ balanceSnapshotMicros: settlement?.remainingCreditMicros,
738
+ balanceSource: settlement ? "seller_authoritative" : "estimated",
739
+ prompt,
740
+ response
741
+ });
742
+ logger.info("inference.ledger.recorded", "safe inference ledger recorded", {
743
+ requestId: settlement?.requestId || requestId,
744
+ sellerKey: route.seller.id,
745
+ model: route.modelId,
746
+ endpoint,
747
+ status: settlement ? "settled" : "estimated",
748
+ estimatedMicros: usage.billedMicros,
749
+ settledMicros,
750
+ balanceSource: settlement ? "seller_authoritative" : "estimated"
751
+ });
752
+ }
753
+
754
+ private async refreshSellerBalance(route: SellerRoute, token: string, balanceSource: string): Promise<SellerBalanceSnapshot | undefined> {
755
+ const sellerKey = route.seller.id;
756
+ const sellerUrl = normalizeSellerUrl(route.seller);
757
+ const response = await fetch(`${sellerUrl}/v1/balance`, {
758
+ headers: { "Authorization": `Bearer ${token}` }
759
+ });
760
+ if (!response.ok) {
761
+ logger.warn("token.balance_refresh.failed", "seller balance refresh failed", {
762
+ sellerKey,
763
+ model: route.modelId,
764
+ status: response.status
765
+ });
766
+ return undefined;
767
+ }
768
+ const data = await response.json() as Record<string, unknown>;
769
+ const creditMicros = numericHeaderField(data.creditMicros ?? data.credit_micros) ?? 0;
770
+ const reservedMicros = numericHeaderField(data.reservedMicros ?? data.reserved_micros) ?? 0;
771
+ const spentMicros = numericHeaderField(data.spentMicros ?? data.spent_micros) ?? 0;
772
+ const snapshot = {
773
+ creditMicros,
774
+ reservedMicros,
775
+ spentMicros,
776
+ availableMicros: Math.max(0, creditMicros - reservedMicros)
777
+ };
778
+ this.tokenStore.reconcileTokenBalance({
779
+ sellerKey,
780
+ balanceMicros: snapshot.availableMicros,
781
+ reservedMicros,
782
+ spentMicros,
783
+ balanceSource
784
+ });
785
+ logger.info("token.balance_refresh.succeeded", "seller balance refreshed", {
786
+ sellerKey,
787
+ model: route.modelId,
788
+ availableMicros: snapshot.availableMicros,
789
+ reservedMicros,
790
+ spentMicros,
791
+ balanceSource
792
+ });
793
+ return snapshot;
794
+ }
795
+
796
+ private isInsufficientFundsResponse(status: number, bodyText: string): boolean {
797
+ if (status !== 402) {
798
+ return false;
799
+ }
800
+ try {
801
+ const parsed = JSON.parse(bodyText) as { error?: { code?: string; message?: string } };
802
+ const code = parsed.error?.code || "";
803
+ const message = parsed.error?.message || "";
804
+ return code === "insufficient_funds" || /insufficient funds/i.test(message);
805
+ } catch {
806
+ return /insufficient funds/i.test(bodyText);
807
+ }
808
+ }
809
+
810
+ private async recoverFromInsufficientFunds(route: SellerRoute, token: string): Promise<string> {
811
+ const sellerKey = route.seller.id;
812
+ this.tokenStore.markTokenStale(sellerKey);
813
+ const snapshot = await this.refreshSellerBalance(route, token, "seller_402_refresh");
814
+ const rebuyMinBalanceMicros = this.tokenRebuyMinBalanceMicros();
815
+ if (!snapshot || snapshot.availableMicros <= rebuyMinBalanceMicros) {
816
+ logger.info("purchase.retry_after_402.started", "seller 402 triggered one-shot auto purchase retry", {
817
+ sellerKey,
818
+ model: route.modelId,
819
+ availableMicros: snapshot?.availableMicros ?? 0,
820
+ rebuyMinBalanceMicros
821
+ });
822
+ return await this.getOrPurchaseToken(route);
823
+ }
824
+ const cached = this.tokenStore.getToken(sellerKey);
825
+ return cached?.token || token;
826
+ }
827
+
384
828
  private inferPromptForHash(body: unknown): string | undefined {
385
829
  if (!body || typeof body !== "object") {
386
830
  return undefined;
@@ -409,7 +853,7 @@ export class TokenbuddyDaemon {
409
853
 
410
854
  private async getOrPurchaseToken(route: SellerRoute): Promise<string> {
411
855
  const sellerKey = route.seller.id;
412
- const sellerUrl = this.normalizeSellerUrl(route.seller);
856
+ const sellerUrl = normalizeSellerUrl(route.seller);
413
857
  const { modelId, paymentMethod } = route;
414
858
  const cached = this.tokenStore.getToken(sellerKey);
415
859
  const rebuyMinBalanceMicros = this.tokenRebuyMinBalanceMicros();
@@ -600,7 +1044,7 @@ export class TokenbuddyDaemon {
600
1044
  const timeoutMs = this.clawtipProofTimeoutMs();
601
1045
  const payload = JSON.stringify({
602
1046
  sellerKey: route.seller.id,
603
- sellerUrl: this.normalizeSellerUrl(route.seller),
1047
+ sellerUrl: normalizeSellerUrl(route.seller),
604
1048
  modelId: route.modelId,
605
1049
  purchase: createData,
606
1050
  paymentInstructions: createData.paymentInstructions || createData.payment_instructions,
@@ -686,7 +1130,7 @@ export class TokenbuddyDaemon {
686
1130
  private copyUpstreamHeaders(upstreamResponse: globalThis.Response, res: Response): void {
687
1131
  upstreamResponse.headers.forEach((value, key) => {
688
1132
  const lowerKey = key.toLowerCase();
689
- if (["connection", "keep-alive", "transfer-encoding", "upgrade"].includes(lowerKey)) {
1133
+ if (["connection", "content-encoding", "content-length", "keep-alive", "transfer-encoding", "upgrade"].includes(lowerKey)) {
690
1134
  return;
691
1135
  }
692
1136
  res.setHeader(key, value);
@@ -696,7 +1140,8 @@ export class TokenbuddyDaemon {
696
1140
  private async forwardProxyRequest(endpoint: string, req: Request, res: Response): Promise<void> {
697
1141
  const startedAt = Date.now();
698
1142
  const body = req.body || {};
699
- const modelId = this.extractModelId(endpoint, body);
1143
+ const { requestedModelId, resolvedModelId } = this.resolveRouteModelId(endpoint, body);
1144
+ const modelId = resolvedModelId;
700
1145
  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
1146
  const idempotencyKey = req.header("idempotency-key") || `idem_${crypto.randomBytes(12).toString("hex")}`;
702
1147
 
@@ -718,24 +1163,16 @@ export class TokenbuddyDaemon {
718
1163
  requestId,
719
1164
  sellerKey,
720
1165
  model: modelId,
1166
+ requestedModel: requestedModelId,
721
1167
  endpoint,
722
1168
  stream: Boolean((body as { stream?: unknown }).stream)
723
1169
  });
724
- const token = await this.getOrPurchaseToken(route);
725
- const sellerUrl = this.normalizeSellerUrl(route.seller);
726
- const upstreamBody = {
1170
+ const sellerUrl = normalizeSellerUrl(route.seller);
1171
+ const upstreamBody = this.applyResolvedModelToBody(endpoint, {
727
1172
  ...(body as Record<string, unknown>),
728
1173
  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}`, {
1174
+ }, modelId);
1175
+ const sendSellerRequest = async (token: string) => fetch(`${sellerUrl}${endpoint}`, {
739
1176
  method: "POST",
740
1177
  headers: {
741
1178
  "Content-Type": "application/json",
@@ -746,8 +1183,45 @@ export class TokenbuddyDaemon {
746
1183
  body: JSON.stringify(upstreamBody)
747
1184
  });
748
1185
 
1186
+ logger.info("proxy.upstream_fetch.started", "proxy upstream fetch started", {
1187
+ requestId,
1188
+ sellerKey,
1189
+ model: modelId,
1190
+ endpoint,
1191
+ stream: Boolean((body as { stream?: unknown }).stream)
1192
+ });
1193
+ let token = await this.getOrPurchaseToken(route);
1194
+ let upstreamResponse = await sendSellerRequest(token);
1195
+
749
1196
  if (!upstreamResponse.ok) {
750
1197
  const errorBody = await upstreamResponse.text();
1198
+ if (this.isInsufficientFundsResponse(upstreamResponse.status, errorBody)) {
1199
+ token = await this.recoverFromInsufficientFunds(route, token);
1200
+ upstreamResponse = await sendSellerRequest(token);
1201
+ if (upstreamResponse.ok) {
1202
+ logger.info("proxy.retry_after_402.succeeded", "seller request succeeded after one-shot auto purchase retry", {
1203
+ requestId,
1204
+ sellerKey,
1205
+ model: modelId,
1206
+ endpoint,
1207
+ durationMs: Date.now() - startedAt
1208
+ });
1209
+ } else {
1210
+ const retryErrorBody = await upstreamResponse.text();
1211
+ logger.warn("proxy.retry_after_402.failed", "seller request still failed after one-shot auto purchase retry", {
1212
+ requestId,
1213
+ sellerKey,
1214
+ model: modelId,
1215
+ endpoint,
1216
+ status: upstreamResponse.status,
1217
+ durationMs: Date.now() - startedAt
1218
+ });
1219
+ this.copyUpstreamHeaders(upstreamResponse, res);
1220
+ res.status(upstreamResponse.status);
1221
+ res.send(retryErrorBody);
1222
+ return;
1223
+ }
1224
+ } else {
751
1225
  logger.warn("proxy.upstream_fetch.failed", "proxy upstream fetch returned non-ok status", {
752
1226
  requestId,
753
1227
  sellerKey,
@@ -765,6 +1239,7 @@ export class TokenbuddyDaemon {
765
1239
  res.status(upstreamResponse.status);
766
1240
  res.send(errorBody);
767
1241
  return;
1242
+ }
768
1243
  }
769
1244
 
770
1245
  this.copyUpstreamHeaders(upstreamResponse, res);
@@ -786,65 +1261,70 @@ export class TokenbuddyDaemon {
786
1261
  return;
787
1262
  }
788
1263
  let bytes = 0;
1264
+ const decoder = new TextDecoder();
1265
+ const responsesStreamNormalizer = new ResponsesStreamNormalizer();
1266
+ const settlementExtractor = new SellerSettlementStreamExtractor();
789
1267
  while (true) {
790
1268
  const { done, value } = await reader.read();
791
1269
  if (done) {
792
1270
  break;
793
1271
  }
794
1272
  bytes += value.byteLength;
795
- res.write(Buffer.from(value));
1273
+ const chunk = decoder.decode(value, { stream: true });
1274
+ const sellerChunk = settlementExtractor.push(chunk);
1275
+ if (sellerChunk.length === 0) {
1276
+ continue;
1277
+ }
1278
+ if (endpoint === "/v1/responses") {
1279
+ const normalized = responsesStreamNormalizer.push(sellerChunk);
1280
+ if (normalized.length > 0) {
1281
+ res.write(`${normalized}\n\n`);
1282
+ }
1283
+ } else {
1284
+ res.write(sellerChunk);
1285
+ }
1286
+ }
1287
+ const settlementTrailing = settlementExtractor.finish();
1288
+ if (settlementTrailing.downstream.length > 0) {
1289
+ if (endpoint === "/v1/responses") {
1290
+ const normalized = responsesStreamNormalizer.push(settlementTrailing.downstream);
1291
+ if (normalized.length > 0) {
1292
+ res.write(`${normalized}\n\n`);
1293
+ }
1294
+ } else {
1295
+ res.write(settlementTrailing.downstream);
1296
+ }
1297
+ }
1298
+ if (endpoint === "/v1/responses") {
1299
+ const trailing = responsesStreamNormalizer.finish();
1300
+ if (trailing.length > 0) {
1301
+ res.write(`${trailing}\n\n`);
1302
+ }
796
1303
  }
797
1304
  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,
1305
+ this.recordReconciledInference(
1306
+ route,
804
1307
  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
1308
  requestId,
813
- sellerKey,
814
- model: modelId,
815
- endpoint,
816
- status: "settled",
817
- billedMicros
818
- });
1309
+ { promptTokens: 0, completionTokens: 0, billedMicros: Math.max(1, bytes) },
1310
+ this.parseSellerSettlementSummary(upstreamResponse.headers) ?? settlementTrailing.settlement ?? settlementExtractor.current(),
1311
+ this.inferPromptForHash(body)
1312
+ );
819
1313
  return;
820
1314
  }
821
1315
 
822
1316
  const responseBody = await upstreamResponse.text();
823
1317
  res.send(responseBody);
824
1318
  const usage = this.readUsage(responseBody);
825
- this.tokenStore.deductBalance(sellerKey, usage.billedMicros);
826
- this.tokenStore.recordInferenceLedger({
827
- requestId,
828
- sellerKey,
829
- modelId,
1319
+ this.recordReconciledInference(
1320
+ route,
830
1321
  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
1322
  requestId,
840
- sellerKey,
841
- model: modelId,
842
- endpoint,
843
- status: "settled",
844
- promptTokens: usage.promptTokens,
845
- completionTokens: usage.completionTokens,
846
- billedMicros: usage.billedMicros
847
- });
1323
+ usage,
1324
+ this.parseSellerSettlementSummary(upstreamResponse.headers),
1325
+ this.inferPromptForHash(body),
1326
+ responseBody
1327
+ );
848
1328
  return;
849
1329
  } catch (routeError: unknown) {
850
1330
  lastError = routeError;
@@ -1026,7 +1506,9 @@ export class TokenbuddyDaemon {
1026
1506
  const changes = previewProviderInstall({
1027
1507
  providers: Array.isArray(req.body?.providers) ? req.body.providers : [],
1028
1508
  proxyUrl: String(req.body?.proxyUrl || ""),
1029
- model: String(req.body?.model || ""),
1509
+ model: typeof req.body?.model === "string" ? req.body.model : undefined,
1510
+ providerSelections: req.body?.providerSelections,
1511
+ sellerRouting: req.body?.sellerRouting,
1030
1512
  home: typeof req.body?.home === "string" ? req.body.home : undefined
1031
1513
  });
1032
1514
  logger.info("provider.install.previewed", "provider install previewed", {
@@ -1053,7 +1535,9 @@ export class TokenbuddyDaemon {
1053
1535
  const applied = applyProviderInstall({
1054
1536
  providers: Array.isArray(req.body?.providers) ? req.body.providers : [],
1055
1537
  proxyUrl: String(req.body?.proxyUrl || ""),
1056
- model: String(req.body?.model || ""),
1538
+ model: typeof req.body?.model === "string" ? req.body.model : undefined,
1539
+ providerSelections: req.body?.providerSelections,
1540
+ sellerRouting: req.body?.sellerRouting,
1057
1541
  home: typeof req.body?.home === "string" ? req.body.home : undefined
1058
1542
  }, this.tokenStore);
1059
1543
  logger.info("provider.install.applied", "provider install applied", {