@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.
- 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
package/src/seller-catalog.ts
CHANGED
|
@@ -2,13 +2,26 @@ import { createModuleLogger } from "@tokenbuddy/logging";
|
|
|
2
2
|
|
|
3
3
|
const logger = createModuleLogger("tb-proxyd");
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* buyer 端协议偏好(用于按协议过滤 catalog)。`messages` 是 anthropic 协议的简称。
|
|
7
|
+
*/
|
|
5
8
|
export type ProtocolPreference = "chat_completions" | "responses" | "messages";
|
|
6
9
|
|
|
10
|
+
/**
|
|
11
|
+
* wallet-bootstrap `/registry/sellers` 里的 seller 描述。
|
|
12
|
+
* v1.2 起 `models` 是 buyer model-index 路由的权威依据;缺省会被报告为
|
|
13
|
+
* `models_refresh.seller_missing_models` 事件。
|
|
14
|
+
*/
|
|
7
15
|
export interface RegistrySeller {
|
|
16
|
+
/** seller 全局唯一 ID(也作为 token class) */
|
|
8
17
|
id: string;
|
|
18
|
+
/** 人类可读名称 */
|
|
9
19
|
name?: string;
|
|
20
|
+
/** seller 服务的公网 URL(去掉尾部 `/`) */
|
|
10
21
|
url: string;
|
|
22
|
+
/** seller 支持的协议列表(包含 `anthropic_messages` 时内部 alias 到 `messages`) */
|
|
11
23
|
supportedProtocols?: string[];
|
|
24
|
+
/** seller 支持的支付方式 */
|
|
12
25
|
paymentMethods?: string[];
|
|
13
26
|
/**
|
|
14
27
|
* v1.2: authoritative model list for buyer-side model-index routing.
|
|
@@ -19,78 +32,152 @@ export interface RegistrySeller {
|
|
|
19
32
|
models?: string[];
|
|
20
33
|
}
|
|
21
34
|
|
|
35
|
+
/**
|
|
36
|
+
* `/registry/sellers` 响应的最小形状(去掉了 buyer 不关心的 metadata)。
|
|
37
|
+
*/
|
|
22
38
|
export interface SellerRegistryDocument {
|
|
39
|
+
/** schema 版本号 */
|
|
23
40
|
version: number;
|
|
41
|
+
/** 默认 seller ID(buyer 未指定时回落的目标) */
|
|
24
42
|
defaultSeller?: string;
|
|
43
|
+
/** seller 列表 */
|
|
25
44
|
sellers: RegistrySeller[];
|
|
26
45
|
}
|
|
27
46
|
|
|
47
|
+
/**
|
|
48
|
+
* 单个 seller 的 `/manifest` 响应,兼容 snake_case 与 camelCase 字段。
|
|
49
|
+
*/
|
|
28
50
|
export interface SellerManifest {
|
|
51
|
+
/** manifest schema 版本(camelCase) */
|
|
52
|
+
manifestVersion?: string;
|
|
53
|
+
/** manifest schema 版本(snake_case 兼容) */
|
|
54
|
+
manifest_version?: string;
|
|
55
|
+
/** seller 实例 ID(camelCase) */
|
|
29
56
|
sellerId?: string;
|
|
57
|
+
/** seller 实例 ID(snake_case 兼容) */
|
|
30
58
|
seller_id?: string;
|
|
59
|
+
/** seller 支持的协议(camelCase) */
|
|
31
60
|
supportedProtocols?: string[];
|
|
61
|
+
/** seller 支持的协议(snake_case 兼容) */
|
|
32
62
|
supported_protocols?: string[];
|
|
63
|
+
/** seller 支持的支付方式(camelCase) */
|
|
33
64
|
paymentMethods?: string[];
|
|
65
|
+
/** seller 支持的支付方式(snake_case 兼容) */
|
|
34
66
|
payment_methods?: string[];
|
|
67
|
+
/** 模型清单(含价格) */
|
|
35
68
|
models?: ManifestModelRecord[];
|
|
69
|
+
/** 选择策略块(折扣等) */
|
|
36
70
|
selection?: {
|
|
71
|
+
/** 折扣系数(camelCase) */
|
|
37
72
|
discountRatio?: number;
|
|
73
|
+
/** 折扣系数(snake_case 兼容) */
|
|
38
74
|
discount_ratio?: number;
|
|
39
75
|
};
|
|
40
76
|
}
|
|
41
77
|
|
|
78
|
+
/**
|
|
79
|
+
* `/manifest` 响应里单个模型记录(兼容 snake_case)。
|
|
80
|
+
*/
|
|
42
81
|
export interface ManifestModelRecord {
|
|
82
|
+
/** 模型 ID */
|
|
43
83
|
id: string;
|
|
84
|
+
/** 输入价格 USD micros/1M(camelCase) */
|
|
44
85
|
inputPriceMicrosPer1m?: number;
|
|
86
|
+
/** 输出价格 USD micros/1M(camelCase) */
|
|
45
87
|
outputPriceMicrosPer1m?: number;
|
|
88
|
+
/** 输入价格 USD micros/1M(snake_case 兼容) */
|
|
46
89
|
input_price_micros_per_1m?: number;
|
|
90
|
+
/** 输出价格 USD micros/1M(snake_case 兼容) */
|
|
47
91
|
output_price_micros_per_1m?: number;
|
|
92
|
+
/** 其它扩展字段(保留以便透传) */
|
|
48
93
|
[key: string]: unknown;
|
|
49
94
|
}
|
|
50
95
|
|
|
96
|
+
/**
|
|
97
|
+
* 聚合后的模型目录条目(catalog 渲染 / provider install 选择的最小可消费形状)。
|
|
98
|
+
*/
|
|
51
99
|
export interface ModelCatalogEntry {
|
|
100
|
+
/** 模型 ID */
|
|
52
101
|
id: string;
|
|
102
|
+
/** 所属 seller ID */
|
|
53
103
|
sellerId: string;
|
|
104
|
+
/** seller 名称(可选) */
|
|
54
105
|
sellerName?: string;
|
|
106
|
+
/** seller 公网 URL(去尾 `/`) */
|
|
55
107
|
sellerUrl: string;
|
|
108
|
+
/** seller 支持的协议 */
|
|
56
109
|
supportedProtocols: string[];
|
|
110
|
+
/** seller 支持的支付方式 */
|
|
57
111
|
paymentMethods: string[];
|
|
112
|
+
/** 输入价格 USD micros/1M */
|
|
58
113
|
inputPriceMicrosPer1m?: number;
|
|
114
|
+
/** 输出价格 USD micros/1M */
|
|
59
115
|
outputPriceMicrosPer1m?: number;
|
|
60
116
|
}
|
|
61
117
|
|
|
118
|
+
/**
|
|
119
|
+
* seller 目录条目(聚合 seller 元信息 + manifest 拉取结果)。
|
|
120
|
+
* 用于 `tb doctor` 和 CLI 表格展示。
|
|
121
|
+
*/
|
|
62
122
|
export interface SellerCatalogEntry {
|
|
123
|
+
/** seller ID */
|
|
63
124
|
id: string;
|
|
125
|
+
/** 人类可读名称 */
|
|
64
126
|
name?: string;
|
|
127
|
+
/** seller 公网 URL(去尾 `/`) */
|
|
65
128
|
url: string;
|
|
129
|
+
/** 当前状态(`active` / `error` / `manifest_unavailable`) */
|
|
66
130
|
status: string;
|
|
131
|
+
/** manifest 报告的 sellerId(可能与 id 不同,跨 namespace 时有用) */
|
|
67
132
|
manifestSellerId?: string;
|
|
133
|
+
/** 折扣系数(来自 manifest.selection) */
|
|
68
134
|
discountRatio?: number;
|
|
135
|
+
/** 模型数(来自 manifest) */
|
|
69
136
|
modelCount?: number;
|
|
137
|
+
/** seller 支持的协议(manifest > registry fallback) */
|
|
70
138
|
supportedProtocols?: string[];
|
|
139
|
+
/** seller 支持的支付方式(manifest > registry fallback) */
|
|
71
140
|
paymentMethods?: string[];
|
|
141
|
+
/** manifest 拉取失败时的错误消息 */
|
|
72
142
|
errorMessage?: string;
|
|
73
143
|
}
|
|
74
144
|
|
|
145
|
+
/**
|
|
146
|
+
* `discoverSellerBackedModels` 的返回结果。
|
|
147
|
+
* 包含聚合后的 `models[]` 和 `sellers[]`,供 provider install / catalog 渲染消费。
|
|
148
|
+
*/
|
|
75
149
|
export interface SellerCatalogResult {
|
|
150
|
+
/** 拉取的 registry URL(去尾 `/`) */
|
|
76
151
|
registryUrl: string;
|
|
152
|
+
/** registry schema 版本 */
|
|
77
153
|
version: number;
|
|
154
|
+
/** 默认 seller ID(来自 registry) */
|
|
78
155
|
defaultSeller?: string;
|
|
156
|
+
/** 模型目录条目列表(去重后) */
|
|
79
157
|
models: ModelCatalogEntry[];
|
|
158
|
+
/** seller 元信息列表(拉取 manifest 后的快照) */
|
|
80
159
|
sellers: SellerCatalogEntry[];
|
|
81
160
|
}
|
|
82
161
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
162
|
+
/**
|
|
163
|
+
* 去掉 seller URL 尾部的 `/`,避免路径拼接时出现 `//v1/messages`。
|
|
164
|
+
*
|
|
165
|
+
* @param seller registry seller
|
|
166
|
+
* @returns 规范化后的 URL
|
|
167
|
+
*/
|
|
90
168
|
export function normalizeSellerUrl(seller: RegistrySeller): string {
|
|
91
169
|
return seller.url.replace(/\/+$/, "");
|
|
92
170
|
}
|
|
93
171
|
|
|
172
|
+
/**
|
|
173
|
+
* 解析 seller 实际支持的协议列表。
|
|
174
|
+
* 优先级:manifest.camelCase > manifest.snake_case > registry fallback。
|
|
175
|
+
* 包含 `anthropic_messages` 时内部 alias 到 `messages`(更短、内部统一用简称)。
|
|
176
|
+
*
|
|
177
|
+
* @param manifest seller 的 `/manifest` 响应
|
|
178
|
+
* @param seller 兜底用的 registry seller
|
|
179
|
+
* @returns 归一化后的协议列表
|
|
180
|
+
*/
|
|
94
181
|
export function manifestProtocols(manifest: SellerManifest, seller: RegistrySeller): string[] {
|
|
95
182
|
const protocols = manifest.supportedProtocols || manifest.supported_protocols || seller.supportedProtocols || [];
|
|
96
183
|
return protocols.includes("anthropic_messages") && !protocols.includes("messages")
|
|
@@ -98,10 +185,24 @@ export function manifestProtocols(manifest: SellerManifest, seller: RegistrySell
|
|
|
98
185
|
: protocols;
|
|
99
186
|
}
|
|
100
187
|
|
|
188
|
+
/**
|
|
189
|
+
* 解析 seller 支持的支付方式。
|
|
190
|
+
* 优先级:manifest.camelCase > manifest.snake_case > registry fallback。
|
|
191
|
+
*
|
|
192
|
+
* @param manifest seller 的 `/manifest` 响应
|
|
193
|
+
* @param seller 兜底用的 registry seller
|
|
194
|
+
* @returns 支付方式列表
|
|
195
|
+
*/
|
|
101
196
|
export function manifestPaymentMethods(manifest: SellerManifest, seller: RegistrySeller): string[] {
|
|
102
197
|
return manifest.paymentMethods || manifest.payment_methods || seller.paymentMethods || [];
|
|
103
198
|
}
|
|
104
199
|
|
|
200
|
+
/**
|
|
201
|
+
* 从 manifest 抽取所有模型 ID,过滤非字符串与空串、trim 空白。
|
|
202
|
+
*
|
|
203
|
+
* @param manifest seller 的 `/manifest` 响应
|
|
204
|
+
* @returns 清理后的模型 ID 列表(保序)
|
|
205
|
+
*/
|
|
105
206
|
export function manifestModelIds(manifest: SellerManifest): string[] {
|
|
106
207
|
return (manifest.models || [])
|
|
107
208
|
.map((model) => model.id)
|
|
@@ -119,11 +220,8 @@ function manifestModels(manifest: SellerManifest): ManifestModelRecord[] {
|
|
|
119
220
|
}
|
|
120
221
|
|
|
121
222
|
/**
|
|
122
|
-
* v1.2 §18.9
|
|
123
|
-
*
|
|
124
|
-
* The daemon catches this in `TokenbuddyDaemon.fetchRegistry` and falls
|
|
125
|
-
* back to the last-known snapshot so the buyer stays routable while the
|
|
126
|
-
* operator shrinks the registry below the 1MB cap.
|
|
223
|
+
* v1.2 §18.9:bootstrap 的 `/registry/sellers` 返回 413 + `X-TokenBuddy-Registry-Too-Large: 1` 时抛出的错误。
|
|
224
|
+
* daemon 在 `TokenbuddyDaemon.fetchRegistry` 捕获并回退到上次成功快照,保证 buyer 仍可路由。
|
|
127
225
|
*/
|
|
128
226
|
export class RegistryTooLargeError extends Error {
|
|
129
227
|
readonly status: number;
|
|
@@ -140,6 +238,16 @@ export class RegistryTooLargeError extends Error {
|
|
|
140
238
|
}
|
|
141
239
|
}
|
|
142
240
|
|
|
241
|
+
/**
|
|
242
|
+
* 拉取 `/registry/sellers` 响应。
|
|
243
|
+
* - HTTP 413:抛 `RegistryTooLargeError`(带 `sizeBytes` / `sellerCount` / `maxBytes` 元信息),让 daemon 决定是否回退快照。
|
|
244
|
+
* - 其它非 2xx:抛通用 Error。
|
|
245
|
+
* - 2xx 但缺 `sellers` 数组:抛 Error。
|
|
246
|
+
*
|
|
247
|
+
* @param registryUrl wallet-bootstrap 的 `/registry/sellers` 完整 URL
|
|
248
|
+
* @returns 解析后的注册表文档
|
|
249
|
+
* @throws RegistryTooLargeError / Error
|
|
250
|
+
*/
|
|
143
251
|
export async function fetchSellerRegistry(registryUrl: string): Promise<SellerRegistryDocument> {
|
|
144
252
|
const response = await fetch(registryUrl);
|
|
145
253
|
if (response.status === 413) {
|
|
@@ -170,6 +278,13 @@ export async function fetchSellerRegistry(registryUrl: string): Promise<SellerRe
|
|
|
170
278
|
return data;
|
|
171
279
|
}
|
|
172
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 拉取单个 seller 的 `/manifest` 响应。
|
|
283
|
+
*
|
|
284
|
+
* @param seller registry seller
|
|
285
|
+
* @returns 解析后的 manifest
|
|
286
|
+
* @throws Error 非 2xx
|
|
287
|
+
*/
|
|
173
288
|
export async function fetchSellerManifest(seller: RegistrySeller): Promise<SellerManifest> {
|
|
174
289
|
const response = await fetch(`${normalizeSellerUrl(seller)}/manifest`);
|
|
175
290
|
if (!response.ok) {
|
|
@@ -178,6 +293,16 @@ export async function fetchSellerManifest(seller: RegistrySeller): Promise<Selle
|
|
|
178
293
|
return await response.json() as SellerManifest;
|
|
179
294
|
}
|
|
180
295
|
|
|
296
|
+
/**
|
|
297
|
+
* 端到端发现 seller-backed 模型目录:
|
|
298
|
+
* 1. 拉取 `/registry/sellers`。
|
|
299
|
+
* 2. 并发拉取每个 seller 的 `/manifest`(失败转成 `errorMessage` 字段,不阻断整体)。
|
|
300
|
+
* 3. 合并 model 列表、按 `(modelId, sellerId)` 去重。
|
|
301
|
+
* 4. 输出 `SellerCatalogResult`(含聚合的 `models[]` 与 `sellers[]`)。
|
|
302
|
+
*
|
|
303
|
+
* @param registryUrl wallet-bootstrap 注册表 URL
|
|
304
|
+
* @returns 聚合后的目录
|
|
305
|
+
*/
|
|
181
306
|
export async function discoverSellerBackedModels(registryUrl: string): Promise<SellerCatalogResult> {
|
|
182
307
|
const registry = await fetchSellerRegistry(registryUrl);
|
|
183
308
|
const sellerResults = await Promise.all(registry.sellers.map(async (seller) => {
|
|
@@ -237,6 +362,13 @@ export async function discoverSellerBackedModels(registryUrl: string): Promise<S
|
|
|
237
362
|
};
|
|
238
363
|
}
|
|
239
364
|
|
|
365
|
+
/**
|
|
366
|
+
* 按协议过滤 catalog 条目。
|
|
367
|
+
*
|
|
368
|
+
* @param models 目录条目
|
|
369
|
+
* @param protocol 目标协议
|
|
370
|
+
* @returns 仅包含该协议的条目
|
|
371
|
+
*/
|
|
240
372
|
export function filterCatalogByProtocol(
|
|
241
373
|
models: ModelCatalogEntry[],
|
|
242
374
|
protocol: ProtocolPreference
|
|
@@ -244,6 +376,14 @@ export function filterCatalogByProtocol(
|
|
|
244
376
|
return models.filter((entry) => entry.supportedProtocols.includes(protocol));
|
|
245
377
|
}
|
|
246
378
|
|
|
379
|
+
/**
|
|
380
|
+
* 按 seller ID 过滤 catalog 条目。
|
|
381
|
+
* `sellerId` 缺省时返回原列表。
|
|
382
|
+
*
|
|
383
|
+
* @param models 目录条目
|
|
384
|
+
* @param sellerId 目标 seller ID
|
|
385
|
+
* @returns 仅包含该 seller 的条目
|
|
386
|
+
*/
|
|
247
387
|
export function filterCatalogBySeller(
|
|
248
388
|
models: ModelCatalogEntry[],
|
|
249
389
|
sellerId: string | undefined
|
|
@@ -254,6 +394,12 @@ export function filterCatalogBySeller(
|
|
|
254
394
|
return models.filter((entry) => entry.sellerId === sellerId);
|
|
255
395
|
}
|
|
256
396
|
|
|
397
|
+
/**
|
|
398
|
+
* 按 `(sellerId, modelId)` 去重 catalog 条目,保留首次出现的顺序。
|
|
399
|
+
*
|
|
400
|
+
* @param models 目录条目
|
|
401
|
+
* @returns 去重后的条目
|
|
402
|
+
*/
|
|
257
403
|
export function dedupeCatalogEntries(models: ModelCatalogEntry[]): ModelCatalogEntry[] {
|
|
258
404
|
const seen = new Set<string>();
|
|
259
405
|
const output: ModelCatalogEntry[] = [];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { RegistrySeller } from "./seller-catalog.js";
|
|
2
|
+
import { fetchSellerManifest } from "./seller-catalog.js";
|
|
3
|
+
import type { SellerRouteMetadata } from "./seller-route-planner.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `SellerMetadataCache` 构造选项。
|
|
7
|
+
*/
|
|
8
|
+
export interface SellerMetadataCacheOptions {
|
|
9
|
+
/** 缓存条目有效期(毫秒),默认 10 分钟 */
|
|
10
|
+
ttlMs?: number;
|
|
11
|
+
/** 时间源,默认 `Date.now`;测试可注入受控时间 */
|
|
12
|
+
now?: () => number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_SELLER_METADATA_TTL_MS = 10 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* seller 路由元数据缓存(`/manifest` 拉取结果)。
|
|
19
|
+
* 内部按 seller id 索引、按 TTL 过期;并发刷新合并到同一 `inFlight` Promise,避免重复打 seller。
|
|
20
|
+
*/
|
|
21
|
+
export class SellerMetadataCache {
|
|
22
|
+
private readonly ttlMs: number;
|
|
23
|
+
private readonly now: () => number;
|
|
24
|
+
private readonly entries = new Map<string, SellerRouteMetadata>();
|
|
25
|
+
private readonly inFlight = new Map<string, Promise<void>>();
|
|
26
|
+
|
|
27
|
+
constructor(options: SellerMetadataCacheOptions = {}) {
|
|
28
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_SELLER_METADATA_TTL_MS;
|
|
29
|
+
this.now = options.now ?? Date.now;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
snapshot(): SellerRouteMetadata[] {
|
|
33
|
+
return Array.from(this.entries.values());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
refreshIfStale(sellers: RegistrySeller[]): Promise<void> {
|
|
37
|
+
const refreshes = sellers
|
|
38
|
+
.filter((seller) => this.shouldRefresh(seller.id))
|
|
39
|
+
.map((seller) => this.refreshSeller(seller));
|
|
40
|
+
return Promise.all(refreshes).then(() => undefined);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private shouldRefresh(sellerId: string): boolean {
|
|
44
|
+
const existing = this.entries.get(sellerId);
|
|
45
|
+
if (!existing) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return this.now() - (existing.lastRefreshAt ?? 0) >= this.ttlMs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private refreshSeller(seller: RegistrySeller): Promise<void> {
|
|
52
|
+
const existing = this.inFlight.get(seller.id);
|
|
53
|
+
if (existing) {
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
const refresh = this.fetchAndStore(seller).finally(() => {
|
|
57
|
+
this.inFlight.delete(seller.id);
|
|
58
|
+
});
|
|
59
|
+
this.inFlight.set(seller.id, refresh);
|
|
60
|
+
return refresh;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async fetchAndStore(seller: RegistrySeller): Promise<void> {
|
|
64
|
+
const refreshedAt = this.now();
|
|
65
|
+
try {
|
|
66
|
+
const manifest = await fetchSellerManifest(seller);
|
|
67
|
+
this.entries.set(seller.id, {
|
|
68
|
+
sellerId: seller.id,
|
|
69
|
+
discountRatio: finiteNumber(manifest.selection?.discountRatio ?? manifest.selection?.discount_ratio),
|
|
70
|
+
manifestVersion: stringField(manifest.manifestVersion ?? manifest.manifest_version),
|
|
71
|
+
lastRefreshAt: refreshedAt,
|
|
72
|
+
source: "manifest_selection"
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
this.entries.set(seller.id, {
|
|
76
|
+
sellerId: seller.id,
|
|
77
|
+
lastRefreshAt: refreshedAt,
|
|
78
|
+
source: "manifest_selection",
|
|
79
|
+
errorMessage: err instanceof Error ? err.message : String(err)
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
86
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stringField(value: unknown): string | undefined {
|
|
90
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
91
|
+
}
|
package/src/seller-pool.ts
CHANGED
|
@@ -6,8 +6,18 @@ import type { CreditTracker } from "./credit-tracker.js";
|
|
|
6
6
|
|
|
7
7
|
const logger = createModuleLogger("tb-proxyd:seller-pool");
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* seller 级熔断器状态。
|
|
11
|
+
* - `closed`:正常挑选
|
|
12
|
+
* - `open`:被踢出候选,等待 `openStateMs` 后降级到 `half_open`
|
|
13
|
+
* - `half_open`:放行一次试探,成功则回 `closed`
|
|
14
|
+
*/
|
|
9
15
|
export type CircuitState = "closed" | "half_open" | "open";
|
|
10
16
|
|
|
17
|
+
/**
|
|
18
|
+
* 路由失败归一化后的错误分类。控制器据此决定切流 / 重试 / 计入 wasted。
|
|
19
|
+
* 与 `SellerPool.recordFailure` 的入参对齐。
|
|
20
|
+
*/
|
|
11
21
|
export type FailureKind =
|
|
12
22
|
| "hard_4xx" // 400/404/422 — the seller is wrong for this request
|
|
13
23
|
| "auth_invalid" // 401/403 token invalid
|
|
@@ -17,55 +27,116 @@ export type FailureKind =
|
|
|
17
27
|
| "stream_aborted" // upstream stream broken after first chunk
|
|
18
28
|
| "no_compatible"; // pool had no candidates for the request
|
|
19
29
|
|
|
30
|
+
/**
|
|
31
|
+
* 池里每个 seller 的运行时视图:registry 描述 + 熔断状态 + 健康画像。
|
|
32
|
+
* 由 `SellerPool.sync()` 从 prewarm cache 重建;写路径(recordSuccess/recordFailure)会原地更新。
|
|
33
|
+
*/
|
|
20
34
|
export interface PoolEntry {
|
|
35
|
+
/** seller 全局 ID */
|
|
21
36
|
sellerId: string;
|
|
37
|
+
/** seller URL(去尾部斜杠) */
|
|
22
38
|
url: string;
|
|
39
|
+
/** registry 原始描述(用于 planSellerRouteSet 等下游) */
|
|
23
40
|
registrySeller: RegistrySeller;
|
|
41
|
+
/** 当前熔断状态 */
|
|
24
42
|
circuit: CircuitState;
|
|
43
|
+
/** 连续失败次数(达到 `failureThreshold` 立即 open) */
|
|
25
44
|
consecutiveFailures: number;
|
|
45
|
+
/** 滑动窗口内失败时间戳(毫秒),长度受 `windowMs` 约束 */
|
|
26
46
|
recentFailures: number[]; // timestamps (ms) for sliding window
|
|
47
|
+
/** 最近一次成功的 unix 毫秒时间戳;0 表示尚无成功 */
|
|
27
48
|
lastSuccessAt: number;
|
|
49
|
+
/** 最近一次失败的 unix 毫秒时间戳;0 表示尚无失败 */
|
|
28
50
|
lastFailAt: number;
|
|
51
|
+
/** 最近一次被 probe 的 unix 毫秒时间戳(即 cache.warmedAt) */
|
|
29
52
|
lastProbeAt: number;
|
|
30
53
|
// Source-of-truth prewarm state; the pool keeps a copy so the hot path
|
|
31
54
|
// can answer health questions without touching the cache map on every
|
|
32
55
|
// request.
|
|
56
|
+
/** 综合健康分 0-100,热路径排序的主键 */
|
|
33
57
|
healthScore: number;
|
|
58
|
+
/** 平均延迟(毫秒) */
|
|
34
59
|
avgLatencyMs: number;
|
|
60
|
+
/** health probe 延迟(毫秒),可选 */
|
|
61
|
+
healthProbeLatencyMs?: number;
|
|
62
|
+
/** TTFT(毫秒),可选 */
|
|
63
|
+
ttftMs?: number;
|
|
64
|
+
/** 平均推理延迟(毫秒),可选 */
|
|
65
|
+
avgInferenceMs?: number;
|
|
66
|
+
/** 上游状态,可选 */
|
|
67
|
+
upstreamStatus?: "healthy" | "degraded" | "unhealthy" | "unknown";
|
|
68
|
+
/** 上游错误类名,可选 */
|
|
69
|
+
upstreamErrorClass?: string;
|
|
35
70
|
}
|
|
36
71
|
|
|
72
|
+
/**
|
|
73
|
+
* `SellerPool.pick()` 的入参:标识一次路由请求 + 可选的时间/数量约束。
|
|
74
|
+
*/
|
|
37
75
|
export interface PickOptions {
|
|
76
|
+
/** 目标模型 ID(已归一化或未归一化都可,pool 内部会归一化) */
|
|
38
77
|
modelId: string;
|
|
78
|
+
/** 目标协议 */
|
|
39
79
|
protocol: string;
|
|
80
|
+
/** 目标支付方式 */
|
|
40
81
|
paymentMethod: string;
|
|
82
|
+
/** 最多返回几个候选,默认 4 */
|
|
41
83
|
limit?: number;
|
|
84
|
+
/** 覆盖时钟(测试用) */
|
|
42
85
|
now?: number;
|
|
43
86
|
}
|
|
44
87
|
|
|
88
|
+
/**
|
|
89
|
+
* `SellerPool.pick()` 的返回:候选列表 + 决策原因 + 底层 model index 解析结果。
|
|
90
|
+
* `reason` 用于日志和 doctor 区分"无缓存""有缓存但全 open""正常"等情况。
|
|
91
|
+
*/
|
|
45
92
|
export interface PickResult {
|
|
93
|
+
/** 已按 healthScore 排序的候选(不含 open 电路的 seller) */
|
|
46
94
|
candidates: Array<{ entry: PoolEntry; registrySeller: RegistrySeller }>;
|
|
95
|
+
/** 决策原因,例:`prewarm_cache`、`prewarm_cache_empty`、`no_prewarm_candidates` */
|
|
47
96
|
reason: string;
|
|
97
|
+
/** 底层 `ModelIndex.resolve()` 的结果(保留给上游做诊断) */
|
|
48
98
|
resolved: ModelIndexResolution;
|
|
49
99
|
}
|
|
50
100
|
|
|
101
|
+
/**
|
|
102
|
+
* `SellerPool` 视角的 model-index 解析快照,与 `model-index.ts` 里的同名类型语义一致;
|
|
103
|
+
* 在 pick 路径上做轻量拷贝,避免循环依赖和暴露 `sellers` vs `candidates` 命名差异。
|
|
104
|
+
*/
|
|
51
105
|
export interface ModelIndexResolution {
|
|
106
|
+
/** 解析时使用的模型 ID(可能未归一化) */
|
|
52
107
|
modelId: string;
|
|
108
|
+
/** 索引里是否至少有一个 seller 命中 */
|
|
53
109
|
matched: boolean;
|
|
110
|
+
/** 命中的 seller 列表(按 default + 声明顺序) */
|
|
54
111
|
candidates: RegistrySeller[];
|
|
112
|
+
/** 当前索引里 `models` 字段缺失的 seller 数(诊断用) */
|
|
55
113
|
missingModelsFlag: number;
|
|
56
114
|
}
|
|
57
115
|
|
|
116
|
+
/**
|
|
117
|
+
* 构造 `SellerPool` 所需的依赖与可调参数。默认值见 `DEFAULTS`:
|
|
118
|
+
* 失败 3 次 open、滑动窗口 60s、失败率阈值 0.5、open 态 30s。
|
|
119
|
+
*/
|
|
58
120
|
export interface SellerPoolOptions {
|
|
121
|
+
/** 共享的 model index */
|
|
59
122
|
modelIndex: ModelIndex;
|
|
123
|
+
/** 共享的 prewarm cache(pool 的真相源) */
|
|
60
124
|
cache: PrewarmCache;
|
|
125
|
+
/** 共享的 credit tracker,wasted / auto-purchase 决策都依赖 */
|
|
61
126
|
creditTracker: CreditTracker;
|
|
62
127
|
// Circuit breaker thresholds (v1.2 §13).
|
|
128
|
+
/** 连续失败次数阈值,到达后立即 open,默认 3 */
|
|
63
129
|
failureThreshold?: number; // default 3
|
|
130
|
+
/** 滑动窗口长度(毫秒),默认 60000 */
|
|
64
131
|
windowMs?: number; // default 60_000 (1m sliding window)
|
|
132
|
+
/** 滑动窗口内失败率阈值(次/秒),默认 0.5 */
|
|
65
133
|
windowFailureRate?: number; // default 0.5
|
|
134
|
+
/** open 态保持时间(毫秒),过期后降级 half_open,默认 30000 */
|
|
66
135
|
openStateMs?: number; // default 30_000
|
|
136
|
+
/** 注入时钟(测试用),默认 `Date.now` */
|
|
67
137
|
now?: () => number;
|
|
68
138
|
// PoolEntry -> CircuitState transition hooks for tests.
|
|
139
|
+
/** 测试钩子:在 sync 后对 entry 列表做额外处理 */
|
|
69
140
|
applyRegistry?: (entries: PoolEntry[], registry: RegistrySeller[]) => PoolEntry[];
|
|
70
141
|
}
|
|
71
142
|
|
|
@@ -132,7 +203,12 @@ export class SellerPool {
|
|
|
132
203
|
lastFailAt: candidate.lastFailAt || previous?.lastFailAt || 0,
|
|
133
204
|
lastProbeAt: entry.warmedAt,
|
|
134
205
|
healthScore: candidate.healthScore,
|
|
135
|
-
avgLatencyMs: candidate.avgLatencyMs
|
|
206
|
+
avgLatencyMs: candidate.avgLatencyMs,
|
|
207
|
+
healthProbeLatencyMs: candidate.healthProbeLatencyMs,
|
|
208
|
+
ttftMs: candidate.ttftMs,
|
|
209
|
+
avgInferenceMs: candidate.avgInferenceMs,
|
|
210
|
+
upstreamStatus: candidate.upstreamStatus,
|
|
211
|
+
upstreamErrorClass: candidate.upstreamErrorClass
|
|
136
212
|
});
|
|
137
213
|
}
|
|
138
214
|
}
|