@tokenbuddy/tokenbuddy 1.0.12 → 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.
- package/dist/src/buyer-store.d.ts +61 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +12 -0
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/cli.d.ts +47 -0
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +287 -63
- package/dist/src/cli.js.map +1 -1
- package/dist/src/credit-tracker.d.ts +26 -0
- package/dist/src/credit-tracker.d.ts.map +1 -1
- package/dist/src/credit-tracker.js +8 -0
- package/dist/src/credit-tracker.js.map +1 -1
- package/dist/src/daemon.d.ts +29 -3
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +292 -65
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/doctor-clawtip-wallet.d.ts +25 -0
- package/dist/src/doctor-clawtip-wallet.d.ts.map +1 -1
- package/dist/src/doctor-clawtip-wallet.js +13 -0
- package/dist/src/doctor-clawtip-wallet.js.map +1 -1
- package/dist/src/doctor-diagnostics.d.ts +63 -0
- package/dist/src/doctor-diagnostics.d.ts.map +1 -1
- package/dist/src/doctor-diagnostics.js +39 -1
- package/dist/src/doctor-diagnostics.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/init-clawtip-activation.d.ts +103 -0
- package/dist/src/init-clawtip-activation.d.ts.map +1 -1
- package/dist/src/init-clawtip-activation.js +60 -0
- package/dist/src/init-clawtip-activation.js.map +1 -1
- package/dist/src/init-payment-options.d.ts +124 -0
- package/dist/src/init-payment-options.d.ts.map +1 -1
- package/dist/src/init-payment-options.js +68 -0
- package/dist/src/init-payment-options.js.map +1 -1
- package/dist/src/model-index.d.ts +9 -0
- package/dist/src/model-index.d.ts.map +1 -1
- package/dist/src/model-index.js.map +1 -1
- package/dist/src/prewarm-cache.d.ts +89 -0
- package/dist/src/prewarm-cache.d.ts.map +1 -1
- package/dist/src/prewarm-cache.js +14 -1
- package/dist/src/prewarm-cache.js.map +1 -1
- package/dist/src/prewarm-scheduler.d.ts +62 -3
- package/dist/src/prewarm-scheduler.d.ts.map +1 -1
- package/dist/src/prewarm-scheduler.js +39 -8
- package/dist/src/prewarm-scheduler.js.map +1 -1
- package/dist/src/provider-install.d.ts +89 -3
- package/dist/src/provider-install.d.ts.map +1 -1
- package/dist/src/provider-install.js +77 -19
- package/dist/src/provider-install.js.map +1 -1
- package/dist/src/route-failover.d.ts +48 -0
- package/dist/src/route-failover.d.ts.map +1 -1
- package/dist/src/route-failover.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +158 -10
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +79 -5
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-metadata-cache.d.ts +29 -0
- package/dist/src/seller-metadata-cache.d.ts.map +1 -0
- package/dist/src/seller-metadata-cache.js +71 -0
- package/dist/src/seller-metadata-cache.js.map +1 -0
- package/dist/src/seller-pool.d.ts +71 -0
- package/dist/src/seller-pool.d.ts.map +1 -1
- package/dist/src/seller-pool.js +6 -1
- package/dist/src/seller-pool.js.map +1 -1
- package/dist/src/seller-route-planner.d.ts +118 -0
- package/dist/src/seller-route-planner.d.ts.map +1 -0
- package/dist/src/seller-route-planner.js +160 -0
- package/dist/src/seller-route-planner.js.map +1 -0
- package/dist/src/seller-routing-config.d.ts +69 -0
- package/dist/src/seller-routing-config.d.ts.map +1 -0
- package/dist/src/seller-routing-config.js +164 -0
- package/dist/src/seller-routing-config.js.map +1 -0
- package/dist/src/seller-routing-strategy.d.ts +118 -0
- package/dist/src/seller-routing-strategy.d.ts.map +1 -0
- package/dist/src/seller-routing-strategy.js +183 -0
- package/dist/src/seller-routing-strategy.js.map +1 -0
- package/dist/src/stream-failover.d.ts +23 -0
- package/dist/src/stream-failover.d.ts.map +1 -1
- package/dist/src/stream-failover.js +4 -0
- package/dist/src/stream-failover.js.map +1 -1
- package/dist/src/tb-proxyd.js +7 -21
- package/dist/src/tb-proxyd.js.map +1 -1
- package/dist/src/terminal-detect.d.ts +51 -0
- package/dist/src/terminal-detect.d.ts.map +1 -1
- package/dist/src/terminal-detect.js +42 -0
- package/dist/src/terminal-detect.js.map +1 -1
- package/dist/src/terminal-image.d.ts +41 -0
- package/dist/src/terminal-image.d.ts.map +1 -1
- package/dist/src/terminal-image.js +15 -0
- package/dist/src/terminal-image.js.map +1 -1
- package/package.json +1 -1
- package/src/buyer-store.ts +61 -0
- package/src/cli.ts +330 -68
- package/src/credit-tracker.ts +26 -0
- package/src/daemon.ts +363 -72
- package/src/doctor-clawtip-wallet.ts +25 -0
- package/src/doctor-diagnostics.ts +63 -1
- package/src/index.ts +4 -0
- package/src/init-clawtip-activation.ts +103 -0
- package/src/init-payment-options.ts +124 -0
- package/src/model-index.ts +9 -0
- package/src/prewarm-cache.ts +99 -1
- package/src/prewarm-scheduler.ts +97 -12
- package/src/provider-install.ts +125 -27
- package/src/route-failover.ts +48 -0
- package/src/seller-catalog.ts +158 -12
- package/src/seller-metadata-cache.ts +91 -0
- package/src/seller-pool.ts +77 -1
- package/src/seller-route-planner.ts +323 -0
- package/src/seller-routing-config.ts +198 -0
- package/src/seller-routing-strategy.ts +316 -0
- package/src/stream-failover.ts +23 -0
- package/src/tb-proxyd.ts +7 -23
- package/src/terminal-detect.ts +51 -0
- package/src/terminal-image.ts +41 -0
- package/tests/cli-routing.test.ts +287 -0
- package/tests/daemon-classify.test.ts +431 -0
- package/tests/daemon-roles.test.ts +92 -0
- package/tests/seller-catalog-utilities.test.ts +70 -0
- package/tests/seller-metadata-cache.test.ts +89 -0
- package/tests/seller-route-planner.test.ts +150 -0
- package/tests/seller-routing-config.test.ts +111 -0
- package/tests/seller-routing-strategy.test.ts +166 -0
- package/tests/tokenbuddy.test.ts +446 -34
- /package/{src → tests}/credit-tracker.test.ts +0 -0
- /package/{src → tests}/model-index.test.ts +0 -0
- /package/{src → tests}/prewarm-cache.test.ts +0 -0
- /package/{src → tests}/prewarm-scheduler.test.ts +0 -0
- /package/{src → tests}/route-failover.test.ts +0 -0
- /package/{src → tests}/seller-catalog-413.test.ts +0 -0
- /package/{src → tests}/seller-pool.test.ts +0 -0
- /package/{src → tests}/stream-failover.test.ts +0 -0
- /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
|
+
}
|
package/src/stream-failover.ts
CHANGED
|
@@ -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
|
|
12
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
}
|
package/src/terminal-detect.ts
CHANGED
|
@@ -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 {
|
package/src/terminal-image.ts
CHANGED
|
@@ -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 = {},
|