@tokenbuddy/tokenbuddy 1.0.11 → 1.0.13

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 -17
  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 -25
  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 +447 -33
  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,316 @@
1
+ /**
2
+ * seller 路由模式:
3
+ * - `fixed`:强制使用单个 seller(`sellerId`)
4
+ * - `fixedSet`:在指定 seller 集合内挑选
5
+ * - `fullAuto`:根据指标和 scorer 自动排序
6
+ */
7
+ export type SellerRoutingMode = "fixed" | "fixedSet" | "fullAuto";
8
+ /**
9
+ * 评分器:决定如何把候选的健康/延迟/折扣分折算成总分。
10
+ * - `speed`:TTFT / 推理延迟优先
11
+ * - `discount`:折扣系数优先
12
+ * - `balanced`:三方面加权均衡
13
+ */
14
+ export type SellerRoutingScorer = "speed" | "discount" | "balanced";
15
+
16
+ /**
17
+ * seller 路由策略配置:模式 + 可选的目标 seller 列表 + 可选评分器。
18
+ * 策略层不强制 `scorer` 必填(缺省走 `balanced`);buyer 配置层
19
+ * `BuyerSellerRoutingConfig` 会进一步收紧。
20
+ */
21
+ export interface SellerRoutingStrategyConfig {
22
+ /** 路由模式 */
23
+ mode: SellerRoutingMode;
24
+ /** `fixed` 模式下唯一允许的 seller ID */
25
+ sellerId?: string;
26
+ /** `fixedSet` 模式下的候选 seller ID 列表(去重) */
27
+ sellerIds?: string[];
28
+ /** 评分器;省略时按 `balanced` 处理 */
29
+ scorer?: SellerRoutingScorer;
30
+ }
31
+
32
+ /**
33
+ * 路由规划阶段的中间数据结构:聚合了"兼容性 + 健康画像 + 折扣"。
34
+ * `supportsModel/Protocol/Payment` 由 `sellerSupports*` 系列函数计算,
35
+ * 任一为 `false` 的 candidate 都会被策略层过滤。
36
+ */
37
+ export interface RoutingCandidate {
38
+ /** seller ID */
39
+ sellerId: string;
40
+ /** 去掉尾部斜杠的 seller URL */
41
+ url: string;
42
+ /** 是否在 seller 的 `models` 列表里 */
43
+ supportsModel: boolean;
44
+ /** 是否在 seller 的 `supportedProtocols` 列表里(含 `messages` alias) */
45
+ supportsProtocol: boolean;
46
+ /** 是否在 seller 的 `paymentMethods` 列表里 */
47
+ supportsPayment: boolean;
48
+ /** 综合健康分 0-100,可选 */
49
+ healthScore?: number;
50
+ /** 平均延迟(毫秒),可选 */
51
+ avgLatencyMs?: number;
52
+ /** health probe 延迟(毫秒),可选 */
53
+ healthProbeLatencyMs?: number;
54
+ /** TTFT(毫秒),可选 */
55
+ ttftMs?: number;
56
+ /** 平均推理延迟(毫秒),可选 */
57
+ avgInferenceMs?: number;
58
+ /** 折扣系数 0-1,可选;缺省视为"无折扣信息" */
59
+ discountRatio?: number;
60
+ /** 上游状态,可选 */
61
+ upstreamStatus?: "healthy" | "degraded" | "unhealthy" | "unknown";
62
+ /** 上游错误类名,可选 */
63
+ upstreamErrorClass?: string;
64
+ /** 在 registry 里的声明顺序(0-based),用于打分平局时的 deterministic 兜底 */
65
+ registryOrder: number;
66
+ }
67
+
68
+ /**
69
+ * `planSellerRoutes()` 的输出:选中的候选列表(已按 scorer 排序)+ 决策溯源。
70
+ */
71
+ export interface SellerRoutingPlan {
72
+ /** 已按策略排序的候选 seller 列表 */
73
+ routes: RoutingCandidate[];
74
+ /** 路由模式(来自 config) */
75
+ mode: SellerRoutingMode;
76
+ /** 实际生效的评分器(config 缺省时为 `balanced`) */
77
+ scorer: SellerRoutingScorer;
78
+ /** 决策原因,例:`fullAuto:balanced:routes_3`、`fixed_seller_not_compatible` */
79
+ reason: string;
80
+ }
81
+
82
+ /**
83
+ * 单个 candidate 在某 scorer 下的打分拆解:总分 + 各维度分量 + 缺失的输入。
84
+ * 用于路由诊断 / `tb doctor` 面板展示。
85
+ */
86
+ export interface CandidateScoreBreakdown {
87
+ /** 评分器 */
88
+ scorer: SellerRoutingScorer;
89
+ /** 总分(按 scorer 权重加和) */
90
+ totalScore: number;
91
+ /** 健康分量(仅 `speed` / `balanced` 有意义) */
92
+ healthComponent?: number;
93
+ /** TTFT 分量(仅 `speed` / `balanced` 有意义) */
94
+ ttftComponent?: number;
95
+ /** 平均推理延迟分量(仅 `speed` / `balanced` 有意义) */
96
+ avgInferenceComponent?: number;
97
+ /** 折扣分量(仅 `discount` / `balanced` 有意义) */
98
+ discountComponent?: number;
99
+ /** 打分时缺失的输入项;缺越多则越说明"无依据" */
100
+ missingInputs: Array<"healthScore" | "ttftMs" | "avgInferenceMs" | "discountRatio">;
101
+ }
102
+
103
+ type SortableCandidate = RoutingCandidate & { score: number };
104
+
105
+ const DEFAULT_SCORER: SellerRoutingScorer = "balanced";
106
+
107
+ /**
108
+ * 根据 `config.mode` 和 `config.scorer` 把候选 seller 过滤、排序并返回路由计划。
109
+ * - `fixed` / `fixedSet` 模式只保留命中的 seller(命中为空时返回空 routes + 决策原因)
110
+ * - `fullAuto` 模式使用所有兼容候选
111
+ * - 排序时按 scorer 计算分数,分数相同时按 `registryOrder` 兜底
112
+ *
113
+ * @param candidates 候选列表(已包含兼容性布尔位)
114
+ * @param config 路由策略配置
115
+ * @returns 路由计划,含 routes / mode / scorer / reason
116
+ */
117
+ export function planSellerRoutes(
118
+ candidates: RoutingCandidate[],
119
+ config: SellerRoutingStrategyConfig
120
+ ): SellerRoutingPlan {
121
+ const scorer = config.scorer ?? DEFAULT_SCORER;
122
+ const compatible = candidates.filter(isCompatibleCandidate);
123
+ const selected = selectCandidates(compatible, config);
124
+
125
+ if (selected.reason !== "ok") {
126
+ return {
127
+ routes: [],
128
+ mode: config.mode,
129
+ scorer,
130
+ reason: selected.reason
131
+ };
132
+ }
133
+
134
+ return {
135
+ routes: sortCandidates(selected.candidates, scorer),
136
+ mode: config.mode,
137
+ scorer,
138
+ reason: routeReason(config.mode, scorer, selected.candidates.length)
139
+ };
140
+ }
141
+
142
+ function selectCandidates(
143
+ compatible: RoutingCandidate[],
144
+ config: SellerRoutingStrategyConfig
145
+ ): { candidates: RoutingCandidate[]; reason: string } {
146
+ if (config.mode === "fixed") {
147
+ const sellerId = normalizeSellerId(config.sellerId);
148
+ if (!sellerId) {
149
+ return { candidates: [], reason: "fixed_seller_missing" };
150
+ }
151
+ const matched = compatible.filter((candidate) => normalizeSellerId(candidate.sellerId) === sellerId);
152
+ return matched.length > 0
153
+ ? { candidates: matched, reason: "ok" }
154
+ : { candidates: [], reason: "fixed_seller_not_compatible" };
155
+ }
156
+
157
+ if (config.mode === "fixedSet") {
158
+ const sellerIds = normalizedSellerIdSet(config.sellerIds);
159
+ if (sellerIds.size === 0) {
160
+ return { candidates: [], reason: "fixed_set_empty" };
161
+ }
162
+ const matched = compatible.filter((candidate) => sellerIds.has(normalizeSellerId(candidate.sellerId)));
163
+ return matched.length > 0
164
+ ? { candidates: matched, reason: "ok" }
165
+ : { candidates: [], reason: "fixed_set_no_compatible_seller" };
166
+ }
167
+
168
+ if (config.mode === "fullAuto") {
169
+ return compatible.length > 0
170
+ ? { candidates: compatible, reason: "ok" }
171
+ : { candidates: [], reason: "no_compatible_seller" };
172
+ }
173
+
174
+ return { candidates: [], reason: "unsupported_routing_mode" };
175
+ }
176
+
177
+ function isCompatibleCandidate(candidate: RoutingCandidate): boolean {
178
+ return candidate.supportsModel && candidate.supportsProtocol && candidate.supportsPayment;
179
+ }
180
+
181
+ function normalizedSellerIdSet(ids: string[] | undefined): Set<string> {
182
+ return new Set((ids ?? []).map(normalizeSellerId).filter(Boolean));
183
+ }
184
+
185
+ function normalizeSellerId(id: string | undefined): string {
186
+ return id?.trim().toLowerCase() ?? "";
187
+ }
188
+
189
+ function sortCandidates(candidates: RoutingCandidate[], scorer: SellerRoutingScorer): RoutingCandidate[] {
190
+ return candidates
191
+ .map((candidate) => ({ ...candidate, score: scoreCandidateBreakdown(candidate, scorer).totalScore }))
192
+ .sort((a, b) => compareCandidates(a, b, scorer))
193
+ .map(({ score: _score, ...candidate }) => candidate);
194
+ }
195
+
196
+ function compareCandidates(a: SortableCandidate, b: SortableCandidate, scorer: SellerRoutingScorer): number {
197
+ const scoreDiff = b.score - a.score;
198
+ if (scoreDiff !== 0) {
199
+ return scoreDiff;
200
+ }
201
+
202
+ if (scorer === "speed") {
203
+ return compareFiniteAsc(effectiveTtftMs(a), effectiveTtftMs(b))
204
+ || compareFiniteAsc(effectiveAvgInferenceMs(a), effectiveAvgInferenceMs(b))
205
+ || compareFiniteDesc(a.healthScore, b.healthScore)
206
+ || compareRegistryOrder(a, b);
207
+ }
208
+
209
+ if (scorer === "discount") {
210
+ return compareFiniteAsc(a.discountRatio, b.discountRatio)
211
+ || compareFiniteDesc(a.healthScore, b.healthScore)
212
+ || compareRegistryOrder(a, b);
213
+ }
214
+
215
+ return compareRegistryOrder(a, b);
216
+ }
217
+
218
+ /**
219
+ * 计算单个 candidate 在指定 scorer 下的完整打分拆解(含各维度分量和缺失项)。
220
+ * 不会修改输入 candidate,常用于 doctor 面板和调试日志。
221
+ *
222
+ * @param candidate 待打分的候选
223
+ * @param scorer 评分器:`speed` / `discount` / `balanced`
224
+ * @returns 打分拆解
225
+ */
226
+ export function scoreCandidateBreakdown(candidate: RoutingCandidate, scorer: SellerRoutingScorer): CandidateScoreBreakdown {
227
+ const missingInputs = missingScoreInputs(candidate);
228
+ if (scorer === "speed") {
229
+ const ttftComponent = latencyScore(effectiveTtftMs(candidate)) * 0.65;
230
+ const avgInferenceComponent = latencyScore(effectiveAvgInferenceMs(candidate)) * 0.25;
231
+ const healthComponent = finiteOr(candidate.healthScore, 0) * 0.1;
232
+ return {
233
+ scorer,
234
+ totalScore: ttftComponent + avgInferenceComponent + healthComponent,
235
+ healthComponent,
236
+ ttftComponent,
237
+ avgInferenceComponent,
238
+ missingInputs
239
+ };
240
+ }
241
+
242
+ if (scorer === "discount") {
243
+ const totalScore = -finiteOr(candidate.discountRatio, Number.POSITIVE_INFINITY);
244
+ return {
245
+ scorer,
246
+ totalScore,
247
+ discountComponent: totalScore,
248
+ missingInputs
249
+ };
250
+ }
251
+
252
+ const healthComponent = finiteOr(candidate.healthScore, 0) * 0.35;
253
+ const ttftComponent = latencyScore(effectiveTtftMs(candidate)) * 0.2;
254
+ const avgInferenceComponent = latencyScore(effectiveAvgInferenceMs(candidate)) * 0.2;
255
+ const discountComponent = discountScore(candidate.discountRatio) * 0.25;
256
+ return {
257
+ scorer,
258
+ totalScore: healthComponent + ttftComponent + avgInferenceComponent + discountComponent,
259
+ healthComponent,
260
+ ttftComponent,
261
+ avgInferenceComponent,
262
+ discountComponent,
263
+ missingInputs
264
+ };
265
+ }
266
+
267
+ function latencyScore(latencyMs: number | undefined): number {
268
+ if (!Number.isFinite(latencyMs)) {
269
+ return 0;
270
+ }
271
+ return Math.max(0, 100 - Math.max(0, latencyMs as number) / 10);
272
+ }
273
+
274
+ function discountScore(discountRatio: number | undefined): number {
275
+ if (!Number.isFinite(discountRatio)) {
276
+ return 0;
277
+ }
278
+ return Math.max(0, 100 * (1 - Math.max(0, discountRatio as number)));
279
+ }
280
+
281
+ function finiteOr(value: number | undefined, fallback: number): number {
282
+ return Number.isFinite(value) ? value as number : fallback;
283
+ }
284
+
285
+ function compareFiniteAsc(a: number | undefined, b: number | undefined): number {
286
+ return finiteOr(a, Number.POSITIVE_INFINITY) - finiteOr(b, Number.POSITIVE_INFINITY);
287
+ }
288
+
289
+ function compareFiniteDesc(a: number | undefined, b: number | undefined): number {
290
+ return finiteOr(b, Number.NEGATIVE_INFINITY) - finiteOr(a, Number.NEGATIVE_INFINITY);
291
+ }
292
+
293
+ function effectiveTtftMs(candidate: RoutingCandidate): number | undefined {
294
+ return candidate.ttftMs ?? candidate.healthProbeLatencyMs ?? candidate.avgLatencyMs;
295
+ }
296
+
297
+ function effectiveAvgInferenceMs(candidate: RoutingCandidate): number | undefined {
298
+ return candidate.avgInferenceMs ?? candidate.avgLatencyMs ?? candidate.healthProbeLatencyMs;
299
+ }
300
+
301
+ function compareRegistryOrder(a: RoutingCandidate, b: RoutingCandidate): number {
302
+ return a.registryOrder - b.registryOrder;
303
+ }
304
+
305
+ function routeReason(mode: SellerRoutingMode, scorer: SellerRoutingScorer, count: number): string {
306
+ return `${mode}:${scorer}:routes_${count}`;
307
+ }
308
+
309
+ function missingScoreInputs(candidate: RoutingCandidate): CandidateScoreBreakdown["missingInputs"] {
310
+ const missing: CandidateScoreBreakdown["missingInputs"] = [];
311
+ if (!Number.isFinite(candidate.healthScore)) missing.push("healthScore");
312
+ if (!Number.isFinite(candidate.ttftMs)) missing.push("ttftMs");
313
+ if (!Number.isFinite(candidate.avgInferenceMs)) missing.push("avgInferenceMs");
314
+ if (!Number.isFinite(candidate.discountRatio)) missing.push("discountRatio");
315
+ return missing;
316
+ }
@@ -16,19 +16,42 @@ const logger = createModuleLogger("tb-proxyd:stream-failover");
16
16
  * doc) because that would double-charge and would require non-trivial
17
17
  * idempotency re-design. v1.2 = abort + retry; v2 may revisit.
18
18
  */
19
+ /**
20
+ * v1.2 §6 / §18.10:stream-failover 策略。
21
+ * buyer 端遵循"abort + client retry"契约:一旦首字节 SSE 写入客户端,上游流失败只能 abrupt close
22
+ * 并附带 `X-TokenBuddy-Retry-Hint: 1` trailer;由 OpenAI / Anthropic SDK 重新发起请求,
23
+ * buyer 切到健康的 seller 重发。
24
+ *
25
+ * 该模块的决策是单向的:buyer 不会把两条流拼接(design doc 里的 option B),
26
+ * 因为会双重计费且需要重做幂等。v1.2 = abort + retry;v2 再考虑。
27
+ */
19
28
  export interface StreamFailoverOptions {
29
+ /** abort 响应中携带的 retry hint 响应头名,默认 `X-TokenBuddy-Retry-Hint` */
20
30
  retryHintHeader?: string;
31
+ /** 时间源,默认 `Date.now`;测试可注入 */
21
32
  now?: () => number;
22
33
  }
23
34
 
35
+ /**
36
+ * `StreamFailover.decideOnStreamAbort` 的返回结果。
37
+ */
24
38
  export interface StreamFailoverDecision {
39
+ /** 决策动作:abort_with_retry_hint(已写首字节,abrupt close) / let_stream_complete(路由层可换 seller) */
25
40
  action: "abort_with_retry_hint" | "let_stream_complete";
41
+ /** 决策原因(用于日志和稳定事件断言) */
26
42
  reason: string;
43
+ /** retry hint 头的取值(`"0"` 或 `"1"`) */
27
44
  retryHintValue: string;
45
+ /** 决策时是否已写过首字节(决策的输入状态) */
28
46
  firstChunkCommitted: boolean;
47
+ /** 决策时已写入客户端的字节数 */
29
48
  bytesFlushed: number;
30
49
  }
31
50
 
51
+ /**
52
+ * buyer 端 SSE 流失败决策器。
53
+ * 跟踪"首字节是否已写客户端"和"累计写入字节数",在 upstream 中断时决定是 abort+retry 还是切 seller 重试。
54
+ */
32
55
  export class StreamFailover {
33
56
  private readonly retryHintHeader: string;
34
57
  private readonly now: () => number;
package/src/tb-proxyd.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { TokenbuddyDaemon } from "./daemon.js";
2
2
  import { createModuleLogger } from "@tokenbuddy/logging";
3
3
  import { resolveBuyerStorePath } from "./buyer-store.js";
4
+ import { normalizeSellerRoutingConfig, parseSellerRoutingEnv } from "./seller-routing-config.js";
4
5
 
5
6
  const logger = createModuleLogger("tb-proxyd");
6
7
 
@@ -8,16 +9,15 @@ const dbPath = resolveBuyerStorePath();
8
9
  const controlPort = parsePortEnv("TB_PROXYD_CONTROL_PORT", 17820);
9
10
  const proxyPort = parsePortEnv("TB_PROXYD_PROXY_PORT", 17821);
10
11
  const sellerRegistryUrl = process.env.TB_PROXYD_SELLER_REGISTRY_URL || "https://tb-wallet-bootstrap.fly.dev/registry/sellers";
11
- const selectionMode = parseSelectionModeEnv();
12
- const selectedSellerId = parseSelectedSellerIdEnv();
12
+ const sellerRoutingEnv = parseSellerRoutingEnv();
13
+ const sellerRouting = sellerRoutingEnv ? normalizeSellerRoutingConfig(sellerRoutingEnv) : undefined;
13
14
 
14
15
  const daemon = new TokenbuddyDaemon({
15
16
  controlPort,
16
17
  proxyPort,
17
18
  dbPath,
18
19
  sellerRegistryUrl,
19
- selectionMode,
20
- selectedSellerId
20
+ ...(sellerRouting ? { sellerRouting } : {})
21
21
  });
22
22
 
23
23
  logger.info("proxy.process.initializing", "tb-proxyd process initializing", {
@@ -25,8 +25,9 @@ logger.info("proxy.process.initializing", "tb-proxyd process initializing", {
25
25
  controlPort,
26
26
  proxyPort,
27
27
  sellerRegistryUrl,
28
- selectionMode,
29
- selectedSellerId
28
+ sellerRoutingEnvOverride: Boolean(sellerRouting),
29
+ sellerRoutingMode: sellerRouting?.mode,
30
+ sellerRoutingScorer: sellerRouting?.scorer
30
31
  });
31
32
  daemon.start();
32
33
 
@@ -53,20 +54,3 @@ function parsePortEnv(name: string, fallback: number): number {
53
54
  }
54
55
  return port;
55
56
  }
56
-
57
- function parseSelectionModeEnv(): "auto" | "manual" {
58
- const rawValue = process.env.TB_PROXYD_SELECTION_MODE || "auto";
59
- if (rawValue === "auto" || rawValue === "manual") {
60
- return rawValue;
61
- }
62
- throw new Error("TB_PROXYD_SELECTION_MODE must be auto or manual");
63
- }
64
-
65
- function parseSelectedSellerIdEnv(): string | undefined {
66
- const rawValue = process.env.TB_PROXYD_SELECTED_SELLER_ID;
67
- if (!rawValue) {
68
- return undefined;
69
- }
70
- const trimmed = rawValue.trim();
71
- return trimmed || undefined;
72
- }
@@ -2,23 +2,44 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import * as os from "os";
4
4
 
5
+ /**
6
+ * `detectTerminals()` 的输出单元:描述一个可被 rewrite 的 coding terminal。
7
+ * `detected=false` 时仍会返回,UI 用其渲染"未安装"标签而不是直接跳过。
8
+ */
5
9
  export interface TerminalCandidate {
10
+ /** terminal 内部 ID,例:`claude-code` / `claude-desktop` / `openclaw` / `hermes` */
6
11
  id: string;
12
+ /** 人类可读名称,例:`Claude Code CLI` */
7
13
  name: string;
14
+ /** 本机是否检测到该 terminal 的配置文件 */
8
15
  detected: boolean;
16
+ /** 配置文件绝对路径(即使未检测到也会拼出预期路径) */
9
17
  configPath: string;
18
+ /** 检测结果的说明,用于 UI 展示 */
10
19
  reason: string;
11
20
  }
12
21
 
13
22
  const PLACEHOLDER_API_KEY = "TOKENBUDDY_PROXY";
14
23
  const DESKTOP_PROFILE_ID = "00000000-0000-4000-8000-000000178210";
15
24
 
25
+ /**
26
+ * 获取当前用户的 home 目录,作为拼装 terminal 配置路径的基准。
27
+ *
28
+ * @returns `os.homedir()` 返回的 home 路径
29
+ */
16
30
  export function getHomeDir(): string {
17
31
  return os.homedir();
18
32
  }
19
33
 
20
34
  /**
21
35
  * Detect which coding terminals are installed on the local system.
36
+ *
37
+ * 通过 `~/.claude/settings.json`、`Library/Application Support/Claude/...`、
38
+ * `~/.openclaw/config.json`、`~/.hermes/settings.json` 等约定路径判断。
39
+ * Claude Desktop 在非 macOS 平台上 `configPath` 可能为空字符串,
40
+ * 调用方需以 `detected` 字段为准。
41
+ *
42
+ * @returns terminal 候选列表(固定四个 ID 顺序)
22
43
  */
23
44
  export function detectTerminals(): TerminalCandidate[] {
24
45
  const home = getHomeDir();
@@ -79,6 +100,14 @@ export function detectTerminals(): TerminalCandidate[] {
79
100
 
80
101
  /**
81
102
  * Safely rewrite Claude Code settings to route requests through our proxy.
103
+ *
104
+ * 写入 `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN`(占位 `TOKENBUDDY_PROXY`)/
105
+ * `ANTHROPIC_MODEL` / `ANTHROPIC_DEFAULT_SONNET_MODEL`。失败时仅打印日志,
106
+ * 不会抛出异常;调用方无需 try/catch。
107
+ *
108
+ * @param configPath `~/.claude/settings.json` 的绝对路径
109
+ * @param proxyUrl TokenBuddy proxy 的 base URL
110
+ * @param model Claude Code 默认模型 ID
82
111
  */
83
112
  export function rewriteClaudeCode(configPath: string, proxyUrl: string, model: string): void {
84
113
  try {
@@ -112,6 +141,14 @@ export function rewriteClaudeCode(configPath: string, proxyUrl: string, model: s
112
141
 
113
142
  /**
114
143
  * Safely rewrite Claude Desktop configuration.
144
+ *
145
+ * 写入 `deploymentMode = "3p"` 到 Claude 与 Claude-3p 两个目录,
146
+ * 并在 `configLibrary/<DESKTOP_PROFILE_ID>.json` 写入 TokenBuddy profile。
147
+ * 最后更新 `_meta.json` 把 `appliedId` 指向该 profile。
148
+ *
149
+ * @param configPath Claude Desktop 主配置(`claude_desktop_config.json`)绝对路径
150
+ * @param proxyUrl TokenBuddy proxy 的 base URL
151
+ * @param model Claude Desktop 默认模型 ID
115
152
  */
116
153
  export function rewriteClaudeDesktop(configPath: string, proxyUrl: string, model: string): void {
117
154
  try {
@@ -175,6 +212,13 @@ export function rewriteClaudeDesktop(configPath: string, proxyUrl: string, model
175
212
 
176
213
  /**
177
214
  * Rewrite Openclaw settings.
215
+ *
216
+ * 写入 `api_url` / `api_key`(占位 `TOKENBUDDY_PROXY`)/`model`。
217
+ * 失败仅打印日志,不抛出。
218
+ *
219
+ * @param configPath `~/.openclaw/config.json` 的绝对路径
220
+ * @param proxyUrl TokenBuddy proxy 的 base URL
221
+ * @param model Openclaw 默认模型 ID
178
222
  */
179
223
  export function rewriteOpenclaw(configPath: string, proxyUrl: string, model: string): void {
180
224
  try {
@@ -199,6 +243,13 @@ export function rewriteOpenclaw(configPath: string, proxyUrl: string, model: str
199
243
 
200
244
  /**
201
245
  * Rewrite Hermes settings.
246
+ *
247
+ * 在 `openai` 块下写入 `base_url` / `api_key`(占位 `TOKENBUDDY_PROXY`)/`model`。
248
+ * 失败仅打印日志,不抛出。
249
+ *
250
+ * @param configPath `~/.hermes/settings.json` 的绝对路径
251
+ * @param proxyUrl TokenBuddy proxy 的 base URL
252
+ * @param model Hermes 默认模型 ID
202
253
  */
203
254
  export function rewriteHermes(configPath: string, proxyUrl: string, model: string): void {
204
255
  try {
@@ -4,26 +4,52 @@ import * as path from "path";
4
4
 
5
5
  type TerminalEnv = Readonly<Record<string, string | undefined>>;
6
6
 
7
+ /**
8
+ * 终端图片的展示方式:
9
+ * - `inline-iterm`:iTerm2 / WezTerm 等支持的 OSC 1337 内嵌图片
10
+ * - `inline-kitty`:Kitty 图形协议内嵌图片
11
+ * - `system-open`:调系统 `open` / `xdg-open` / `cmd /c start` 打开图片
12
+ * - `manual`:都不可用时让用户手动打开(通常附 fallbackCommand 提示)
13
+ */
7
14
  export type TerminalImageDisplayMethod =
8
15
  | "inline-iterm"
9
16
  | "inline-kitty"
10
17
  | "system-open"
11
18
  | "manual";
12
19
 
20
+ /**
21
+ * `displayTerminalImage()` 的返回:成功/失败 + 用的方法 + 提示消息。
22
+ * `displayed=false` 时调用方应回退到 `fallbackCommand` 提示用户。
23
+ */
13
24
  export interface TerminalImageDisplayResult {
25
+ /** 实际使用的展示方法 */
14
26
  method: TerminalImageDisplayMethod;
27
+ /** 是否真的把图片写到终端/打开了 */
15
28
  displayed: boolean;
29
+ /** 人类可读结果描述(给 init / doctor 用) */
16
30
  message: string;
31
+ /** 仅在 `displayed=false` 时存在:建议用户手动执行的命令 */
17
32
  fallbackCommand?: string;
18
33
  }
19
34
 
35
+ /**
36
+ * `displayTerminalImage()` / `detectTerminalImageDisplay()` 的可注入依赖。
37
+ * 全部可选,缺省时使用真实 `process.env` / `process.platform` / `process.stdout`。
38
+ */
20
39
  export interface DisplayTerminalImageOptions {
40
+ /** 环境变量(默认 `process.env`) */
21
41
  env?: TerminalEnv;
42
+ /** 平台(默认 `process.platform`) */
22
43
  platform?: NodeJS.Platform;
44
+ /** stdout 是否为 TTY(默认 `process.stdout.isTTY`) */
23
45
  stdoutIsTTY?: boolean;
46
+ /** 写入 escape sequence 的方法(默认 `process.stdout.write`) */
24
47
  write?: (chunk: string) => void;
48
+ /** 调起子进程执行 system-open 命令的方法(默认 `child_process.spawn`) */
25
49
  runCommand?: (command: string, args: string[]) => Promise<void>;
50
+ /** 文件存在性检查(默认 `fs.existsSync`) */
26
51
  fileExists?: (filePath: string) => boolean;
52
+ /** 文件读取方法(默认 `fs.readFileSync`) */
27
53
  readFile?: (filePath: string) => Buffer;
28
54
  }
29
55
 
@@ -105,6 +131,13 @@ function openCommandForPlatform(platform: NodeJS.Platform, filePath: string): {
105
131
  return undefined;
106
132
  }
107
133
 
134
+ /**
135
+ * 仅检测当前终端是否支持某种内嵌图片协议(Kitty / iTerm2)。
136
+ * 用于上层在展示前先决策"要不要走内嵌"而不直接写入任何输出。
137
+ *
138
+ * @param options 可注入依赖(env / platform / stdoutIsTTY)
139
+ * @returns 支持的内嵌协议;不支持时返回 `undefined`
140
+ */
108
141
  export function detectTerminalImageDisplay(
109
142
  options: DisplayTerminalImageOptions = {},
110
143
  ): InlineImageProtocol | undefined {
@@ -114,6 +147,14 @@ export function detectTerminalImageDisplay(
114
147
  );
115
148
  }
116
149
 
150
+ /**
151
+ * 把本地图片文件展示到终端:优先内嵌(Kitty / iTerm2),其次 system-open,最后 manual。
152
+ * 不会抛异常;任何错误都会转成 `TerminalImageDisplayResult { displayed: false }`。
153
+ *
154
+ * @param filePath 本地图片绝对路径
155
+ * @param options 可注入依赖
156
+ * @returns 展示结果(含 method / displayed / message / fallbackCommand)
157
+ */
117
158
  export async function displayTerminalImage(
118
159
  filePath: string,
119
160
  options: DisplayTerminalImageOptions = {},