@tokenbuddy/tokenbuddy 1.0.12 → 1.0.14

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 (135) hide show
  1. package/dist/src/buyer-store.d.ts +61 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +12 -0
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts +47 -0
  6. package/dist/src/cli.d.ts.map +1 -1
  7. package/dist/src/cli.js +287 -63
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/credit-tracker.d.ts +26 -0
  10. package/dist/src/credit-tracker.d.ts.map +1 -1
  11. package/dist/src/credit-tracker.js +8 -0
  12. package/dist/src/credit-tracker.js.map +1 -1
  13. package/dist/src/daemon.d.ts +29 -3
  14. package/dist/src/daemon.d.ts.map +1 -1
  15. package/dist/src/daemon.js +292 -65
  16. package/dist/src/daemon.js.map +1 -1
  17. package/dist/src/doctor-clawtip-wallet.d.ts +25 -0
  18. package/dist/src/doctor-clawtip-wallet.d.ts.map +1 -1
  19. package/dist/src/doctor-clawtip-wallet.js +13 -0
  20. package/dist/src/doctor-clawtip-wallet.js.map +1 -1
  21. package/dist/src/doctor-diagnostics.d.ts +63 -0
  22. package/dist/src/doctor-diagnostics.d.ts.map +1 -1
  23. package/dist/src/doctor-diagnostics.js +39 -1
  24. package/dist/src/doctor-diagnostics.js.map +1 -1
  25. package/dist/src/index.d.ts +4 -0
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/init-clawtip-activation.d.ts +103 -0
  30. package/dist/src/init-clawtip-activation.d.ts.map +1 -1
  31. package/dist/src/init-clawtip-activation.js +60 -0
  32. package/dist/src/init-clawtip-activation.js.map +1 -1
  33. package/dist/src/init-payment-options.d.ts +124 -0
  34. package/dist/src/init-payment-options.d.ts.map +1 -1
  35. package/dist/src/init-payment-options.js +68 -0
  36. package/dist/src/init-payment-options.js.map +1 -1
  37. package/dist/src/model-index.d.ts +9 -0
  38. package/dist/src/model-index.d.ts.map +1 -1
  39. package/dist/src/model-index.js.map +1 -1
  40. package/dist/src/prewarm-cache.d.ts +89 -0
  41. package/dist/src/prewarm-cache.d.ts.map +1 -1
  42. package/dist/src/prewarm-cache.js +14 -1
  43. package/dist/src/prewarm-cache.js.map +1 -1
  44. package/dist/src/prewarm-scheduler.d.ts +62 -3
  45. package/dist/src/prewarm-scheduler.d.ts.map +1 -1
  46. package/dist/src/prewarm-scheduler.js +39 -8
  47. package/dist/src/prewarm-scheduler.js.map +1 -1
  48. package/dist/src/provider-install.d.ts +89 -3
  49. package/dist/src/provider-install.d.ts.map +1 -1
  50. package/dist/src/provider-install.js +77 -19
  51. package/dist/src/provider-install.js.map +1 -1
  52. package/dist/src/route-failover.d.ts +48 -0
  53. package/dist/src/route-failover.d.ts.map +1 -1
  54. package/dist/src/route-failover.js.map +1 -1
  55. package/dist/src/seller-catalog.d.ts +158 -10
  56. package/dist/src/seller-catalog.d.ts.map +1 -1
  57. package/dist/src/seller-catalog.js +79 -5
  58. package/dist/src/seller-catalog.js.map +1 -1
  59. package/dist/src/seller-metadata-cache.d.ts +29 -0
  60. package/dist/src/seller-metadata-cache.d.ts.map +1 -0
  61. package/dist/src/seller-metadata-cache.js +71 -0
  62. package/dist/src/seller-metadata-cache.js.map +1 -0
  63. package/dist/src/seller-pool.d.ts +71 -0
  64. package/dist/src/seller-pool.d.ts.map +1 -1
  65. package/dist/src/seller-pool.js +6 -1
  66. package/dist/src/seller-pool.js.map +1 -1
  67. package/dist/src/seller-route-planner.d.ts +118 -0
  68. package/dist/src/seller-route-planner.d.ts.map +1 -0
  69. package/dist/src/seller-route-planner.js +160 -0
  70. package/dist/src/seller-route-planner.js.map +1 -0
  71. package/dist/src/seller-routing-config.d.ts +69 -0
  72. package/dist/src/seller-routing-config.d.ts.map +1 -0
  73. package/dist/src/seller-routing-config.js +164 -0
  74. package/dist/src/seller-routing-config.js.map +1 -0
  75. package/dist/src/seller-routing-strategy.d.ts +118 -0
  76. package/dist/src/seller-routing-strategy.d.ts.map +1 -0
  77. package/dist/src/seller-routing-strategy.js +183 -0
  78. package/dist/src/seller-routing-strategy.js.map +1 -0
  79. package/dist/src/stream-failover.d.ts +23 -0
  80. package/dist/src/stream-failover.d.ts.map +1 -1
  81. package/dist/src/stream-failover.js +4 -0
  82. package/dist/src/stream-failover.js.map +1 -1
  83. package/dist/src/tb-proxyd.js +7 -21
  84. package/dist/src/tb-proxyd.js.map +1 -1
  85. package/dist/src/terminal-detect.d.ts +51 -0
  86. package/dist/src/terminal-detect.d.ts.map +1 -1
  87. package/dist/src/terminal-detect.js +42 -0
  88. package/dist/src/terminal-detect.js.map +1 -1
  89. package/dist/src/terminal-image.d.ts +41 -0
  90. package/dist/src/terminal-image.d.ts.map +1 -1
  91. package/dist/src/terminal-image.js +15 -0
  92. package/dist/src/terminal-image.js.map +1 -1
  93. package/package.json +1 -1
  94. package/src/buyer-store.ts +61 -0
  95. package/src/cli.ts +330 -68
  96. package/src/credit-tracker.ts +26 -0
  97. package/src/daemon.ts +363 -72
  98. package/src/doctor-clawtip-wallet.ts +25 -0
  99. package/src/doctor-diagnostics.ts +63 -1
  100. package/src/index.ts +4 -0
  101. package/src/init-clawtip-activation.ts +103 -0
  102. package/src/init-payment-options.ts +124 -0
  103. package/src/model-index.ts +9 -0
  104. package/src/prewarm-cache.ts +99 -1
  105. package/src/prewarm-scheduler.ts +97 -12
  106. package/src/provider-install.ts +125 -27
  107. package/src/route-failover.ts +48 -0
  108. package/src/seller-catalog.ts +158 -12
  109. package/src/seller-metadata-cache.ts +91 -0
  110. package/src/seller-pool.ts +77 -1
  111. package/src/seller-route-planner.ts +323 -0
  112. package/src/seller-routing-config.ts +198 -0
  113. package/src/seller-routing-strategy.ts +316 -0
  114. package/src/stream-failover.ts +23 -0
  115. package/src/tb-proxyd.ts +7 -23
  116. package/src/terminal-detect.ts +51 -0
  117. package/src/terminal-image.ts +41 -0
  118. package/tests/cli-routing.test.ts +287 -0
  119. package/tests/daemon-classify.test.ts +431 -0
  120. package/tests/daemon-roles.test.ts +92 -0
  121. package/tests/seller-catalog-utilities.test.ts +70 -0
  122. package/tests/seller-metadata-cache.test.ts +89 -0
  123. package/tests/seller-route-planner.test.ts +150 -0
  124. package/tests/seller-routing-config.test.ts +111 -0
  125. package/tests/seller-routing-strategy.test.ts +166 -0
  126. package/tests/tokenbuddy.test.ts +446 -34
  127. /package/{src → tests}/credit-tracker.test.ts +0 -0
  128. /package/{src → tests}/model-index.test.ts +0 -0
  129. /package/{src → tests}/prewarm-cache.test.ts +0 -0
  130. /package/{src → tests}/prewarm-scheduler.test.ts +0 -0
  131. /package/{src → tests}/route-failover.test.ts +0 -0
  132. /package/{src → tests}/seller-catalog-413.test.ts +0 -0
  133. /package/{src → tests}/seller-pool.test.ts +0 -0
  134. /package/{src → tests}/stream-failover.test.ts +0 -0
  135. /package/{src → tests}/thousand-seller.test.ts +0 -0
@@ -0,0 +1,323 @@
1
+ import type { RegistrySeller } from "./seller-catalog.js";
2
+ import {
3
+ planSellerRoutes,
4
+ type RoutingCandidate,
5
+ type SellerRoutingPlan,
6
+ type SellerRoutingStrategyConfig
7
+ } from "./seller-routing-strategy.js";
8
+
9
+ /**
10
+ * `planSellerRouteSet` 候选来源:走了 prewarm cache 还是回退到 registry 顺序。
11
+ */
12
+ export type SellerRouteSource = "prewarm_cache" | "registry_fallback";
13
+ /** 路由层使用的熔断状态枚举,与 `seller-pool.CircuitState` 语义一致。 */
14
+ export type SellerCircuitState = "closed" | "half_open" | "open";
15
+
16
+ /**
17
+ * 路由规划器在合并多个数据源时使用的"实时指标"维度。
18
+ * 由 `SellerPool` 快照聚合;可空字段表示该维度暂未采集。
19
+ */
20
+ export interface SellerRouteMetric {
21
+ /** seller ID */
22
+ sellerId: string;
23
+ /** 综合健康分 0-100,可选 */
24
+ healthScore?: number;
25
+ /** 平均延迟(毫秒),可选 */
26
+ avgLatencyMs?: number;
27
+ /** 折扣系数(0-1),可选;缺省时 scoring 视为"无折扣信息" */
28
+ discountRatio?: number;
29
+ /** 当前熔断状态,可选;`open` 的 seller 直接被剔除候选 */
30
+ circuit?: SellerCircuitState;
31
+ }
32
+
33
+ /**
34
+ * 路由规划器视角的 prewarm 候选:比 `PrewarmCandidate` 少调度内部状态,只保留打分需要的字段。
35
+ */
36
+ export interface SellerRoutePrewarmCandidate {
37
+ /** seller ID */
38
+ sellerId: string;
39
+ /** seller URL(去尾部斜杠) */
40
+ url: string;
41
+ /** 综合健康分,可选 */
42
+ healthScore?: number;
43
+ /** 平均延迟(毫秒),可选 */
44
+ avgLatencyMs?: number;
45
+ }
46
+
47
+ /**
48
+ * 路由规划器视角的 seller 元数据:折扣系数和 manifest 刷新时间。
49
+ * 用于在 `discount` 排序时把"无折扣"和"折扣已知"区分开。
50
+ */
51
+ export interface SellerRouteMetadata {
52
+ sellerId: string;
53
+ discountRatio?: number;
54
+ manifestVersion?: string;
55
+ lastRefreshAt?: number;
56
+ source: "manifest_selection";
57
+ errorMessage?: string;
58
+ }
59
+
60
+ /**
61
+ * `planSellerRouteSet()` 的入参:把"模型 + 协议 + 支付" + 三类数据源(registry / prewarm / 指标)+ 路由策略打包传入。
62
+ */
63
+ export interface SellerRoutePlannerInput {
64
+ /** 目标模型 ID */
65
+ modelId: string;
66
+ /** 协议偏好(`chat_completions` / `responses` / `messages`) */
67
+ protocol: string;
68
+ /** 支付方式(`mock` / `clawtip`) */
69
+ paymentMethod: string;
70
+ /** seller 注册表快照 */
71
+ registrySellers: RegistrySeller[];
72
+ /** buyer 路由策略配置(fixed / fixedSet / fullAuto + 评分器) */
73
+ routing: SellerRoutingStrategyConfig;
74
+ /** 来自 prewarm cache 的候选(可选;提供时优先使用) */
75
+ prewarmCandidates?: SellerRoutePrewarmCandidate[];
76
+ /** seller 实时指标(可选;`circuit=open` 的 seller 被剔除) */
77
+ sellerMetrics?: SellerRouteMetric[];
78
+ }
79
+
80
+ /**
81
+ * 单条规划后的路由:含 seller 描述、URL、聚合后的指标。
82
+ */
83
+ export interface PlannedSellerRoute {
84
+ /** seller 注册表描述(用于后续转发) */
85
+ seller: RegistrySeller;
86
+ /** seller URL(去尾部斜杠) */
87
+ url: string;
88
+ /** 聚合指标:健康分、平均延迟、折扣、registry 声明顺序 */
89
+ metrics: {
90
+ healthScore?: number;
91
+ avgLatencyMs?: number;
92
+ discountRatio?: number;
93
+ /** 在 registry 里的声明顺序(0-based,tie-breaker) */
94
+ registryOrder: number;
95
+ };
96
+ }
97
+
98
+ /**
99
+ * `planSellerRouteSet()` 的返回结果:路由列表 + 来源 + 决策原因 + 模式 / 评分器。
100
+ */
101
+ export interface SellerRoutePlan {
102
+ /** 已排好序的可选 seller 列表(按路由策略打分) */
103
+ routes: PlannedSellerRoute[];
104
+ /** 候选来源:`prewarm_cache` 或 `registry_fallback` */
105
+ source: SellerRouteSource;
106
+ /** 候选来源的细化原因(用于诊断 / 稳定事件断言) */
107
+ sourceReason: string;
108
+ /** 路由策略输出的整体决策原因 */
109
+ reason: string;
110
+ /** 路由模式(`fixed` / `fixedSet` / `fullAuto`) */
111
+ mode: SellerRoutingPlan["mode"];
112
+ /** 使用的评分器(`speed` / `discount` / `balanced`) */
113
+ scorer: SellerRoutingPlan["scorer"];
114
+ /** 候选总数(prewarm 阶段剔除了不支持模型 / 协议 / 支付的 seller 之前) */
115
+ candidateCount: number;
116
+ }
117
+
118
+ interface CandidateSourceResult {
119
+ source: SellerRouteSource;
120
+ sourceReason: string;
121
+ candidates: RoutingCandidate[];
122
+ }
123
+
124
+ interface IndexedSeller {
125
+ seller: RegistrySeller;
126
+ registryOrder: number;
127
+ }
128
+
129
+ interface MetricIndex {
130
+ bySellerId: Map<string, SellerRouteMetric>;
131
+ openSellerIds: Set<string>;
132
+ }
133
+
134
+ /**
135
+ * 给定 (model, protocol, payment) + 三类数据源,规划出有序的 seller 路由列表。
136
+ * 决策流程:
137
+ * 1. 索引 registry sellers(按声明顺序)。
138
+ * 2. 索引 `sellerMetrics`,把 `circuit=open` 的 seller 标记为剔除。
139
+ * 3. 优先从 `prewarmCandidates` 构造候选;都不可用时回退 registry 顺序。
140
+ * 4. 调 `planSellerRoutes`(`seller-routing-strategy`)做策略评分。
141
+ * 5. 把 `RoutingCandidate` 转回 `PlannedSellerRoute`,加上 `registryOrder`。
142
+ *
143
+ * @param input 规划入参
144
+ * @returns 规划后的路由计划
145
+ */
146
+ export function planSellerRouteSet(input: SellerRoutePlannerInput): SellerRoutePlan {
147
+ const indexed = indexRegistrySellers(input.registrySellers);
148
+ const metrics = indexMetrics(input.sellerMetrics);
149
+ const source = chooseCandidateSource(input, indexed, metrics);
150
+ const strategyPlan = planSellerRoutes(source.candidates, input.routing);
151
+ const routes = strategyPlan.routes.map((candidate) => {
152
+ const seller = indexed.bySellerId.get(candidate.sellerId)?.seller;
153
+ if (!seller) {
154
+ throw new Error(`planned seller ${candidate.sellerId} is missing from registry index`);
155
+ }
156
+ return {
157
+ seller,
158
+ url: candidate.url,
159
+ metrics: {
160
+ healthScore: candidate.healthScore,
161
+ avgLatencyMs: candidate.avgLatencyMs,
162
+ discountRatio: candidate.discountRatio,
163
+ registryOrder: candidate.registryOrder
164
+ }
165
+ };
166
+ });
167
+
168
+ return {
169
+ routes,
170
+ source: source.source,
171
+ sourceReason: source.sourceReason,
172
+ reason: strategyPlan.reason,
173
+ mode: strategyPlan.mode,
174
+ scorer: strategyPlan.scorer,
175
+ candidateCount: source.candidates.length
176
+ };
177
+ }
178
+
179
+ function chooseCandidateSource(
180
+ input: SellerRoutePlannerInput,
181
+ indexed: ReturnType<typeof indexRegistrySellers>,
182
+ metrics: MetricIndex
183
+ ): CandidateSourceResult {
184
+ const prewarm = input.prewarmCandidates ?? [];
185
+ if (prewarm.length > 0) {
186
+ const prewarmCandidates = prewarm
187
+ .map((candidate) => {
188
+ const indexedSeller = indexed.bySellerId.get(candidate.sellerId);
189
+ if (!indexedSeller) {
190
+ return undefined;
191
+ }
192
+ if (metrics.openSellerIds.has(indexedSeller.seller.id)) {
193
+ return undefined;
194
+ }
195
+ return buildCandidate({
196
+ seller: indexedSeller.seller,
197
+ registryOrder: indexedSeller.registryOrder,
198
+ modelId: input.modelId,
199
+ protocol: input.protocol,
200
+ paymentMethod: input.paymentMethod,
201
+ metric: mergeMetric(metrics.bySellerId.get(candidate.sellerId), candidate)
202
+ });
203
+ })
204
+ .filter((candidate): candidate is RoutingCandidate => Boolean(candidate))
205
+ .filter(isSelectableCandidate);
206
+
207
+ if (prewarmCandidates.length > 0) {
208
+ return {
209
+ source: "prewarm_cache",
210
+ sourceReason: "prewarm_candidates_compatible",
211
+ candidates: prewarmCandidates
212
+ };
213
+ }
214
+ }
215
+
216
+ return {
217
+ source: "registry_fallback",
218
+ sourceReason: prewarm.length > 0 ? "prewarm_no_compatible_candidates" : "prewarm_missing",
219
+ candidates: indexed.ordered
220
+ .filter((entry) => !metrics.openSellerIds.has(entry.seller.id))
221
+ .map((entry) => buildCandidate({
222
+ seller: entry.seller,
223
+ registryOrder: entry.registryOrder,
224
+ modelId: input.modelId,
225
+ protocol: input.protocol,
226
+ paymentMethod: input.paymentMethod,
227
+ metric: metrics.bySellerId.get(entry.seller.id)
228
+ }))
229
+ .filter(isSelectableCandidate)
230
+ };
231
+ }
232
+
233
+ function buildCandidate(input: {
234
+ seller: RegistrySeller;
235
+ registryOrder: number;
236
+ modelId: string;
237
+ protocol: string;
238
+ paymentMethod: string;
239
+ metric?: SellerRouteMetric;
240
+ }): RoutingCandidate {
241
+ return {
242
+ sellerId: input.seller.id,
243
+ url: trimTrailingSlashes(input.seller.url),
244
+ supportsModel: sellerSupportsModel(input.seller, input.modelId),
245
+ supportsProtocol: sellerSupportsProtocol(input.seller, input.protocol),
246
+ supportsPayment: sellerSupportsPayment(input.seller, input.paymentMethod),
247
+ healthScore: input.metric?.healthScore,
248
+ avgLatencyMs: input.metric?.avgLatencyMs,
249
+ discountRatio: input.metric?.discountRatio,
250
+ registryOrder: input.registryOrder
251
+ };
252
+ }
253
+
254
+ function isSelectableCandidate(candidate: RoutingCandidate): boolean {
255
+ return candidate.supportsModel && candidate.supportsProtocol && candidate.supportsPayment;
256
+ }
257
+
258
+ function indexRegistrySellers(sellers: RegistrySeller[]): {
259
+ ordered: IndexedSeller[];
260
+ bySellerId: Map<string, IndexedSeller>;
261
+ } {
262
+ const ordered = sellers
263
+ .filter((seller) => Boolean(seller?.id && seller.url))
264
+ .map((seller, registryOrder) => ({ seller, registryOrder }));
265
+ return {
266
+ ordered,
267
+ bySellerId: new Map(ordered.map((entry) => [entry.seller.id, entry]))
268
+ };
269
+ }
270
+
271
+ function indexMetrics(metrics: SellerRouteMetric[] | undefined): MetricIndex {
272
+ const openSellerIds = new Set((metrics ?? [])
273
+ .filter((metric) => metric.circuit === "open")
274
+ .map((metric) => metric.sellerId));
275
+ return {
276
+ bySellerId: new Map((metrics ?? [])
277
+ .filter((metric) => metric.circuit !== "open")
278
+ .map((metric) => [metric.sellerId, metric])),
279
+ openSellerIds
280
+ };
281
+ }
282
+
283
+ function mergeMetric(
284
+ metric: SellerRouteMetric | undefined,
285
+ prewarm: SellerRoutePrewarmCandidate
286
+ ): SellerRouteMetric {
287
+ return {
288
+ sellerId: prewarm.sellerId,
289
+ healthScore: prewarm.healthScore ?? metric?.healthScore,
290
+ avgLatencyMs: prewarm.avgLatencyMs ?? metric?.avgLatencyMs,
291
+ discountRatio: metric?.discountRatio,
292
+ circuit: metric?.circuit
293
+ };
294
+ }
295
+
296
+ function sellerSupportsModel(seller: RegistrySeller, modelId: string): boolean {
297
+ const normalized = normalizeLookupValue(modelId);
298
+ return (seller.models ?? []).some((model) => normalizeLookupValue(model) === normalized);
299
+ }
300
+
301
+ function sellerSupportsProtocol(seller: RegistrySeller, protocol: string): boolean {
302
+ const normalized = normalizeLookupValue(protocol);
303
+ return protocolAliases(seller.supportedProtocols ?? []).some((entry) => normalizeLookupValue(entry) === normalized);
304
+ }
305
+
306
+ function sellerSupportsPayment(seller: RegistrySeller, paymentMethod: string): boolean {
307
+ const normalized = normalizeLookupValue(paymentMethod);
308
+ return (seller.paymentMethods ?? []).some((entry) => normalizeLookupValue(entry) === normalized);
309
+ }
310
+
311
+ function protocolAliases(protocols: string[]): string[] {
312
+ return protocols.includes("anthropic_messages") && !protocols.includes("messages")
313
+ ? [...protocols, "messages"]
314
+ : protocols;
315
+ }
316
+
317
+ function normalizeLookupValue(value: string): string {
318
+ return value.trim().toLowerCase();
319
+ }
320
+
321
+ function trimTrailingSlashes(value: string): string {
322
+ return value.replace(/\/+$/, "");
323
+ }
@@ -0,0 +1,198 @@
1
+ import type {
2
+ SellerRoutingMode,
3
+ SellerRoutingScorer,
4
+ SellerRoutingStrategyConfig
5
+ } from "./seller-routing-strategy.js";
6
+
7
+ /** buyer 配置存盘时使用的顶层 key,对应 `BuyerStore.set("routing", ...)` */
8
+ export const ROUTING_CONFIG_KEY = "routing";
9
+ /** 缺省 scorer:健康 + 速度 + 折扣三方面均衡 */
10
+ export const DEFAULT_ROUTING_SCORER: SellerRoutingScorer = "balanced";
11
+
12
+ /**
13
+ * buyer 端的 seller 路由配置,继承策略层 `SellerRoutingStrategyConfig`,
14
+ * 在此基础上强制 `mode` 和 `scorer` 都必须存在(策略层允许省略)。
15
+ */
16
+ export interface BuyerSellerRoutingConfig extends SellerRoutingStrategyConfig {
17
+ /** 路由模式:`fixed` / `fixedSet` / `fullAuto` */
18
+ mode: SellerRoutingMode;
19
+ /** 评分器:`speed` / `discount` / `balanced` */
20
+ scorer: SellerRoutingScorer;
21
+ }
22
+
23
+ /**
24
+ * 返回 buyer 默认的 seller 路由配置:`fullAuto` + `balanced`。
25
+ * 用于首次启动没有持久化配置时的兜底。
26
+ *
27
+ * @returns buyer 默认路由配置
28
+ */
29
+ export function defaultSellerRoutingConfig(): BuyerSellerRoutingConfig {
30
+ return {
31
+ mode: "fullAuto",
32
+ scorer: DEFAULT_ROUTING_SCORER
33
+ };
34
+ }
35
+
36
+ /**
37
+ * 把任意 `unknown`(通常来自 buyer-store 读盘或外部输入)归一化为合法的
38
+ * `BuyerSellerRoutingConfig`。非法 `mode` / `scorer` 抛错,缺字段则回退到默认。
39
+ *
40
+ * @param value 任意输入值(对象 / undefined / 字符串等)
41
+ * @returns 归一化后的路由配置
42
+ * @throws 当 `value.mode` 是非 `fixed` / `fixedSet` / `fullAuto` 时
43
+ * @throws 当 `value.scorer` 是非 `speed` / `discount` / `balanced` 时
44
+ */
45
+ export function normalizeSellerRoutingConfig(value: unknown): BuyerSellerRoutingConfig {
46
+ if (!isObject(value)) {
47
+ return defaultSellerRoutingConfig();
48
+ }
49
+
50
+ const mode = readMode(value.mode);
51
+ const scorer = readScorer(value.scorer);
52
+ if (mode === "fixed") {
53
+ return {
54
+ mode,
55
+ sellerId: readOptionalString(value.sellerId),
56
+ scorer
57
+ };
58
+ }
59
+ if (mode === "fixedSet") {
60
+ return {
61
+ mode,
62
+ sellerIds: readSellerIds(value.sellerIds),
63
+ scorer
64
+ };
65
+ }
66
+ return {
67
+ mode: "fullAuto",
68
+ scorer
69
+ };
70
+ }
71
+
72
+ /**
73
+ * 从 `process.env`(可注入)里解析出 `TB_PROXYD_ROUTING_*` 覆盖项。
74
+ * 至少有一个变量非空时才返回 `Partial<BuyerSellerRoutingConfig>`,否则返回 `undefined`。
75
+ * 当 `mode` 未指定时,会根据 `sellerId` / `sellerIds` 是否存在自动选择 `fixed` / `fixedSet` / `fullAuto`。
76
+ *
77
+ * @param env 进程环境变量(默认 `process.env`,测试可注入)
78
+ * @returns 解析出的覆盖项;无任何相关变量时返回 `undefined`
79
+ * @throws 当 `TB_PROXYD_ROUTING_MODE` 或 `TB_PROXYD_ROUTING_SCORER` 取值非法时
80
+ */
81
+ export function parseSellerRoutingEnv(env: NodeJS.ProcessEnv = process.env): Partial<BuyerSellerRoutingConfig> | undefined {
82
+ const modeRaw = env.TB_PROXYD_ROUTING_MODE?.trim();
83
+ const scorerRaw = env.TB_PROXYD_ROUTING_SCORER?.trim();
84
+ const sellerId = env.TB_PROXYD_ROUTING_SELLER_ID?.trim();
85
+ const sellerIdsRaw = env.TB_PROXYD_ROUTING_SELLER_IDS?.trim();
86
+
87
+ if (!modeRaw && !scorerRaw && !sellerId && !sellerIdsRaw) {
88
+ return undefined;
89
+ }
90
+
91
+ const mode = modeRaw
92
+ ? readMode(modeRaw)
93
+ : sellerId
94
+ ? "fixed"
95
+ : sellerIdsRaw
96
+ ? "fixedSet"
97
+ : "fullAuto";
98
+ const scorer = scorerRaw ? readScorer(scorerRaw) : DEFAULT_ROUTING_SCORER;
99
+
100
+ if (mode === "fixed") {
101
+ return { mode, scorer, sellerId: sellerId || undefined };
102
+ }
103
+ if (mode === "fixedSet") {
104
+ return { mode, scorer, sellerIds: parseSellerIdList(sellerIdsRaw || "") };
105
+ }
106
+ return { mode: "fullAuto", scorer };
107
+ }
108
+
109
+ /**
110
+ * 合并"buyer-store 持久化值"和"运行时 override":override 字段优先,
111
+ * 但合并结果会再次走 `normalizeSellerRoutingConfig` 校验。
112
+ *
113
+ * @param stored buyer-store 读出的原始值(可能是 undefined 或非法对象)
114
+ * @param override 运行时 override(CLI / 临时开关)
115
+ * @returns 合并并归一化后的配置
116
+ * @throws 当合并结果包含非法 `mode` / `scorer` 时
117
+ */
118
+ export function mergeSellerRoutingConfig(
119
+ stored: unknown,
120
+ override?: Partial<BuyerSellerRoutingConfig>
121
+ ): BuyerSellerRoutingConfig {
122
+ const base = normalizeSellerRoutingConfig(stored);
123
+ if (!override) {
124
+ return base;
125
+ }
126
+ return normalizeSellerRoutingConfig({
127
+ ...base,
128
+ ...override
129
+ });
130
+ }
131
+
132
+ /**
133
+ * 把逗号分隔的 seller ID 列表解析为去重、trim、过滤空串后的字符串数组。
134
+ *
135
+ * @param value 原始字符串,例:`"a,b,b, c,,"`
136
+ * @returns 去重后的 seller ID 列表
137
+ */
138
+ export function parseSellerIdList(value: string): string[] {
139
+ return value
140
+ .split(",")
141
+ .map((entry) => entry.trim())
142
+ .filter((entry, index, all) => entry.length > 0 && all.indexOf(entry) === index);
143
+ }
144
+
145
+ /**
146
+ * 校验 `BuyerSellerRoutingConfig` 是否满足当前模式的必要字段。
147
+ * `fixed` 模式必须有非空 `sellerId`;`fixedSet` 模式必须有非空 `sellerIds`。
148
+ *
149
+ * @param config 待校验的路由配置
150
+ * @throws 当 `fixed` 模式缺少 `sellerId` 时
151
+ * @throws 当 `fixedSet` 模式缺少 `sellerIds` 时
152
+ */
153
+ export function assertSellerRoutingConfig(config: BuyerSellerRoutingConfig): void {
154
+ if (config.mode === "fixed" && !config.sellerId?.trim()) {
155
+ throw new Error("fixed routing requires --seller <sellerId>");
156
+ }
157
+ if (config.mode === "fixedSet" && (!config.sellerIds || config.sellerIds.length === 0)) {
158
+ throw new Error("fixedSet routing requires --seller-set <sellerId[,sellerId...]>");
159
+ }
160
+ }
161
+
162
+ function readMode(value: unknown): SellerRoutingMode {
163
+ if (value === "fixed" || value === "fixedSet" || value === "fullAuto") {
164
+ return value;
165
+ }
166
+ throw new Error("seller routing mode must be fixed, fixedSet, or fullAuto");
167
+ }
168
+
169
+ function readScorer(value: unknown): SellerRoutingScorer {
170
+ if (value === undefined || value === null || value === "") {
171
+ return DEFAULT_ROUTING_SCORER;
172
+ }
173
+ if (value === "speed" || value === "discount" || value === "balanced") {
174
+ return value;
175
+ }
176
+ throw new Error("seller routing scorer must be speed, discount, or balanced");
177
+ }
178
+
179
+ function readOptionalString(value: unknown): string | undefined {
180
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
181
+ }
182
+
183
+ function readSellerIds(value: unknown): string[] {
184
+ if (Array.isArray(value)) {
185
+ return value
186
+ .filter((entry): entry is string => typeof entry === "string")
187
+ .map((entry) => entry.trim())
188
+ .filter((entry, index, all) => entry.length > 0 && all.indexOf(entry) === index);
189
+ }
190
+ if (typeof value === "string") {
191
+ return parseSellerIdList(value);
192
+ }
193
+ return [];
194
+ }
195
+
196
+ function isObject(value: unknown): value is Record<string, unknown> {
197
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
198
+ }