@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.
Files changed (42) hide show
  1. package/dist/src/buyer-store.d.ts +20 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +73 -1
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts.map +1 -1
  6. package/dist/src/cli.js +390 -62
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/daemon.d.ts +6 -5
  9. package/dist/src/daemon.d.ts.map +1 -1
  10. package/dist/src/daemon.js +298 -92
  11. package/dist/src/daemon.js.map +1 -1
  12. package/dist/src/doctor-diagnostics.d.ts +97 -0
  13. package/dist/src/doctor-diagnostics.d.ts.map +1 -0
  14. package/dist/src/doctor-diagnostics.js +547 -0
  15. package/dist/src/doctor-diagnostics.js.map +1 -0
  16. package/dist/src/init-payment-options.d.ts +34 -0
  17. package/dist/src/init-payment-options.d.ts.map +1 -0
  18. package/dist/src/init-payment-options.js +90 -0
  19. package/dist/src/init-payment-options.js.map +1 -0
  20. package/dist/src/provider-install.d.ts +37 -2
  21. package/dist/src/provider-install.d.ts.map +1 -1
  22. package/dist/src/provider-install.js +317 -67
  23. package/dist/src/provider-install.js.map +1 -1
  24. package/dist/src/seller-catalog.d.ts +79 -0
  25. package/dist/src/seller-catalog.d.ts.map +1 -0
  26. package/dist/src/seller-catalog.js +126 -0
  27. package/dist/src/seller-catalog.js.map +1 -0
  28. package/dist/src/tb-proxyd.js +13 -2
  29. package/dist/src/tb-proxyd.js.map +1 -1
  30. package/package.json +4 -4
  31. package/src/buyer-store.ts +113 -1
  32. package/src/cli.ts +490 -67
  33. package/src/daemon.ts +346 -117
  34. package/src/doctor-diagnostics.ts +850 -0
  35. package/src/init-payment-options.ts +131 -0
  36. package/src/provider-install.ts +426 -76
  37. package/src/seller-catalog.ts +222 -0
  38. package/src/tb-proxyd.ts +14 -2
  39. package/tests/e2e.test.ts +9 -0
  40. package/tests/tokenbuddy.test.ts +628 -19
  41. package/bin/tb-proxyd.js +0 -2
  42. package/bin/tb.js +0 -3
@@ -0,0 +1,850 @@
1
+ import { BuyerStore } from "./buyer-store.js";
2
+ import Table from "cli-table3";
3
+ import {
4
+ detectProviders,
5
+ PROXY_ACCESS_TOKEN_PLACEHOLDER,
6
+ SUPPORTED_PROVIDER_IDS,
7
+ type ProviderCandidate,
8
+ type ProviderId,
9
+ type ProviderRuntimeConfig,
10
+ } from "./provider-install.js";
11
+ import {
12
+ discoverSellerBackedModels,
13
+ type ModelCatalogEntry,
14
+ type SellerCatalogEntry,
15
+ } from "./seller-catalog.js";
16
+
17
+ export interface DoctorProviderView extends ProviderCandidate {
18
+ runtimeConfig?: ProviderRuntimeConfig;
19
+ runtimeConfigUpdatedAt?: string;
20
+ }
21
+
22
+ export interface DoctorSellerEntry {
23
+ id: string;
24
+ name?: string;
25
+ url: string;
26
+ status: string;
27
+ discountRatio?: number;
28
+ supportedProtocols?: string[];
29
+ paymentMethods?: string[];
30
+ manifestSellerId?: string;
31
+ modelCount?: number;
32
+ errorMessage?: string;
33
+ }
34
+
35
+ export interface DoctorDiagnostics {
36
+ access: DoctorAccessSummary;
37
+ models: DoctorModelsSummary;
38
+ providers: DoctorProviderView[];
39
+ sellers: DoctorSellersSummary;
40
+ }
41
+
42
+ interface RemoteJsonResult<T> {
43
+ url: string;
44
+ available: boolean;
45
+ statusCode?: number;
46
+ data?: T;
47
+ error?: string;
48
+ }
49
+
50
+ interface DoctorEndpointStatus {
51
+ id: string;
52
+ name: string;
53
+ url: string;
54
+ probeUrl?: string;
55
+ available: boolean;
56
+ requiresToken: boolean;
57
+ token?: string;
58
+ error?: string;
59
+ }
60
+
61
+ interface DoctorSellerResponse {
62
+ registryUrl?: string;
63
+ version?: number;
64
+ defaultSeller?: string;
65
+ sellers: DoctorSellerEntry[];
66
+ }
67
+
68
+ interface DoctorRegistryDocument {
69
+ version?: number;
70
+ defaultSeller?: string;
71
+ sellers: Array<{
72
+ id: string;
73
+ name?: string;
74
+ url: string;
75
+ supportedProtocols?: string[];
76
+ paymentMethods?: string[];
77
+ }>;
78
+ }
79
+
80
+ interface DoctorModelsResponse {
81
+ object?: string;
82
+ registryUrl?: string;
83
+ data: ModelCatalogEntry[];
84
+ sellers?: DoctorSellerEntry[];
85
+ }
86
+
87
+ export interface DoctorModelSummaryEntry {
88
+ id: string;
89
+ sellerCount: number;
90
+ discountMin?: number;
91
+ discountMax?: number;
92
+ discountRange: string;
93
+ inputPriceMinMicrosPer1m?: number;
94
+ inputPriceMaxMicrosPer1m?: number;
95
+ outputPriceMinMicrosPer1m?: number;
96
+ outputPriceMaxMicrosPer1m?: number;
97
+ priceRange: string;
98
+ }
99
+
100
+ interface DoctorFetchResults {
101
+ healthResult: RemoteJsonResult<Record<string, unknown>>;
102
+ sellersResult: RemoteJsonResult<DoctorSellerResponse>;
103
+ modelsResult: RemoteJsonResult<DoctorModelsResponse>;
104
+ proxyModelsResult: RemoteJsonResult<{ object?: string; data?: Array<{ id?: string }> }>;
105
+ registryResult: RemoteJsonResult<DoctorRegistryDocument>;
106
+ }
107
+
108
+ interface DoctorFetchPromises {
109
+ healthResult: Promise<RemoteJsonResult<Record<string, unknown>>>;
110
+ sellersResult: Promise<RemoteJsonResult<DoctorSellerResponse>>;
111
+ modelsResult: Promise<RemoteJsonResult<DoctorModelsResponse>>;
112
+ proxyModelsResult: Promise<RemoteJsonResult<{ object?: string; data?: Array<{ id?: string }> }>>;
113
+ registryResult: Promise<RemoteJsonResult<DoctorRegistryDocument>>;
114
+ }
115
+
116
+ interface DoctorAccessAvailability {
117
+ sellersAvailable?: boolean;
118
+ sellersError?: string;
119
+ modelsAvailable?: boolean;
120
+ modelsError?: string;
121
+ }
122
+
123
+ interface DoctorRenderOptions {
124
+ controlPort: number;
125
+ proxyPort: number;
126
+ daemonRunning: boolean;
127
+ daemonError?: string;
128
+ sellerRegistryUrl?: string;
129
+ providers: DoctorProviderView[];
130
+ writeLine?: (line: string) => void;
131
+ }
132
+
133
+ interface DoctorCollectOptions {
134
+ controlPort: number;
135
+ proxyPort: number;
136
+ daemonRunning: boolean;
137
+ daemonError?: string;
138
+ sellerRegistryUrl?: string;
139
+ providers: DoctorProviderView[];
140
+ }
141
+
142
+ type DoctorAccessSummary = {
143
+ controlBaseUrl: string;
144
+ proxyBaseUrl: string;
145
+ openAiBaseUrl: string;
146
+ anthropicBaseUrl: string;
147
+ token: string;
148
+ endpoints: DoctorEndpointStatus[];
149
+ };
150
+
151
+ type DoctorSellersSummary = {
152
+ available: boolean;
153
+ registryUrl?: string;
154
+ version?: number;
155
+ defaultSeller?: string;
156
+ sellers: DoctorSellerEntry[];
157
+ error?: string;
158
+ };
159
+
160
+ export type DoctorModelsSummary = {
161
+ available: boolean;
162
+ count: number;
163
+ uniqueCount: number;
164
+ registryUrl?: string;
165
+ data: ModelCatalogEntry[];
166
+ grouped: DoctorModelSummaryEntry[];
167
+ sellers: DoctorSellerEntry[];
168
+ error?: string;
169
+ };
170
+
171
+ function parseJsonText(text: string): unknown {
172
+ if (!text.trim()) {
173
+ return undefined;
174
+ }
175
+ return JSON.parse(text) as unknown;
176
+ }
177
+
178
+ function extractRemoteError(payload: unknown, fallback: string): string {
179
+ if (payload && typeof payload === "object") {
180
+ if ("error" in payload) {
181
+ const error = (payload as { error?: unknown }).error;
182
+ if (typeof error === "string" && error.trim()) {
183
+ return error;
184
+ }
185
+ if (error && typeof error === "object" && "message" in error && typeof error.message === "string" && error.message.trim()) {
186
+ return error.message;
187
+ }
188
+ }
189
+ if ("message" in payload && typeof (payload as { message?: unknown }).message === "string" && (payload as { message: string }).message.trim()) {
190
+ return (payload as { message: string }).message;
191
+ }
192
+ }
193
+ return fallback;
194
+ }
195
+
196
+ async function fetchJsonDocument<T>(url: string): Promise<RemoteJsonResult<T>> {
197
+ try {
198
+ const response = await fetch(url);
199
+ const text = await response.text();
200
+ const payload = text ? parseJsonText(text) : undefined;
201
+ if (!response.ok) {
202
+ return {
203
+ url,
204
+ available: false,
205
+ statusCode: response.status,
206
+ error: extractRemoteError(payload, `HTTP ${response.status}`),
207
+ };
208
+ }
209
+ return {
210
+ url,
211
+ available: true,
212
+ statusCode: response.status,
213
+ data: payload as T,
214
+ };
215
+ } catch (error: unknown) {
216
+ return {
217
+ url,
218
+ available: false,
219
+ error: error instanceof Error ? error.message : String(error),
220
+ };
221
+ }
222
+ }
223
+
224
+ function providerRuntimeSummary(runtimeConfig?: ProviderRuntimeConfig): string | undefined {
225
+ if (!runtimeConfig) {
226
+ return undefined;
227
+ }
228
+ if (runtimeConfig.selectionKind === "single-model") {
229
+ return [
230
+ runtimeConfig.protocolPreference,
231
+ runtimeConfig.defaultModel,
232
+ runtimeConfig.sellerId ? `seller=${runtimeConfig.sellerId}` : undefined,
233
+ ].filter(Boolean).join(" · ");
234
+ }
235
+
236
+ const bindings = [
237
+ runtimeConfig.roles.haiku?.upstreamModel ? `haiku=${runtimeConfig.roles.haiku.upstreamModel}` : undefined,
238
+ runtimeConfig.roles.sonnet?.upstreamModel ? `sonnet=${runtimeConfig.roles.sonnet.upstreamModel}` : undefined,
239
+ runtimeConfig.roles.opus?.upstreamModel ? `opus=${runtimeConfig.roles.opus.upstreamModel}` : undefined,
240
+ runtimeConfig.fallbackModel ? `fallback=${runtimeConfig.fallbackModel}` : undefined,
241
+ ].filter(Boolean);
242
+ return [runtimeConfig.protocolPreference, ...bindings].join(" · ");
243
+ }
244
+
245
+ function mergeDoctorSellerEntries(
246
+ configuredSellers: DoctorSellerEntry[],
247
+ probedSellers: DoctorSellerEntry[],
248
+ ): DoctorSellerEntry[] {
249
+ const configuredById = new Map(configuredSellers.map((seller) => [seller.id, seller]));
250
+ const merged: DoctorSellerEntry[] = [];
251
+
252
+ for (const seller of probedSellers) {
253
+ const configured = configuredById.get(seller.id);
254
+ merged.push({
255
+ ...configured,
256
+ ...seller,
257
+ discountRatio: seller.discountRatio ?? configured?.discountRatio,
258
+ supportedProtocols: seller.supportedProtocols || configured?.supportedProtocols,
259
+ paymentMethods: seller.paymentMethods || configured?.paymentMethods,
260
+ modelCount: seller.modelCount ?? configured?.modelCount,
261
+ errorMessage: seller.errorMessage || configured?.errorMessage,
262
+ });
263
+ configuredById.delete(seller.id);
264
+ }
265
+
266
+ for (const seller of configuredById.values()) {
267
+ merged.push(seller);
268
+ }
269
+
270
+ return merged;
271
+ }
272
+
273
+ function registrySellersToDoctorEntries(registry?: DoctorRegistryDocument): DoctorSellerEntry[] {
274
+ if (!registry?.sellers) {
275
+ return [];
276
+ }
277
+ return registry.sellers.map((seller) => ({
278
+ id: seller.id,
279
+ name: seller.name,
280
+ url: seller.url,
281
+ status: "configured",
282
+ supportedProtocols: seller.supportedProtocols || [],
283
+ paymentMethods: seller.paymentMethods || [],
284
+ }));
285
+ }
286
+
287
+ function sellerCatalogEntriesToDoctorEntries(sellers: SellerCatalogEntry[]): DoctorSellerEntry[] {
288
+ return sellers.map((seller) => ({
289
+ id: seller.id,
290
+ name: seller.name,
291
+ url: seller.url,
292
+ status: seller.status,
293
+ discountRatio: seller.discountRatio,
294
+ supportedProtocols: seller.supportedProtocols,
295
+ paymentMethods: seller.paymentMethods,
296
+ manifestSellerId: seller.manifestSellerId,
297
+ modelCount: seller.modelCount,
298
+ errorMessage: seller.errorMessage,
299
+ }));
300
+ }
301
+
302
+ function discountRatioFromSeller(seller: DoctorSellerEntry): number | undefined {
303
+ if (typeof seller.discountRatio === "number") {
304
+ return seller.discountRatio;
305
+ }
306
+ const match = seller.name?.match(/([0-9]+(?:\.[0-9]+)?)\s+discount/i);
307
+ if (!match) {
308
+ return undefined;
309
+ }
310
+ const parsed = Number(match[1]);
311
+ return Number.isFinite(parsed) ? parsed : undefined;
312
+ }
313
+
314
+ function formatDiscountRatio(value: number): string {
315
+ return value.toFixed(2).replace(/\.?0+$/, "");
316
+ }
317
+
318
+ function formatUsdPer1m(microsPer1m: number): string {
319
+ const usd = microsPer1m / 1_000_000;
320
+ return `$${usd.toFixed(2).replace(/\.?0+$/, "")}`;
321
+ }
322
+
323
+ function formatPriceRange(minMicros?: number, maxMicros?: number): string {
324
+ if (minMicros == null || maxMicros == null) {
325
+ return "-";
326
+ }
327
+ if (minMicros === maxMicros) {
328
+ return formatUsdPer1m(minMicros);
329
+ }
330
+ return `${formatUsdPer1m(minMicros)}~${formatUsdPer1m(maxMicros)}`;
331
+ }
332
+
333
+ function modelsHaveExplicitPriceData(models: ModelCatalogEntry[]): boolean {
334
+ return models.some((model) =>
335
+ typeof model.inputPriceMicrosPer1m === "number" ||
336
+ typeof model.outputPriceMicrosPer1m === "number"
337
+ );
338
+ }
339
+
340
+ function buildDoctorModelSummaryEntries(
341
+ models: ModelCatalogEntry[],
342
+ sellers: DoctorSellerEntry[],
343
+ ): DoctorModelSummaryEntry[] {
344
+ const grouped = new Map<string, {
345
+ sellerIds: Set<string>;
346
+ discounts: number[];
347
+ inputPrices: number[];
348
+ outputPrices: number[];
349
+ }>();
350
+ const discountBySellerId = new Map(
351
+ sellers
352
+ .map((seller) => [seller.id, discountRatioFromSeller(seller)] as const)
353
+ .filter((entry): entry is readonly [string, number] => typeof entry[1] === "number")
354
+ );
355
+
356
+ for (const model of models) {
357
+ const entry = grouped.get(model.id) || {
358
+ sellerIds: new Set<string>(),
359
+ discounts: [],
360
+ inputPrices: [],
361
+ outputPrices: [],
362
+ };
363
+ if (!entry.sellerIds.has(model.sellerId)) {
364
+ entry.sellerIds.add(model.sellerId);
365
+ const discount = discountBySellerId.get(model.sellerId);
366
+ if (typeof discount === "number") {
367
+ entry.discounts.push(discount);
368
+ }
369
+ if (typeof model.inputPriceMicrosPer1m === "number") {
370
+ entry.inputPrices.push(model.inputPriceMicrosPer1m);
371
+ }
372
+ if (typeof model.outputPriceMicrosPer1m === "number") {
373
+ entry.outputPrices.push(model.outputPriceMicrosPer1m);
374
+ }
375
+ }
376
+ grouped.set(model.id, entry);
377
+ }
378
+
379
+ return Array.from(grouped.entries())
380
+ .map(([id, entry]) => {
381
+ const discountMin = entry.discounts.length > 0 ? Math.min(...entry.discounts) : undefined;
382
+ const discountMax = entry.discounts.length > 0 ? Math.max(...entry.discounts) : undefined;
383
+ const inputPriceMinMicrosPer1m = entry.inputPrices.length > 0 ? Math.min(...entry.inputPrices) : undefined;
384
+ const inputPriceMaxMicrosPer1m = entry.inputPrices.length > 0 ? Math.max(...entry.inputPrices) : undefined;
385
+ const outputPriceMinMicrosPer1m = entry.outputPrices.length > 0 ? Math.min(...entry.outputPrices) : undefined;
386
+ const outputPriceMaxMicrosPer1m = entry.outputPrices.length > 0 ? Math.max(...entry.outputPrices) : undefined;
387
+ const discountRange = discountMin == null || discountMax == null
388
+ ? "-"
389
+ : discountMin === discountMax
390
+ ? formatDiscountRatio(discountMin)
391
+ : `${formatDiscountRatio(discountMin)}~${formatDiscountRatio(discountMax)}`;
392
+ const priceRange = inputPriceMinMicrosPer1m == null || outputPriceMinMicrosPer1m == null
393
+ ? "-"
394
+ : `in ${formatPriceRange(inputPriceMinMicrosPer1m, inputPriceMaxMicrosPer1m)} / out ${formatPriceRange(outputPriceMinMicrosPer1m, outputPriceMaxMicrosPer1m)}`;
395
+ return {
396
+ id,
397
+ sellerCount: entry.sellerIds.size,
398
+ discountMin,
399
+ discountMax,
400
+ discountRange,
401
+ inputPriceMinMicrosPer1m,
402
+ inputPriceMaxMicrosPer1m,
403
+ outputPriceMinMicrosPer1m,
404
+ outputPriceMaxMicrosPer1m,
405
+ priceRange,
406
+ };
407
+ })
408
+ .sort((left, right) => left.id.localeCompare(right.id));
409
+ }
410
+
411
+ function startDoctorFetches(
412
+ controlPort: number,
413
+ proxyPort: number,
414
+ daemonRunning: boolean,
415
+ daemonError: string | undefined,
416
+ sellerRegistryUrl?: string,
417
+ ): DoctorFetchPromises {
418
+ const controlBaseUrl = `http://127.0.0.1:${controlPort}`;
419
+ const openAiBaseUrl = `http://127.0.0.1:${proxyPort}/v1`;
420
+ const unavailableMessage = daemonError || "tb-proxyd is not running";
421
+
422
+ if (!daemonRunning) {
423
+ return {
424
+ healthResult: Promise.resolve({ url: `${controlBaseUrl}/health`, available: false, error: unavailableMessage }),
425
+ sellersResult: Promise.resolve({ url: `${controlBaseUrl}/sellers`, available: false, error: unavailableMessage }),
426
+ modelsResult: Promise.resolve({ url: `${controlBaseUrl}/models`, available: false, error: unavailableMessage }),
427
+ proxyModelsResult: Promise.resolve({ url: `${openAiBaseUrl}/models`, available: false, error: unavailableMessage }),
428
+ registryResult: Promise.resolve({ url: sellerRegistryUrl || "", available: false, error: unavailableMessage }),
429
+ };
430
+ }
431
+
432
+ return {
433
+ healthResult: fetchJsonDocument<Record<string, unknown>>(`${controlBaseUrl}/health`),
434
+ sellersResult: fetchJsonDocument<DoctorSellerResponse>(`${controlBaseUrl}/sellers`),
435
+ modelsResult: fetchJsonDocument<DoctorModelsResponse>(`${controlBaseUrl}/models`),
436
+ proxyModelsResult: fetchJsonDocument<{ object?: string; data?: Array<{ id?: string }> }>(`${openAiBaseUrl}/models`),
437
+ registryResult: sellerRegistryUrl
438
+ ? fetchJsonDocument<DoctorRegistryDocument>(sellerRegistryUrl)
439
+ : Promise.resolve({ url: "", available: false, error: "registry url unavailable" }),
440
+ };
441
+ }
442
+
443
+ async function resolveDoctorFetches(fetches: DoctorFetchPromises): Promise<DoctorFetchResults> {
444
+ const [
445
+ healthResult,
446
+ sellersResult,
447
+ modelsResult,
448
+ proxyModelsResult,
449
+ registryResult,
450
+ ] = await Promise.all([
451
+ fetches.healthResult,
452
+ fetches.sellersResult,
453
+ fetches.modelsResult,
454
+ fetches.proxyModelsResult,
455
+ fetches.registryResult,
456
+ ]);
457
+ return {
458
+ healthResult,
459
+ sellersResult,
460
+ modelsResult,
461
+ proxyModelsResult,
462
+ registryResult,
463
+ };
464
+ }
465
+
466
+ function buildDoctorSellerSummary(
467
+ sellersResult: RemoteJsonResult<DoctorSellerResponse>,
468
+ registryResult: RemoteJsonResult<DoctorRegistryDocument>,
469
+ sellerRegistryUrl?: string,
470
+ probedSellers: DoctorSellerEntry[] = [],
471
+ ): DoctorSellersSummary {
472
+ const sellersData = sellersResult.data;
473
+ const configuredSellers = mergeDoctorSellerEntries(
474
+ registrySellersToDoctorEntries(registryResult.data),
475
+ sellersData?.sellers || [],
476
+ );
477
+ const mergedSellers = probedSellers.length > 0
478
+ ? mergeDoctorSellerEntries(configuredSellers, probedSellers)
479
+ : configuredSellers;
480
+
481
+ return {
482
+ available: sellersResult.available || registryResult.available || mergedSellers.length > 0,
483
+ registryUrl: sellersData?.registryUrl || sellerRegistryUrl,
484
+ version: sellersData?.version || registryResult.data?.version,
485
+ defaultSeller: sellersData?.defaultSeller || registryResult.data?.defaultSeller,
486
+ sellers: mergedSellers,
487
+ error: sellersResult.error || registryResult.error,
488
+ };
489
+ }
490
+
491
+ function buildDoctorAccessSummary(
492
+ controlPort: number,
493
+ proxyPort: number,
494
+ healthResult: RemoteJsonResult<Record<string, unknown>>,
495
+ proxyModelsResult: RemoteJsonResult<{ object?: string; data?: Array<{ id?: string }> }>,
496
+ availability: DoctorAccessAvailability = {},
497
+ ): DoctorAccessSummary {
498
+ const controlBaseUrl = `http://127.0.0.1:${controlPort}`;
499
+ const proxyBaseUrl = `http://127.0.0.1:${proxyPort}`;
500
+ const openAiBaseUrl = `${proxyBaseUrl}/v1`;
501
+ const anthropicBaseUrl = proxyBaseUrl;
502
+
503
+ return {
504
+ controlBaseUrl,
505
+ proxyBaseUrl,
506
+ openAiBaseUrl,
507
+ anthropicBaseUrl,
508
+ token: PROXY_ACCESS_TOKEN_PLACEHOLDER,
509
+ endpoints: [
510
+ {
511
+ id: "control.health",
512
+ name: "Control Plane Health",
513
+ url: `${controlBaseUrl}/health`,
514
+ available: healthResult.available,
515
+ requiresToken: false,
516
+ error: healthResult.error,
517
+ },
518
+ {
519
+ id: "control.sellers",
520
+ name: "Seller Registry",
521
+ url: `${controlBaseUrl}/sellers`,
522
+ available: availability.sellersAvailable ?? true,
523
+ requiresToken: false,
524
+ error: availability.sellersError,
525
+ },
526
+ {
527
+ id: "control.models",
528
+ name: "Seller-backed Models",
529
+ url: `${controlBaseUrl}/models`,
530
+ available: availability.modelsAvailable ?? true,
531
+ requiresToken: false,
532
+ error: availability.modelsError,
533
+ },
534
+ {
535
+ id: "proxy.openai",
536
+ name: "OpenAI-compatible Proxy",
537
+ url: openAiBaseUrl,
538
+ probeUrl: `${openAiBaseUrl}/models`,
539
+ available: proxyModelsResult.available,
540
+ requiresToken: true,
541
+ token: PROXY_ACCESS_TOKEN_PLACEHOLDER,
542
+ error: proxyModelsResult.error,
543
+ },
544
+ {
545
+ id: "proxy.anthropic",
546
+ name: "Anthropic-compatible Proxy",
547
+ url: anthropicBaseUrl,
548
+ probeUrl: `${openAiBaseUrl}/models`,
549
+ available: proxyModelsResult.available,
550
+ requiresToken: true,
551
+ token: PROXY_ACCESS_TOKEN_PLACEHOLDER,
552
+ error: proxyModelsResult.error,
553
+ },
554
+ ],
555
+ };
556
+ }
557
+
558
+ function buildDoctorModelsSummary(
559
+ modelsResult: RemoteJsonResult<DoctorModelsResponse>,
560
+ sellers: DoctorSellerEntry[],
561
+ ): DoctorModelsSummary {
562
+ const modelsData = modelsResult.data;
563
+ const grouped = buildDoctorModelSummaryEntries(modelsData?.data || [], sellers);
564
+ return {
565
+ available: modelsResult.available,
566
+ count: modelsData?.data?.length || 0,
567
+ uniqueCount: grouped.length,
568
+ registryUrl: modelsData?.registryUrl,
569
+ data: modelsData?.data || [],
570
+ grouped,
571
+ sellers,
572
+ error: modelsResult.error,
573
+ };
574
+ }
575
+
576
+ function providerStatusIcon(status: DoctorProviderView["status"]): string {
577
+ if (status === "configured") {
578
+ return "✅";
579
+ }
580
+ if (status === "installed") {
581
+ return "🟡";
582
+ }
583
+ return "🔘";
584
+ }
585
+
586
+ function remoteStatusIcon(available: boolean): string {
587
+ return available ? "✅" : "❌";
588
+ }
589
+
590
+ function formatList(values?: string[]): string {
591
+ return values && values.length > 0 ? values.join(", ") : "-";
592
+ }
593
+
594
+ function defaultWriter(line: string): void {
595
+ console.log(line);
596
+ }
597
+
598
+ function printDoctorAccess(access: DoctorAccessSummary, writeLine: (line: string) => void): void {
599
+ writeLine("Access check complete.");
600
+ writeLine(`Proxy token: ${access.token}`);
601
+ for (const endpoint of access.endpoints) {
602
+ writeLine(`${remoteStatusIcon(endpoint.available)} ${endpoint.name}`);
603
+ writeLine(` URL: ${endpoint.url}`);
604
+ if (endpoint.probeUrl) {
605
+ writeLine(` Probe: ${endpoint.probeUrl}`);
606
+ }
607
+ if (endpoint.requiresToken && endpoint.token) {
608
+ writeLine(` Token: ${endpoint.token}`);
609
+ }
610
+ if (!endpoint.available && endpoint.error) {
611
+ writeLine(` Error: ${endpoint.error}`);
612
+ }
613
+ }
614
+ }
615
+
616
+ function printDoctorSellers(sellers: DoctorSellersSummary, writeLine: (line: string) => void): void {
617
+ writeLine("Seller registry refresh complete.");
618
+ if (!sellers.available) {
619
+ writeLine(`❌ Seller registry unavailable: ${sellers.error || "unknown error"}`);
620
+ return;
621
+ }
622
+ writeLine(`Registry: ${sellers.registryUrl || "-"}`);
623
+ writeLine(`Default seller: ${sellers.defaultSeller || "-"}`);
624
+ for (const seller of sellers.sellers) {
625
+ const label = seller.name ? `${seller.name} (${seller.id})` : seller.id;
626
+ const icon = seller.status === "ok" || seller.status === "configured"
627
+ ? "✅"
628
+ : seller.status === "failed"
629
+ ? "❌"
630
+ : "🔘";
631
+ writeLine(`${icon} ${label} [${seller.status}]`);
632
+ writeLine(` URL: ${seller.url}`);
633
+ writeLine(` Protocols: ${formatList(seller.supportedProtocols)}`);
634
+ writeLine(` Payments: ${formatList(seller.paymentMethods)}`);
635
+ if (seller.modelCount != null) {
636
+ writeLine(` Models: ${seller.modelCount}`);
637
+ }
638
+ if (seller.errorMessage) {
639
+ writeLine(` Error: ${seller.errorMessage}`);
640
+ }
641
+ }
642
+ }
643
+
644
+ export function printDoctorModelsSummary(
645
+ models: DoctorModelsSummary,
646
+ writeLine: (line: string) => void = defaultWriter,
647
+ ): void {
648
+ writeLine("Model catalog refresh complete.");
649
+ if (!models.available) {
650
+ writeLine(`❌ Model catalog unavailable: ${models.error || "unknown error"}`);
651
+ return;
652
+ }
653
+ writeLine(`Unique models: ${models.uniqueCount}`);
654
+ writeLine(`Seller offers: ${models.count}`);
655
+
656
+ const table = new Table({
657
+ head: ["Model ID", "Seller Count", "Discount Range", "Price Range"],
658
+ style: {
659
+ head: []
660
+ }
661
+ });
662
+ for (const entry of models.grouped) {
663
+ table.push([
664
+ entry.id,
665
+ String(entry.sellerCount),
666
+ entry.discountRange,
667
+ entry.priceRange
668
+ ]);
669
+ }
670
+ writeLine(table.toString());
671
+ }
672
+
673
+ export function readDoctorProviders(): DoctorProviderView[] {
674
+ const store = new BuyerStore();
675
+ try {
676
+ const runtimeConfigByProvider = new Map<ProviderId, { config: ProviderRuntimeConfig; updatedAt: string }>();
677
+ for (const providerId of SUPPORTED_PROVIDER_IDS) {
678
+ const record = store.getProviderRuntimeConfig<ProviderRuntimeConfig>(providerId);
679
+ if (record) {
680
+ runtimeConfigByProvider.set(providerId, {
681
+ config: record.config,
682
+ updatedAt: record.updatedAt,
683
+ });
684
+ }
685
+ }
686
+
687
+ return detectProviders().map((provider) => {
688
+ const runtime = runtimeConfigByProvider.get(provider.id);
689
+ return {
690
+ ...provider,
691
+ runtimeConfig: runtime?.config,
692
+ runtimeConfigUpdatedAt: runtime?.updatedAt,
693
+ };
694
+ });
695
+ } finally {
696
+ store.close();
697
+ }
698
+ }
699
+
700
+ export function printDoctorProviders(
701
+ providers: DoctorProviderView[],
702
+ writeLine: (line: string) => void = defaultWriter,
703
+ ): void {
704
+ writeLine("\n--- Programming Terminals ---");
705
+ for (const provider of providers) {
706
+ writeLine(`${providerStatusIcon(provider.status)} ${provider.name} [${provider.status}]`);
707
+ if (provider.commandName) {
708
+ writeLine(` Command: ${provider.commandName}${provider.executablePath ? ` -> ${provider.executablePath}` : " (not found in PATH)"}`);
709
+ }
710
+ writeLine(` Config: ${provider.configPath}`);
711
+ if (provider.observedPaths && provider.observedPaths.length > 0) {
712
+ writeLine(` Native hints: ${provider.observedPaths.join(", ")}`);
713
+ }
714
+ const runtimeSummary = providerRuntimeSummary(provider.runtimeConfig);
715
+ if (runtimeSummary) {
716
+ writeLine(` Runtime: ${runtimeSummary}`);
717
+ }
718
+ writeLine(` Notes: ${provider.reason}`);
719
+ }
720
+ }
721
+
722
+ export async function collectDoctorDiagnostics(options: DoctorCollectOptions): Promise<DoctorDiagnostics> {
723
+ const fetches = startDoctorFetches(
724
+ options.controlPort,
725
+ options.proxyPort,
726
+ options.daemonRunning,
727
+ options.daemonError,
728
+ options.sellerRegistryUrl,
729
+ );
730
+ const results = await resolveDoctorFetches(fetches);
731
+ const sellers = buildDoctorSellerSummary(
732
+ results.sellersResult,
733
+ results.registryResult,
734
+ options.sellerRegistryUrl,
735
+ results.modelsResult.data?.sellers || [],
736
+ );
737
+ const models = buildDoctorModelsSummary(results.modelsResult, sellers.sellers);
738
+
739
+ return {
740
+ access: buildDoctorAccessSummary(
741
+ options.controlPort,
742
+ options.proxyPort,
743
+ results.healthResult,
744
+ results.proxyModelsResult,
745
+ {
746
+ sellersAvailable: sellers.available,
747
+ sellersError: sellers.error,
748
+ modelsAvailable: models.available,
749
+ modelsError: models.error,
750
+ },
751
+ ),
752
+ models,
753
+ providers: options.providers,
754
+ sellers,
755
+ };
756
+ }
757
+
758
+ export async function collectDoctorModelsSummary(options: Omit<DoctorCollectOptions, "providers">): Promise<DoctorModelsSummary> {
759
+ const diagnostics = await collectDoctorDiagnostics({
760
+ ...options,
761
+ providers: [],
762
+ });
763
+ if (modelsHaveExplicitPriceData(diagnostics.models.data) || !options.sellerRegistryUrl) {
764
+ return diagnostics.models;
765
+ }
766
+
767
+ try {
768
+ const catalog = await discoverSellerBackedModels(options.sellerRegistryUrl);
769
+ const sellers = sellerCatalogEntriesToDoctorEntries(catalog.sellers);
770
+ return buildDoctorModelsSummary({
771
+ url: options.sellerRegistryUrl,
772
+ available: true,
773
+ data: {
774
+ object: "list",
775
+ registryUrl: catalog.registryUrl,
776
+ data: catalog.models,
777
+ },
778
+ }, sellers);
779
+ } catch {
780
+ return diagnostics.models;
781
+ }
782
+
783
+ }
784
+
785
+ export async function renderDoctorDiagnosticsProgressively(options: DoctorRenderOptions): Promise<DoctorDiagnostics> {
786
+ const writeLine = options.writeLine || defaultWriter;
787
+ const fetches = startDoctorFetches(
788
+ options.controlPort,
789
+ options.proxyPort,
790
+ options.daemonRunning,
791
+ options.daemonError,
792
+ options.sellerRegistryUrl,
793
+ );
794
+
795
+ writeLine("\n--- Access Interfaces ---");
796
+ writeLine("Checking local control plane and proxy endpoints...");
797
+ const [healthResult, proxyModelsResult] = await Promise.all([
798
+ fetches.healthResult,
799
+ fetches.proxyModelsResult,
800
+ ]);
801
+ const access = buildDoctorAccessSummary(
802
+ options.controlPort,
803
+ options.proxyPort,
804
+ healthResult,
805
+ proxyModelsResult,
806
+ );
807
+ printDoctorAccess(access, writeLine);
808
+
809
+ writeLine("\n--- Sellers ---");
810
+ writeLine("Refreshing seller registry...");
811
+ const [sellersResult, registryResult] = await Promise.all([
812
+ fetches.sellersResult,
813
+ fetches.registryResult,
814
+ ]);
815
+ const partialSellers = buildDoctorSellerSummary(
816
+ sellersResult,
817
+ registryResult,
818
+ options.sellerRegistryUrl,
819
+ );
820
+ printDoctorSellers(partialSellers, writeLine);
821
+
822
+ const modelsResult = await fetches.modelsResult;
823
+ const finalSellers = buildDoctorSellerSummary(
824
+ sellersResult,
825
+ registryResult,
826
+ options.sellerRegistryUrl,
827
+ modelsResult.data?.sellers || [],
828
+ );
829
+ const models = buildDoctorModelsSummary(modelsResult, finalSellers.sellers);
830
+ access.endpoints = buildDoctorAccessSummary(
831
+ options.controlPort,
832
+ options.proxyPort,
833
+ healthResult,
834
+ proxyModelsResult,
835
+ {
836
+ sellersAvailable: finalSellers.available,
837
+ sellersError: finalSellers.error,
838
+ modelsAvailable: models.available,
839
+ modelsError: models.error,
840
+ },
841
+ ).endpoints;
842
+ writeLine("\nModel catalog hidden in `tb doctor`. Run `tb models` for the current model summary.");
843
+
844
+ return {
845
+ access,
846
+ models,
847
+ providers: options.providers,
848
+ sellers: finalSellers,
849
+ };
850
+ }