@tokenbuddy/tokenbuddy 1.0.31 → 1.0.33

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 (57) hide show
  1. package/dist/src/buyer-store.d.ts +26 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +61 -8
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/daemon.d.ts +24 -0
  6. package/dist/src/daemon.d.ts.map +1 -1
  7. package/dist/src/daemon.js +1259 -9
  8. package/dist/src/daemon.js.map +1 -1
  9. package/dist/src/doctor-diagnostics.d.ts +12 -0
  10. package/dist/src/doctor-diagnostics.d.ts.map +1 -1
  11. package/dist/src/doctor-diagnostics.js +71 -2
  12. package/dist/src/doctor-diagnostics.js.map +1 -1
  13. package/dist/src/prewarm-cache.js +4 -1
  14. package/dist/src/prewarm-cache.js.map +1 -1
  15. package/dist/src/provider-routing-config.d.ts +91 -0
  16. package/dist/src/provider-routing-config.d.ts.map +1 -0
  17. package/dist/src/provider-routing-config.js +292 -0
  18. package/dist/src/provider-routing-config.js.map +1 -0
  19. package/package.json +1 -1
  20. package/src/buyer-store.ts +113 -8
  21. package/src/daemon.ts +1389 -16
  22. package/src/doctor-diagnostics.ts +100 -1
  23. package/src/prewarm-cache.ts +5 -1
  24. package/src/provider-routing-config.ts +410 -0
  25. package/static/ui/assets/index-0MVXD7bH.css +1 -0
  26. package/static/ui/assets/index-Mt3BZFuP.js +266 -0
  27. package/static/ui/assets/index-Mt3BZFuP.js.map +1 -0
  28. package/static/ui/icons/apple-touch-icon.png +0 -0
  29. package/static/ui/icons/tokenbuddy-192.png +0 -0
  30. package/static/ui/icons/tokenbuddy-512.png +0 -0
  31. package/static/ui/icons/tokenbuddy.svg +6 -0
  32. package/static/ui/index.html +3 -2
  33. package/static/ui/tool-logos/cc-switch.png +0 -0
  34. package/static/ui/tool-logos/claude.svg +1 -0
  35. package/static/ui/tool-logos/cline.svg +1 -0
  36. package/static/ui/tool-logos/codex.svg +1 -0
  37. package/static/ui/tool-logos/cursor.svg +1 -0
  38. package/static/ui/tool-logos/dirac.ico +18 -0
  39. package/static/ui/tool-logos/factory.svg +4 -0
  40. package/static/ui/tool-logos/fast-agent.svg +6 -0
  41. package/static/ui/tool-logos/glm.svg +1 -0
  42. package/static/ui/tool-logos/goose.svg +1 -0
  43. package/static/ui/tool-logos/hermes.svg +1 -0
  44. package/static/ui/tool-logos/kilocode.svg +1 -0
  45. package/static/ui/tool-logos/opencode.svg +1 -0
  46. package/static/ui/tool-logos/pi.svg +28 -0
  47. package/static/ui/tool-logos/qwen-code.png +0 -0
  48. package/tests/cli-routing.test.ts +43 -0
  49. package/tests/control-plane-ui-endpoints.test.ts +776 -0
  50. package/tests/daemon-classify.test.ts +5 -1
  51. package/tests/e2e.test.ts +5 -0
  52. package/tests/prewarm-cache.test.ts +15 -0
  53. package/tests/provider-routing-config.test.ts +150 -0
  54. package/tests/tokenbuddy.test.ts +27 -0
  55. package/static/ui/assets/index-Bzbrp7Qe.css +0 -1
  56. package/static/ui/assets/index-DEDEl8o2.js +0 -236
  57. package/static/ui/assets/index-DEDEl8o2.js.map +0 -1
@@ -18,6 +18,7 @@ import {
18
18
  readDoctorClawtipWallet,
19
19
  type DoctorClawtipWalletSummary,
20
20
  } from "./doctor-clawtip-wallet.js";
21
+ import type { PublicManualProviderConfig } from "./provider-routing-config.js";
21
22
 
22
23
  /**
23
24
  * `tb doctor` 输出里 provider 行的形状。
@@ -55,6 +56,8 @@ export interface DoctorDiagnostics {
55
56
  access: DoctorAccessSummary;
56
57
  /** Clawtip 钱包信息 */
57
58
  clawtipWallet: DoctorClawtipWalletSummary;
59
+ /** buyer 本地 Manual provider 状态 */
60
+ manualProviders: DoctorManualProvidersSummary;
58
61
  /** 模型目录汇总 */
59
62
  models: DoctorModelsSummary;
60
63
  /** provider 安装 / 配置状态 */
@@ -108,12 +111,18 @@ interface DoctorModelsResponse {
108
111
  sellers?: DoctorSellerEntry[];
109
112
  }
110
113
 
114
+ interface DoctorManualProvidersResponse {
115
+ providers: PublicManualProviderConfig[];
116
+ }
117
+
111
118
  /**
112
119
  * `tb doctor` 模型表的单行:按 model id 聚合 seller 的折扣 / 价格区间。
113
120
  */
114
121
  export interface DoctorModelSummaryEntry {
115
122
  id: string;
116
123
  sellerCount: number;
124
+ localProviderCount: number;
125
+ sourceRange: string;
117
126
  discountMin?: number;
118
127
  discountMax?: number;
119
128
  discountRange: string;
@@ -128,6 +137,7 @@ interface DoctorFetchResults {
128
137
  healthResult: RemoteJsonResult<Record<string, unknown>>;
129
138
  sellersResult: RemoteJsonResult<DoctorSellerResponse>;
130
139
  modelsResult: RemoteJsonResult<DoctorModelsResponse>;
140
+ manualProvidersResult: RemoteJsonResult<DoctorManualProvidersResponse>;
131
141
  proxyModelsResult: RemoteJsonResult<{ object?: string; data?: Array<{ id?: string }> }>;
132
142
  registryResult: RemoteJsonResult<DoctorRegistryDocument>;
133
143
  }
@@ -136,6 +146,7 @@ interface DoctorFetchPromises {
136
146
  healthResult: Promise<RemoteJsonResult<Record<string, unknown>>>;
137
147
  sellersResult: Promise<RemoteJsonResult<DoctorSellerResponse>>;
138
148
  modelsResult: Promise<RemoteJsonResult<DoctorModelsResponse>>;
149
+ manualProvidersResult: Promise<RemoteJsonResult<DoctorManualProvidersResponse>>;
139
150
  proxyModelsResult: Promise<RemoteJsonResult<{ object?: string; data?: Array<{ id?: string }> }>>;
140
151
  registryResult: Promise<RemoteJsonResult<DoctorRegistryDocument>>;
141
152
  }
@@ -184,6 +195,14 @@ type DoctorSellersSummary = {
184
195
  error?: string;
185
196
  };
186
197
 
198
+ export type DoctorManualProvidersSummary = {
199
+ available: boolean;
200
+ count: number;
201
+ configuredKeyCount: number;
202
+ providers: PublicManualProviderConfig[];
203
+ error?: string;
204
+ };
205
+
187
206
  /**
188
207
  * `tb doctor` 模型目录汇总:原始 `data` + 聚合后的 `grouped`(按 model id 分组)。
189
208
  */
@@ -363,6 +382,14 @@ function formatPriceRange(minMicros?: number, maxMicros?: number): string {
363
382
  return `${formatUsdPer1m(minMicros)}~${formatUsdPer1m(maxMicros)}`;
364
383
  }
365
384
 
385
+ function formatModelSourceRange(marketplaceCount: number, localProviderCount: number): string {
386
+ const parts = [
387
+ marketplaceCount > 0 ? `${marketplaceCount} marketplace` : undefined,
388
+ localProviderCount > 0 ? `${localProviderCount} local` : undefined,
389
+ ].filter(Boolean);
390
+ return parts.length > 0 ? parts.join(" + ") : "-";
391
+ }
392
+
366
393
  function modelsHaveExplicitPriceData(models: ModelCatalogEntry[]): boolean {
367
394
  return models.some((model) =>
368
395
  typeof model.inputPriceMicrosPer1m === "number" ||
@@ -376,6 +403,7 @@ function buildDoctorModelSummaryEntries(
376
403
  ): DoctorModelSummaryEntry[] {
377
404
  const grouped = new Map<string, {
378
405
  sellerIds: Set<string>;
406
+ localProviderIds: Set<string>;
379
407
  discounts: number[];
380
408
  inputPrices: number[];
381
409
  outputPrices: number[];
@@ -389,12 +417,16 @@ function buildDoctorModelSummaryEntries(
389
417
  for (const model of models) {
390
418
  const entry = grouped.get(model.id) || {
391
419
  sellerIds: new Set<string>(),
420
+ localProviderIds: new Set<string>(),
392
421
  discounts: [],
393
422
  inputPrices: [],
394
423
  outputPrices: [],
395
424
  };
396
425
  if (!entry.sellerIds.has(model.sellerId)) {
397
426
  entry.sellerIds.add(model.sellerId);
427
+ if (model.paymentMethods.includes("provider_key")) {
428
+ entry.localProviderIds.add(model.sellerId);
429
+ }
398
430
  const discount = discountBySellerId.get(model.sellerId);
399
431
  if (typeof discount === "number") {
400
432
  entry.discounts.push(discount);
@@ -411,6 +443,7 @@ function buildDoctorModelSummaryEntries(
411
443
 
412
444
  return Array.from(grouped.entries())
413
445
  .map(([id, entry]) => {
446
+ const marketplaceCount = Math.max(0, entry.sellerIds.size - entry.localProviderIds.size);
414
447
  const discountMin = entry.discounts.length > 0 ? Math.min(...entry.discounts) : undefined;
415
448
  const discountMax = entry.discounts.length > 0 ? Math.max(...entry.discounts) : undefined;
416
449
  const inputPriceMinMicrosPer1m = entry.inputPrices.length > 0 ? Math.min(...entry.inputPrices) : undefined;
@@ -428,6 +461,8 @@ function buildDoctorModelSummaryEntries(
428
461
  return {
429
462
  id,
430
463
  sellerCount: entry.sellerIds.size,
464
+ localProviderCount: entry.localProviderIds.size,
465
+ sourceRange: formatModelSourceRange(marketplaceCount, entry.localProviderIds.size),
431
466
  discountMin,
432
467
  discountMax,
433
468
  discountRange,
@@ -457,6 +492,7 @@ function startDoctorFetches(
457
492
  healthResult: Promise.resolve({ url: `${controlBaseUrl}/health`, available: false, error: unavailableMessage }),
458
493
  sellersResult: Promise.resolve({ url: `${controlBaseUrl}/sellers`, available: false, error: unavailableMessage }),
459
494
  modelsResult: Promise.resolve({ url: `${controlBaseUrl}/models`, available: false, error: unavailableMessage }),
495
+ manualProvidersResult: Promise.resolve({ url: `${controlBaseUrl}/routing/manual-providers`, available: false, error: unavailableMessage }),
460
496
  proxyModelsResult: Promise.resolve({ url: `${openAiBaseUrl}/models`, available: false, error: unavailableMessage }),
461
497
  registryResult: Promise.resolve({ url: sellerRegistryUrl || "", available: false, error: unavailableMessage }),
462
498
  };
@@ -466,6 +502,7 @@ function startDoctorFetches(
466
502
  healthResult: fetchJsonDocument<Record<string, unknown>>(`${controlBaseUrl}/health`),
467
503
  sellersResult: fetchJsonDocument<DoctorSellerResponse>(`${controlBaseUrl}/sellers`),
468
504
  modelsResult: fetchJsonDocument<DoctorModelsResponse>(`${controlBaseUrl}/models`),
505
+ manualProvidersResult: fetchJsonDocument<DoctorManualProvidersResponse>(`${controlBaseUrl}/routing/manual-providers`),
469
506
  proxyModelsResult: fetchJsonDocument<{ object?: string; data?: Array<{ id?: string }> }>(`${openAiBaseUrl}/models`),
470
507
  registryResult: sellerRegistryUrl
471
508
  ? fetchJsonDocument<DoctorRegistryDocument>(sellerRegistryUrl)
@@ -478,12 +515,14 @@ async function resolveDoctorFetches(fetches: DoctorFetchPromises): Promise<Docto
478
515
  healthResult,
479
516
  sellersResult,
480
517
  modelsResult,
518
+ manualProvidersResult,
481
519
  proxyModelsResult,
482
520
  registryResult,
483
521
  ] = await Promise.all([
484
522
  fetches.healthResult,
485
523
  fetches.sellersResult,
486
524
  fetches.modelsResult,
525
+ fetches.manualProvidersResult,
487
526
  fetches.proxyModelsResult,
488
527
  fetches.registryResult,
489
528
  ]);
@@ -491,6 +530,7 @@ async function resolveDoctorFetches(fetches: DoctorFetchPromises): Promise<Docto
491
530
  healthResult,
492
531
  sellersResult,
493
532
  modelsResult,
533
+ manualProvidersResult,
494
534
  proxyModelsResult,
495
535
  registryResult,
496
536
  };
@@ -521,6 +561,19 @@ function buildDoctorSellerSummary(
521
561
  };
522
562
  }
523
563
 
564
+ function buildDoctorManualProvidersSummary(
565
+ manualProvidersResult: RemoteJsonResult<DoctorManualProvidersResponse>
566
+ ): DoctorManualProvidersSummary {
567
+ const providers = manualProvidersResult.data?.providers || [];
568
+ return {
569
+ available: manualProvidersResult.available,
570
+ count: providers.length,
571
+ configuredKeyCount: providers.filter((provider) => provider.keyRef?.configured).length,
572
+ providers,
573
+ error: manualProvidersResult.error,
574
+ };
575
+ }
576
+
524
577
  function buildDoctorAccessSummary(
525
578
  controlPort: number,
526
579
  proxyPort: number,
@@ -674,6 +727,42 @@ function printDoctorSellers(sellers: DoctorSellersSummary, writeLine: (line: str
674
727
  }
675
728
  }
676
729
 
730
+ function printDoctorManualProviders(
731
+ manualProviders: DoctorManualProvidersSummary,
732
+ writeLine: (line: string) => void
733
+ ): void {
734
+ writeLine("Manual provider check complete.");
735
+ if (!manualProviders.available) {
736
+ writeLine(`❌ Manual providers unavailable: ${manualProviders.error || "unknown error"}`);
737
+ return;
738
+ }
739
+ writeLine(`Manual providers: ${manualProviders.count}`);
740
+ writeLine(`Configured keys: ${manualProviders.configuredKeyCount}/${manualProviders.count}`);
741
+ for (const provider of manualProviders.providers) {
742
+ const icon = provider.enabled && provider.keyRef?.configured
743
+ ? "✅"
744
+ : provider.enabled
745
+ ? "⚠️"
746
+ : "🔘";
747
+ const keyRef = provider.keyRef
748
+ ? `${provider.keyRef.kind}:${provider.keyRef.name} ${provider.keyRef.configured ? "configured" : "missing"}`
749
+ : "missing";
750
+ writeLine(`${icon} ${provider.name} (${provider.id}) [${provider.enabled ? "enabled" : "disabled"}]`);
751
+ writeLine(` Key: ${keyRef}`);
752
+ writeLine(` Models: ${provider.models.length}`);
753
+ writeLine(` Protocols: ${formatList(provider.supportedProtocols)}`);
754
+ if (provider.lastAccess) {
755
+ writeLine(` Last access: ${provider.lastAccess}`);
756
+ }
757
+ if (provider.status) {
758
+ writeLine(` Status: ${provider.status}`);
759
+ }
760
+ if (provider.errorMessage) {
761
+ writeLine(` Error: ${provider.errorMessage}`);
762
+ }
763
+ }
764
+ }
765
+
677
766
  /**
678
767
  * 打印模型目录汇总(写到 `writeLine`,默认 stdout)。
679
768
  *
@@ -693,7 +782,7 @@ export function printDoctorModelsSummary(
693
782
  writeLine(`Seller offers: ${models.count}`);
694
783
 
695
784
  const table = new Table({
696
- head: ["Model ID", "Seller Count", "Discount Range", "Price Range"],
785
+ head: ["Model ID", "Seller Count", "Sources", "Discount Range", "Price Range"],
697
786
  style: {
698
787
  head: []
699
788
  }
@@ -702,6 +791,7 @@ export function printDoctorModelsSummary(
702
791
  table.push([
703
792
  entry.id,
704
793
  String(entry.sellerCount),
794
+ entry.sourceRange,
705
795
  entry.discountRange,
706
796
  entry.priceRange
707
797
  ]);
@@ -794,6 +884,7 @@ export async function collectDoctorDiagnostics(options: DoctorCollectOptions): P
794
884
  results.modelsResult.data?.sellers || [],
795
885
  );
796
886
  const models = buildDoctorModelsSummary(results.modelsResult, sellers.sellers);
887
+ const manualProviders = buildDoctorManualProvidersSummary(results.manualProvidersResult);
797
888
 
798
889
  return {
799
890
  access: buildDoctorAccessSummary(
@@ -809,6 +900,7 @@ export async function collectDoctorDiagnostics(options: DoctorCollectOptions): P
809
900
  },
810
901
  ),
811
902
  clawtipWallet: readDoctorClawtipWallet(),
903
+ manualProviders,
812
904
  models,
813
905
  providers: options.providers,
814
906
  sellers,
@@ -882,6 +974,12 @@ export async function renderDoctorDiagnosticsProgressively(options: DoctorRender
882
974
  );
883
975
  printDoctorAccess(access, writeLine);
884
976
 
977
+ writeLine("\n--- Manual Providers ---");
978
+ writeLine("Reading local custom provider status...");
979
+ const manualProvidersResult = await fetches.manualProvidersResult;
980
+ const manualProviders = buildDoctorManualProvidersSummary(manualProvidersResult);
981
+ printDoctorManualProviders(manualProviders, writeLine);
982
+
885
983
  writeLine("\n--- Sellers ---");
886
984
  writeLine("Refreshing seller registry...");
887
985
  const [sellersResult, registryResult] = await Promise.all([
@@ -920,6 +1018,7 @@ export async function renderDoctorDiagnosticsProgressively(options: DoctorRender
920
1018
  return {
921
1019
  access,
922
1020
  clawtipWallet,
1021
+ manualProviders,
923
1022
  models,
924
1023
  providers: options.providers,
925
1024
  sellers: finalSellers,
@@ -456,7 +456,7 @@ function toCandidate(input: PrewarmCandidateInput): PrewarmCandidate {
456
456
  healthProbeLatencyMs: finiteNonNegative(input.healthProbeLatencyMs),
457
457
  ttftMs: finiteNonNegative(input.ttftMs),
458
458
  avgInferenceMs: finiteNonNegative(input.avgInferenceMs),
459
- avgTokensPerSecond: finiteNonNegative(input.avgTokensPerSecond),
459
+ avgTokensPerSecond: finitePositive(input.avgTokensPerSecond),
460
460
  upstreamStatus: input.upstreamStatus,
461
461
  upstreamErrorClass: input.upstreamErrorClass,
462
462
  capacityBlockedUntil: finiteNonNegative(input.capacityBlockedUntil)
@@ -467,6 +467,10 @@ function finiteNonNegative(value: number | undefined): number | undefined {
467
467
  return Number.isFinite(value) ? Math.max(0, value as number) : undefined;
468
468
  }
469
469
 
470
+ function finitePositive(value: number | undefined): number | undefined {
471
+ return Number.isFinite(value) && (value as number) > 0 ? value : undefined;
472
+ }
473
+
470
474
  function clampScore(score: number): number {
471
475
  if (!Number.isFinite(score)) {
472
476
  return 50;
@@ -0,0 +1,410 @@
1
+ import type { SellerRoutingScorer } from "./seller-routing-strategy.js";
2
+
3
+ export const PROVIDER_MODE_CONFIG_KEY = "provider-mode";
4
+ export const MANUAL_PROVIDER_CONFIG_KEY = "manual-providers";
5
+ export const AUTO_PROVIDER_CONFIG_KEY = "auto-provider";
6
+ export const MANUAL_PROVIDER_OBSERVATIONS_CONFIG_KEY = "manual-provider-observations";
7
+
8
+ export type ProviderMode = "manual" | "auto";
9
+ export type ManualProviderKind = "openai-compatible";
10
+ export type ProviderProtocol = "chat_completions" | "responses" | "messages";
11
+ export type AutoProviderRange = "recommended" | "custom";
12
+ export type ManualProviderRoutingPolicy = "fallback" | "locked";
13
+
14
+ export interface ProviderModeConfig {
15
+ mode: ProviderMode;
16
+ updatedAt: string;
17
+ }
18
+
19
+ export interface ManualProviderConfig {
20
+ id: string;
21
+ name: string;
22
+ kind: ManualProviderKind;
23
+ baseUrl: string;
24
+ apiKeyEnv?: string;
25
+ secretRef?: string;
26
+ models: string[];
27
+ supportedProtocols: ProviderProtocol[];
28
+ enabled: boolean;
29
+ notes?: string;
30
+ createdAt: string;
31
+ updatedAt: string;
32
+ }
33
+
34
+ export interface ManualProvidersConfig {
35
+ version: 1;
36
+ providers: ManualProviderConfig[];
37
+ routing: ManualProviderRoutingConfig;
38
+ updatedAt: string;
39
+ }
40
+
41
+ export interface ManualProviderRoutingConfig {
42
+ policy: ManualProviderRoutingPolicy;
43
+ lockedProviderId?: string;
44
+ }
45
+
46
+ export interface AutoProviderConfig {
47
+ enabled: boolean;
48
+ range: AutoProviderRange;
49
+ scorer: SellerRoutingScorer;
50
+ modelIds: string[];
51
+ sellerIds: string[];
52
+ maxConcurrentProviders: 10;
53
+ updatedAt: string;
54
+ }
55
+
56
+ export interface PublicManualProviderConfig extends Omit<ManualProviderConfig, "apiKeyEnv" | "secretRef"> {
57
+ keyRef?: {
58
+ kind: "env" | "secret";
59
+ name: string;
60
+ configured: boolean;
61
+ };
62
+ current?: boolean;
63
+ lastAccess?: string;
64
+ status?: "healthy" | "degraded" | "unhealthy" | "unknown";
65
+ errorClass?: string;
66
+ errorMessage?: string;
67
+ ttftMs?: number;
68
+ avgTokensPerSecond?: number;
69
+ }
70
+
71
+ export interface ManualProviderObservation {
72
+ providerId: string;
73
+ current: boolean;
74
+ lastAccess: string;
75
+ status: "healthy" | "degraded" | "unhealthy" | "unknown";
76
+ errorClass?: string;
77
+ errorMessage?: string;
78
+ ttftMs?: number;
79
+ avgTokensPerSecond?: number;
80
+ }
81
+
82
+ export interface ManualProviderObservationsConfig {
83
+ version: 1;
84
+ observations: ManualProviderObservation[];
85
+ updatedAt: string;
86
+ }
87
+
88
+ const VALID_PROTOCOLS = new Set<ProviderProtocol>(["chat_completions", "responses", "messages"]);
89
+ const VALID_SCORERS = new Set<SellerRoutingScorer>(["balanced", "speed", "discount"]);
90
+
91
+ export function defaultProviderModeConfig(now = new Date().toISOString()): ProviderModeConfig {
92
+ return {
93
+ mode: "manual",
94
+ updatedAt: now
95
+ };
96
+ }
97
+
98
+ export function normalizeProviderModeConfig(value: unknown, now = new Date().toISOString()): ProviderModeConfig {
99
+ if (!isRecord(value)) {
100
+ return defaultProviderModeConfig(now);
101
+ }
102
+ return {
103
+ mode: readProviderMode(value.mode),
104
+ updatedAt: readOptionalString(value.updatedAt) ?? now
105
+ };
106
+ }
107
+
108
+ export function normalizeManualProvidersConfig(value: unknown, now = new Date().toISOString()): ManualProvidersConfig {
109
+ if (!isRecord(value)) {
110
+ return {
111
+ version: 1,
112
+ providers: [],
113
+ routing: defaultManualProviderRoutingConfig(),
114
+ updatedAt: now
115
+ };
116
+ }
117
+ const providersValue = Array.isArray(value.providers) ? value.providers : [];
118
+ const seen = new Set<string>();
119
+ const providers = providersValue.map((providerValue) => {
120
+ const provider = normalizeManualProviderConfig(providerValue, { now });
121
+ if (seen.has(provider.id)) {
122
+ throw new Error(`manual provider id is duplicated: ${provider.id}`);
123
+ }
124
+ seen.add(provider.id);
125
+ return provider;
126
+ });
127
+ return {
128
+ version: 1,
129
+ providers,
130
+ routing: normalizeManualProviderRoutingConfig(value.routing),
131
+ updatedAt: readOptionalString(value.updatedAt) ?? now
132
+ };
133
+ }
134
+
135
+ export function defaultManualProviderRoutingConfig(): ManualProviderRoutingConfig {
136
+ return {
137
+ policy: "fallback"
138
+ };
139
+ }
140
+
141
+ export function normalizeManualProviderRoutingConfig(value: unknown): ManualProviderRoutingConfig {
142
+ if (!isRecord(value)) {
143
+ return defaultManualProviderRoutingConfig();
144
+ }
145
+ const policy = readManualProviderRoutingPolicy(value.policy);
146
+ const lockedProviderId = readOptionalString(value.lockedProviderId);
147
+ if (policy === "locked" && !lockedProviderId) {
148
+ throw new Error("manual provider locked routing requires lockedProviderId");
149
+ }
150
+ return {
151
+ policy,
152
+ lockedProviderId: policy === "locked" ? readProviderId(lockedProviderId) : undefined
153
+ };
154
+ }
155
+
156
+ export function normalizeManualProviderConfig(
157
+ value: unknown,
158
+ options: { now?: string; id?: string; existingIds?: ReadonlySet<string> } = {}
159
+ ): ManualProviderConfig {
160
+ if (!isRecord(value)) {
161
+ throw new Error("manual provider config must be an object");
162
+ }
163
+ if ("apiKey" in value) {
164
+ throw new Error("manual provider config must not contain a raw apiKey; use apiKeyEnv or secretRef");
165
+ }
166
+ const now = options.now ?? new Date().toISOString();
167
+ const id = readProviderId(options.id ?? value.id);
168
+ if (options.existingIds?.has(id)) {
169
+ throw new Error(`manual provider id is duplicated: ${id}`);
170
+ }
171
+ const apiKeyEnv = readOptionalString(value.apiKeyEnv);
172
+ const secretRef = readOptionalString(value.secretRef);
173
+ if (!apiKeyEnv && !secretRef) {
174
+ throw new Error("manual provider requires apiKeyEnv or secretRef");
175
+ }
176
+ if (apiKeyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) {
177
+ throw new Error("manual provider apiKeyEnv must be a valid environment variable name");
178
+ }
179
+
180
+ return {
181
+ id,
182
+ name: readRequiredString(value.name, "manual provider name"),
183
+ kind: readManualProviderKind(value.kind),
184
+ baseUrl: normalizeProviderBaseUrl(value.baseUrl),
185
+ apiKeyEnv,
186
+ secretRef,
187
+ models: readNonEmptyStringList(value.models, "manual provider models"),
188
+ supportedProtocols: readProtocols(value.supportedProtocols),
189
+ enabled: value.enabled === undefined ? true : value.enabled === true,
190
+ notes: readOptionalString(value.notes),
191
+ createdAt: readOptionalString(value.createdAt) ?? now,
192
+ updatedAt: now
193
+ };
194
+ }
195
+
196
+ export function normalizeAutoProviderConfig(value: unknown, now = new Date().toISOString()): AutoProviderConfig {
197
+ if (!isRecord(value)) {
198
+ return defaultAutoProviderConfig(now);
199
+ }
200
+ return {
201
+ enabled: value.enabled === true,
202
+ range: readAutoProviderRange(value.range),
203
+ scorer: readScorer(value.scorer),
204
+ modelIds: readStringList(value.modelIds),
205
+ sellerIds: readStringList(value.sellerIds),
206
+ maxConcurrentProviders: 10,
207
+ updatedAt: readOptionalString(value.updatedAt) ?? now
208
+ };
209
+ }
210
+
211
+ export function defaultAutoProviderConfig(now = new Date().toISOString()): AutoProviderConfig {
212
+ return {
213
+ enabled: false,
214
+ range: "recommended",
215
+ scorer: "balanced",
216
+ modelIds: [],
217
+ sellerIds: [],
218
+ maxConcurrentProviders: 10,
219
+ updatedAt: now
220
+ };
221
+ }
222
+
223
+ export function normalizeManualProviderObservationsConfig(
224
+ value: unknown,
225
+ now = new Date().toISOString()
226
+ ): ManualProviderObservationsConfig {
227
+ if (!isRecord(value)) {
228
+ return {
229
+ version: 1,
230
+ observations: [],
231
+ updatedAt: now
232
+ };
233
+ }
234
+ const observationsValue = Array.isArray(value.observations) ? value.observations : [];
235
+ const seen = new Set<string>();
236
+ const observations = observationsValue
237
+ .filter(isRecord)
238
+ .map((entry) => normalizeManualProviderObservation(entry, now))
239
+ .filter((entry) => {
240
+ if (seen.has(entry.providerId)) {
241
+ return false;
242
+ }
243
+ seen.add(entry.providerId);
244
+ return true;
245
+ });
246
+ return {
247
+ version: 1,
248
+ observations,
249
+ updatedAt: readOptionalString(value.updatedAt) ?? now
250
+ };
251
+ }
252
+
253
+ export function publicManualProviderConfig(
254
+ provider: ManualProviderConfig,
255
+ observation?: ManualProviderObservation,
256
+ env: NodeJS.ProcessEnv = process.env
257
+ ): PublicManualProviderConfig {
258
+ const keyRef = provider.apiKeyEnv
259
+ ? { kind: "env" as const, name: provider.apiKeyEnv, configured: Boolean(env[provider.apiKeyEnv]) }
260
+ : provider.secretRef
261
+ ? { kind: "secret" as const, name: provider.secretRef, configured: true }
262
+ : undefined;
263
+ const { apiKeyEnv: _apiKeyEnv, secretRef: _secretRef, ...publicProvider } = provider;
264
+ return {
265
+ ...publicProvider,
266
+ keyRef,
267
+ current: observation?.current,
268
+ lastAccess: observation?.lastAccess,
269
+ status: observation?.status,
270
+ errorClass: observation?.errorClass,
271
+ errorMessage: observation?.errorMessage,
272
+ ttftMs: observation?.ttftMs,
273
+ avgTokensPerSecond: observation?.avgTokensPerSecond
274
+ };
275
+ }
276
+
277
+ function normalizeManualProviderObservation(value: Record<string, unknown>, now: string): ManualProviderObservation {
278
+ return {
279
+ providerId: readRequiredString(value.providerId, "manual provider observation providerId"),
280
+ current: value.current === true,
281
+ lastAccess: readOptionalString(value.lastAccess) ?? now,
282
+ status: readObservationStatus(value.status),
283
+ errorClass: readOptionalString(value.errorClass),
284
+ errorMessage: readOptionalString(value.errorMessage),
285
+ ttftMs: readOptionalNumber(value.ttftMs),
286
+ avgTokensPerSecond: readOptionalNumber(value.avgTokensPerSecond)
287
+ };
288
+ }
289
+
290
+ function readObservationStatus(value: unknown): ManualProviderObservation["status"] {
291
+ if (value === "healthy" || value === "degraded" || value === "unhealthy" || value === "unknown") {
292
+ return value;
293
+ }
294
+ return "unknown";
295
+ }
296
+
297
+ function readProviderMode(value: unknown): ProviderMode {
298
+ if (value === "manual" || value === "auto") {
299
+ return value;
300
+ }
301
+ throw new Error("provider mode must be manual or auto");
302
+ }
303
+
304
+ function readManualProviderRoutingPolicy(value: unknown): ManualProviderRoutingPolicy {
305
+ if (value === undefined || value === null || value === "" || value === "fallback") {
306
+ return "fallback";
307
+ }
308
+ if (value === "locked") {
309
+ return "locked";
310
+ }
311
+ throw new Error("manual provider routing policy must be fallback or locked");
312
+ }
313
+
314
+ function readManualProviderKind(value: unknown): ManualProviderKind {
315
+ if (value === undefined || value === null || value === "" || value === "openai-compatible") {
316
+ return "openai-compatible";
317
+ }
318
+ throw new Error("manual provider kind must be openai-compatible");
319
+ }
320
+
321
+ function readAutoProviderRange(value: unknown): AutoProviderRange {
322
+ if (value === undefined || value === null || value === "" || value === "recommended") {
323
+ return "recommended";
324
+ }
325
+ if (value === "custom") {
326
+ return "custom";
327
+ }
328
+ throw new Error("auto provider range must be recommended or custom");
329
+ }
330
+
331
+ function readScorer(value: unknown): SellerRoutingScorer {
332
+ if (value === undefined || value === null || value === "") {
333
+ return "balanced";
334
+ }
335
+ if (typeof value === "string" && VALID_SCORERS.has(value as SellerRoutingScorer)) {
336
+ return value as SellerRoutingScorer;
337
+ }
338
+ throw new Error("auto provider scorer must be balanced, speed, or discount");
339
+ }
340
+
341
+ function readProtocols(value: unknown): ProviderProtocol[] {
342
+ const protocols = readStringList(value);
343
+ if (protocols.length === 0) {
344
+ return ["chat_completions"];
345
+ }
346
+ const invalid = protocols.find((protocol) => !VALID_PROTOCOLS.has(protocol as ProviderProtocol));
347
+ if (invalid) {
348
+ throw new Error(`manual provider protocol is invalid: ${invalid}`);
349
+ }
350
+ return protocols as ProviderProtocol[];
351
+ }
352
+
353
+ function normalizeProviderBaseUrl(value: unknown): string {
354
+ const raw = readRequiredString(value, "manual provider baseUrl");
355
+ let parsed: URL;
356
+ try {
357
+ parsed = new URL(raw);
358
+ } catch {
359
+ throw new Error("manual provider baseUrl must be a valid URL");
360
+ }
361
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
362
+ throw new Error("manual provider baseUrl must use http or https");
363
+ }
364
+ return parsed.toString().replace(/\/+$/, "");
365
+ }
366
+
367
+ function readProviderId(value: unknown): string {
368
+ const id = readRequiredString(value, "manual provider id");
369
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/.test(id)) {
370
+ throw new Error("manual provider id must be 2-64 characters of letters, numbers, underscore, or dash");
371
+ }
372
+ return id;
373
+ }
374
+
375
+ function readRequiredString(value: unknown, label: string): string {
376
+ if (typeof value !== "string" || value.trim().length === 0) {
377
+ throw new Error(`${label} is required`);
378
+ }
379
+ return value.trim();
380
+ }
381
+
382
+ function readOptionalString(value: unknown): string | undefined {
383
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
384
+ }
385
+
386
+ function readOptionalNumber(value: unknown): number | undefined {
387
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
388
+ }
389
+
390
+ function readNonEmptyStringList(value: unknown, label: string): string[] {
391
+ const entries = readStringList(value);
392
+ if (entries.length === 0) {
393
+ throw new Error(`${label} must contain at least one value`);
394
+ }
395
+ return entries;
396
+ }
397
+
398
+ function readStringList(value: unknown): string[] {
399
+ if (!Array.isArray(value)) {
400
+ return [];
401
+ }
402
+ return value
403
+ .filter((entry): entry is string => typeof entry === "string")
404
+ .map((entry) => entry.trim())
405
+ .filter((entry, index, all) => entry.length > 0 && all.indexOf(entry) === index);
406
+ }
407
+
408
+ function isRecord(value: unknown): value is Record<string, unknown> {
409
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
410
+ }