@tokenbuddy/tokenbuddy 1.0.14 → 1.0.15
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 +23 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +31 -6
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/clawtip-bootstrap.d.ts +23 -0
- package/dist/src/clawtip-bootstrap.d.ts.map +1 -0
- package/dist/src/clawtip-bootstrap.js +47 -0
- package/dist/src/clawtip-bootstrap.js.map +1 -0
- package/dist/src/cli.d.ts +24 -33
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +157 -58
- package/dist/src/cli.js.map +1 -1
- package/dist/src/daemon.d.ts +79 -1
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +984 -23
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/model-index.d.ts +1 -1
- package/dist/src/model-index.d.ts.map +1 -1
- package/dist/src/model-index.js +4 -0
- package/dist/src/model-index.js.map +1 -1
- package/dist/src/prewarm-cache.d.ts +4 -0
- package/dist/src/prewarm-cache.d.ts.map +1 -1
- package/dist/src/prewarm-cache.js +2 -1
- package/dist/src/prewarm-cache.js.map +1 -1
- package/dist/src/prewarm-scheduler.d.ts +2 -0
- package/dist/src/prewarm-scheduler.d.ts.map +1 -1
- package/dist/src/prewarm-scheduler.js +4 -2
- package/dist/src/prewarm-scheduler.js.map +1 -1
- package/dist/src/route-failover.d.ts.map +1 -1
- package/dist/src/route-failover.js +10 -0
- package/dist/src/route-failover.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +17 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +15 -1
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-pool.d.ts +12 -1
- package/dist/src/seller-pool.d.ts.map +1 -1
- package/dist/src/seller-pool.js +61 -7
- package/dist/src/seller-pool.js.map +1 -1
- package/dist/src/seller-route-planner.d.ts +11 -1
- package/dist/src/seller-route-planner.d.ts.map +1 -1
- package/dist/src/seller-route-planner.js +21 -9
- package/dist/src/seller-route-planner.js.map +1 -1
- package/dist/src/seller-routing-config.d.ts +2 -0
- package/dist/src/seller-routing-config.d.ts.map +1 -1
- package/dist/src/seller-routing-config.js +11 -1
- package/dist/src/seller-routing-config.js.map +1 -1
- package/package.json +1 -1
- package/src/buyer-store.ts +70 -7
- package/src/clawtip-bootstrap.ts +64 -0
- package/src/cli.ts +201 -76
- package/src/daemon.ts +1132 -25
- package/src/model-index.ts +4 -1
- package/src/prewarm-cache.ts +6 -1
- package/src/prewarm-scheduler.ts +6 -2
- package/src/route-failover.ts +11 -0
- package/src/seller-catalog.ts +24 -1
- package/src/seller-pool.ts +69 -7
- package/src/seller-route-planner.ts +33 -11
- package/src/seller-routing-config.ts +14 -1
- package/static/clawtip/recharge.png +0 -0
- package/tests/control-plane-ui-endpoints.test.ts +559 -0
- package/tests/daemon-classify.test.ts +9 -0
- package/tests/model-index.test.ts +14 -0
- package/tests/route-failover.test.ts +16 -0
- package/tests/seller-catalog-utilities.test.ts +54 -0
- package/tests/seller-pool.test.ts +56 -0
- package/tests/seller-route-planner.test.ts +40 -0
- package/tests/seller-routing-config.test.ts +13 -0
- package/tests/tokenbuddy.test.ts +200 -7
package/dist/src/daemon.js
CHANGED
|
@@ -2,20 +2,78 @@ import express from "express";
|
|
|
2
2
|
import * as crypto from "crypto";
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
4
|
import * as fs from "fs";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { ErrorCode } from "@tokenbuddy/contracts";
|
|
5
7
|
import { createModuleLogger } from "@tokenbuddy/logging";
|
|
6
8
|
import { BuyerStore } from "./buyer-store.js";
|
|
9
|
+
import { fetchClawtipBootstrap } from "./clawtip-bootstrap.js";
|
|
10
|
+
import { inspectOpenClawWalletConfig, } from "./init-payment-options.js";
|
|
11
|
+
import { startClawtipWalletBootstrap, waitForClawtipActivationConfirmation, } from "./init-clawtip-activation.js";
|
|
7
12
|
import { applyProviderInstall, detectProviders, previewProviderInstall, rollbackProviderInstall, } from "./provider-install.js";
|
|
8
13
|
import { discoverSellerBackedModels, fetchSellerRegistry, normalizeSellerUrl, RegistryTooLargeError, } from "./seller-catalog.js";
|
|
9
14
|
import { ModelIndex } from "./model-index.js";
|
|
10
|
-
import { PrewarmCache } from "./prewarm-cache.js";
|
|
15
|
+
import { PrewarmCache, prewarmKey } from "./prewarm-cache.js";
|
|
11
16
|
import { CreditTracker } from "./credit-tracker.js";
|
|
12
17
|
import { SellerPool } from "./seller-pool.js";
|
|
13
18
|
import { RouteFailover } from "./route-failover.js";
|
|
14
19
|
import { PrewarmScheduler } from "./prewarm-scheduler.js";
|
|
15
20
|
import { planSellerRouteSet } from "./seller-route-planner.js";
|
|
16
|
-
import { mergeSellerRoutingConfig, ROUTING_CONFIG_KEY } from "./seller-routing-config.js";
|
|
21
|
+
import { assertSellerRoutingConfig, mergeSellerRoutingConfig, normalizeSellerRoutingConfig, parseSellerIdList, ROUTING_CONFIG_KEY } from "./seller-routing-config.js";
|
|
17
22
|
const logger = createModuleLogger("tb-proxyd");
|
|
23
|
+
const FOCUS_SET_CONFIG_KEY = "focus-set";
|
|
18
24
|
const PROXY_JSON_BODY_LIMIT = "10mb";
|
|
25
|
+
const SELLER_CAPACITY_BLOCK_MS = 2_000;
|
|
26
|
+
const CLAWTIP_STATIC_ROUTE = "/static/clawtip";
|
|
27
|
+
const CLAWTIP_RECHARGE_QR_FILE = "recharge.png";
|
|
28
|
+
function clientToolStatusFromProvider(provider) {
|
|
29
|
+
return {
|
|
30
|
+
id: provider.id,
|
|
31
|
+
name: provider.name,
|
|
32
|
+
status: provider.status,
|
|
33
|
+
detected: provider.detected,
|
|
34
|
+
configured: provider.configured,
|
|
35
|
+
configPath: provider.configPath,
|
|
36
|
+
commandName: provider.commandName,
|
|
37
|
+
reason: provider.reason,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function buildCustomClientToolStatus(proxyPort) {
|
|
41
|
+
const openaiBaseUrl = `http://127.0.0.1:${proxyPort}/v1`;
|
|
42
|
+
const anthropicBaseUrl = `http://127.0.0.1:${proxyPort}`;
|
|
43
|
+
return {
|
|
44
|
+
id: "custom",
|
|
45
|
+
name: "Custom client",
|
|
46
|
+
status: "manual",
|
|
47
|
+
detected: true,
|
|
48
|
+
configured: false,
|
|
49
|
+
reason: `OpenAI-compatible: ${openaiBaseUrl} · Anthropic-compatible: ${anthropicBaseUrl}`,
|
|
50
|
+
manualConfig: {
|
|
51
|
+
openaiBaseUrl,
|
|
52
|
+
anthropicBaseUrl,
|
|
53
|
+
apiKey: "TOKENBUDDY_PROXY",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 解析 `tb-ui` 构建产物目录(daemon 静态托管 SPA 用)。
|
|
59
|
+
* 优先级:env TB_UI_DIR > require.resolve("tb-ui") > 相对路径猜
|
|
60
|
+
* 找不到时记录 warning 仍允许 daemon 启动(纯 API 模式);静态请求会 404。
|
|
61
|
+
*/
|
|
62
|
+
function resolveUiDir() {
|
|
63
|
+
if (process.env.TB_UI_DIR)
|
|
64
|
+
return process.env.TB_UI_DIR;
|
|
65
|
+
try {
|
|
66
|
+
// require.resolve 在 npm workspaces 装好时能找到 tb-ui/package.json
|
|
67
|
+
const pkgPath = require.resolve("tb-ui/package.json");
|
|
68
|
+
return path.join(path.dirname(pkgPath), "dist");
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// fallback: monorepo 假设 daemon dist 跟 tb-ui/dist 在同 root
|
|
72
|
+
// 用 __filename (jest cjs 编译下可用,生产 ESM 也兼容) 推回 src 再到 root
|
|
73
|
+
const here = typeof __filename !== "undefined" ? path.dirname(__filename) : process.cwd();
|
|
74
|
+
return path.resolve(here, "../../tb-ui/dist");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
19
77
|
function numericHeaderField(value) {
|
|
20
78
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
21
79
|
return value;
|
|
@@ -123,6 +181,55 @@ function summarizeProxyBody(body) {
|
|
|
123
181
|
temperaturePresent: data.temperature !== undefined
|
|
124
182
|
};
|
|
125
183
|
}
|
|
184
|
+
function finiteNumber(value) {
|
|
185
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
189
|
+
const parsed = Number(value);
|
|
190
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
function readErrorCode(bodyText) {
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(bodyText);
|
|
197
|
+
const code = parsed.error?.code ?? parsed.code;
|
|
198
|
+
return typeof code === "string" ? code : undefined;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function isBusyCapacityErrorBody(bodyText) {
|
|
205
|
+
if (!bodyText) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
return readErrorCode(bodyText) === ErrorCode.BusyCapacity;
|
|
209
|
+
}
|
|
210
|
+
function capacityBlockedUntilFromHealth(body, now) {
|
|
211
|
+
const capacity = body.capacity;
|
|
212
|
+
if (!capacity) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
const activeConnections = finiteNumber(capacity.activeConnections ?? capacity.active_connections);
|
|
216
|
+
const maxConnections = finiteNumber(capacity.maxConnections ?? capacity.max_connections);
|
|
217
|
+
const queueDepth = finiteNumber(capacity.queueDepth ?? capacity.queue_depth);
|
|
218
|
+
const maxQueueDepth = finiteNumber(capacity.maxQueueDepth ?? capacity.max_queue_depth);
|
|
219
|
+
if (activeConnections === undefined ||
|
|
220
|
+
maxConnections === undefined ||
|
|
221
|
+
queueDepth === undefined ||
|
|
222
|
+
maxQueueDepth === undefined) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
if (maxConnections <= 0) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
const connectionsFull = activeConnections >= maxConnections;
|
|
229
|
+
const queueUnavailable = maxQueueDepth <= 0;
|
|
230
|
+
const queueFull = queueUnavailable || queueDepth >= maxQueueDepth;
|
|
231
|
+
return connectionsFull && queueFull ? now + SELLER_CAPACITY_BLOCK_MS : undefined;
|
|
232
|
+
}
|
|
126
233
|
function reorderDefaultSellerFirst(sellers, defaultSellerId) {
|
|
127
234
|
if (!defaultSellerId) {
|
|
128
235
|
return sellers;
|
|
@@ -146,6 +253,15 @@ export class TokenbuddyDaemon {
|
|
|
146
253
|
selectionMode;
|
|
147
254
|
selectedSellerId;
|
|
148
255
|
sellerRouting;
|
|
256
|
+
lastRoutingPrewarmKey;
|
|
257
|
+
lazyPrewarmKeys = new Set();
|
|
258
|
+
clawtipActivationWait;
|
|
259
|
+
clawtipActivationWaitCancelToken;
|
|
260
|
+
/**
|
|
261
|
+
* tb-ui v1 控制平面 `PUT /prewarm/focus-set` 写入的 explicit focus set。
|
|
262
|
+
* 优先级最高;`null` 表示回退到 env / historical(与 `resolveFocusSet()` 原行为一致)。
|
|
263
|
+
*/
|
|
264
|
+
currentFocusSet = null;
|
|
149
265
|
activePurchases = new Map();
|
|
150
266
|
// v1.2 fallback pipeline: model-index, prewarm-cache, credit-tracker,
|
|
151
267
|
// pool, and route-failover together replace the v1
|
|
@@ -170,10 +286,17 @@ export class TokenbuddyDaemon {
|
|
|
170
286
|
this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
|
|
171
287
|
const storedRouting = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)
|
|
172
288
|
?.config;
|
|
289
|
+
const storedFocusSet = this.tokenStore.getDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY)
|
|
290
|
+
?.config;
|
|
173
291
|
this.config = config;
|
|
174
292
|
this.sellerRouting = mergeSellerRoutingConfig(storedRouting, config.sellerRouting);
|
|
175
|
-
this.selectionMode = this.sellerRouting
|
|
176
|
-
this.selectedSellerId = this.sellerRouting
|
|
293
|
+
this.selectionMode = selectionModeForRouting(this.sellerRouting);
|
|
294
|
+
this.selectedSellerId = selectedSellerIdForRouting(this.sellerRouting);
|
|
295
|
+
// tb-ui v1: explicit focus set 优先于 env / historical
|
|
296
|
+
if (storedFocusSet && Array.isArray(storedFocusSet.models)) {
|
|
297
|
+
const deduped = Array.from(new Set(storedFocusSet.models.map((m) => m.trim()).filter(Boolean)));
|
|
298
|
+
this.currentFocusSet = deduped.length > 0 ? deduped : null;
|
|
299
|
+
}
|
|
177
300
|
// v1.2 §18.5: scheduler is created here (not in the field initializer)
|
|
178
301
|
// because it needs the config-derived prober + idle interval.
|
|
179
302
|
Object.assign(this, {
|
|
@@ -204,7 +327,22 @@ export class TokenbuddyDaemon {
|
|
|
204
327
|
if (!res.ok) {
|
|
205
328
|
return { ok: false, latencyMs: Date.now() - startedAt, httpStatus: res.status, errorMessage: `health returned ${res.status}` };
|
|
206
329
|
}
|
|
207
|
-
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
const body = await res.json();
|
|
332
|
+
const upstream = body.upstream;
|
|
333
|
+
const upstreamErrorClass = upstream?.lastErrorClass ?? upstream?.last_error_class;
|
|
334
|
+
return {
|
|
335
|
+
ok: true,
|
|
336
|
+
latencyMs: now - startedAt,
|
|
337
|
+
httpStatus: res.status,
|
|
338
|
+
upstreamStatus: typeof upstream?.status === "string"
|
|
339
|
+
? upstream.status
|
|
340
|
+
: undefined,
|
|
341
|
+
upstreamErrorClass: typeof upstreamErrorClass === "string"
|
|
342
|
+
? upstreamErrorClass
|
|
343
|
+
: undefined,
|
|
344
|
+
capacityBlockedUntil: capacityBlockedUntilFromHealth(body, now)
|
|
345
|
+
};
|
|
208
346
|
}
|
|
209
347
|
catch (err) {
|
|
210
348
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -220,6 +358,193 @@ export class TokenbuddyDaemon {
|
|
|
220
358
|
const address = this.proxyServer?.address?.();
|
|
221
359
|
return typeof address === "object" && address ? address.port : this.config.proxyPort;
|
|
222
360
|
}
|
|
361
|
+
clawtipStaticDir() {
|
|
362
|
+
return path.join(path.dirname(this.config.dbPath), "static", "clawtip");
|
|
363
|
+
}
|
|
364
|
+
bundledClawtipStaticDir() {
|
|
365
|
+
if (this.config.clawtipBundledStaticDir === false) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
if (typeof this.config.clawtipBundledStaticDir === "string") {
|
|
369
|
+
return fs.existsSync(this.config.clawtipBundledStaticDir) ? this.config.clawtipBundledStaticDir : undefined;
|
|
370
|
+
}
|
|
371
|
+
const here = typeof __filename !== "undefined" ? path.dirname(__filename) : process.cwd();
|
|
372
|
+
const candidates = [
|
|
373
|
+
path.resolve(here, "../static/clawtip"),
|
|
374
|
+
path.resolve(here, "../../static/clawtip"),
|
|
375
|
+
path.resolve(process.cwd(), "packages/tokenbuddy-cli/static/clawtip")
|
|
376
|
+
];
|
|
377
|
+
return candidates.find((candidate) => fs.existsSync(candidate));
|
|
378
|
+
}
|
|
379
|
+
clawtipPublicUrl(fileName) {
|
|
380
|
+
return `${CLAWTIP_STATIC_ROUTE}/${encodeURIComponent(fileName)}`;
|
|
381
|
+
}
|
|
382
|
+
ensureClawtipStaticAssets() {
|
|
383
|
+
const outputDir = this.clawtipStaticDir();
|
|
384
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
385
|
+
const rechargeOutputPath = path.join(outputDir, CLAWTIP_RECHARGE_QR_FILE);
|
|
386
|
+
if (fs.existsSync(rechargeOutputPath)) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const bundledDir = this.bundledClawtipStaticDir();
|
|
390
|
+
const rechargeSourcePath = bundledDir ? path.join(bundledDir, CLAWTIP_RECHARGE_QR_FILE) : undefined;
|
|
391
|
+
if (rechargeSourcePath && fs.existsSync(rechargeSourcePath)) {
|
|
392
|
+
fs.copyFileSync(rechargeSourcePath, rechargeOutputPath);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
copyClawtipQrToStatic(mediaPath, orderNo) {
|
|
396
|
+
if (!fs.existsSync(mediaPath)) {
|
|
397
|
+
throw new Error(`ClawTip QR image does not exist: ${mediaPath}`);
|
|
398
|
+
}
|
|
399
|
+
const extension = safeQrExtension(mediaPath);
|
|
400
|
+
const fileName = `${safeStaticFileSegment(orderNo)}-${Date.now()}${extension}`;
|
|
401
|
+
const outputDir = this.clawtipStaticDir();
|
|
402
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
403
|
+
const outputPath = path.join(outputDir, fileName);
|
|
404
|
+
fs.copyFileSync(mediaPath, outputPath);
|
|
405
|
+
return {
|
|
406
|
+
fileName,
|
|
407
|
+
path: outputPath,
|
|
408
|
+
url: this.clawtipPublicUrl(fileName)
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
async startClawtipActivationQr() {
|
|
412
|
+
const bootstrapUrl = process.env.TOKENBUDDY_BOOTSTRAP_URL || "https://tb-wallet-bootstrap.fly.dev";
|
|
413
|
+
const fetchBootstrap = this.config.clawtipBootstrapFetcher || fetchClawtipBootstrap;
|
|
414
|
+
const bootstrap = await fetchBootstrap(bootstrapUrl);
|
|
415
|
+
const payment = normalizeClawtipActivationPayment(bootstrap);
|
|
416
|
+
const startBootstrap = this.config.clawtipWalletBootstrapStarter || startClawtipWalletBootstrap;
|
|
417
|
+
const activation = await startBootstrap(payment, { home: this.config.clawtipHomeDir });
|
|
418
|
+
if (!activation.parsedOutput.mediaPath) {
|
|
419
|
+
throw new Error("ClawTip activation did not return a QR image.");
|
|
420
|
+
}
|
|
421
|
+
const staticQr = this.copyClawtipQrToStatic(activation.parsedOutput.mediaPath, payment.orderNo);
|
|
422
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
423
|
+
const existingPayment = this.tokenStore.getPayment("clawtip");
|
|
424
|
+
this.tokenStore.savePayment({
|
|
425
|
+
method: "clawtip",
|
|
426
|
+
enabled: walletConfig.exists,
|
|
427
|
+
isDefault: existingPayment?.isDefault ?? true,
|
|
428
|
+
config: {
|
|
429
|
+
...(existingPayment?.config ?? {}),
|
|
430
|
+
bootstrapUrl,
|
|
431
|
+
orderNo: payment.orderNo,
|
|
432
|
+
amountFen: payment.amountFen,
|
|
433
|
+
indicator: payment.indicator,
|
|
434
|
+
slug: payment.slug,
|
|
435
|
+
skillId: payment.skillId,
|
|
436
|
+
description: payment.description,
|
|
437
|
+
resourceUrl: payment.resourceUrl,
|
|
438
|
+
activationOrderFile: activation.orderFile,
|
|
439
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
440
|
+
walletConfigPresent: walletConfig.exists,
|
|
441
|
+
activationQrImagePath: activation.parsedOutput.mediaPath,
|
|
442
|
+
activationQrImageUrl: staticQr.url,
|
|
443
|
+
authUrl: activation.parsedOutput.authUrl,
|
|
444
|
+
clawtipId: activation.parsedOutput.clawtipId,
|
|
445
|
+
payCredentialWritten: Boolean(activation.payCredential),
|
|
446
|
+
activationCompletedBy: activation.payCredential
|
|
447
|
+
? (walletConfig.exists ? "payCredential+wallet-config" : "payCredential")
|
|
448
|
+
: walletConfig.exists ? "wallet-config" : "pending-wallet-scan"
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
this.scheduleClawtipActivationWait(activation.parsedOutput.clawtipId);
|
|
452
|
+
return {
|
|
453
|
+
ok: true,
|
|
454
|
+
kind: "activate",
|
|
455
|
+
method: "clawtip",
|
|
456
|
+
orderNo: payment.orderNo,
|
|
457
|
+
amountFen: payment.amountFen,
|
|
458
|
+
qrImageUrl: staticQr.url,
|
|
459
|
+
sourceImagePath: activation.parsedOutput.mediaPath,
|
|
460
|
+
staticImagePath: staticQr.path,
|
|
461
|
+
authUrl: activation.parsedOutput.authUrl,
|
|
462
|
+
clawtipId: activation.parsedOutput.clawtipId,
|
|
463
|
+
orderFile: activation.orderFile,
|
|
464
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
465
|
+
walletConfigPresent: walletConfig.exists,
|
|
466
|
+
requiresWalletAuth: activation.parsedOutput.requiresWalletAuth,
|
|
467
|
+
payCredentialWritten: Boolean(activation.payCredential)
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
scheduleClawtipActivationWait(clawtipId) {
|
|
471
|
+
if (this.clawtipActivationWait) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const cancelToken = { cancelled: false };
|
|
475
|
+
this.clawtipActivationWaitCancelToken = cancelToken;
|
|
476
|
+
const waitForActivation = this.config.clawtipActivationWaiter || waitForClawtipActivationConfirmation;
|
|
477
|
+
this.clawtipActivationWait = waitForActivation({
|
|
478
|
+
clawtipId,
|
|
479
|
+
inspectWalletConfig: () => inspectOpenClawWalletConfig(this.config.clawtipHomeDir),
|
|
480
|
+
isCancelled: () => cancelToken.cancelled,
|
|
481
|
+
cancel: () => undefined
|
|
482
|
+
})
|
|
483
|
+
.then((walletRegistered) => {
|
|
484
|
+
if (cancelToken.cancelled) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (!walletRegistered) {
|
|
488
|
+
logger.info("control.payment.clawtip.activation_wait.pending", "ClawTip activation wait ended before wallet registration");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
492
|
+
const payment = this.tokenStore.getPayment("clawtip");
|
|
493
|
+
if (!payment || payment.method !== "clawtip") {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
this.tokenStore.savePayment({
|
|
497
|
+
...payment,
|
|
498
|
+
enabled: walletConfig.exists,
|
|
499
|
+
config: {
|
|
500
|
+
...(payment.config ?? {}),
|
|
501
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
502
|
+
walletConfigPresent: walletConfig.exists,
|
|
503
|
+
activationCompletedBy: walletConfig.exists
|
|
504
|
+
? "wallet-config"
|
|
505
|
+
: readConfigString(payment.config, "activationCompletedBy") ?? "pending-wallet-scan"
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
logger.info("control.payment.clawtip.activation_wait.completed", "ClawTip activation wait completed", {
|
|
509
|
+
walletRegistered,
|
|
510
|
+
walletConfigPresent: walletConfig.exists
|
|
511
|
+
});
|
|
512
|
+
})
|
|
513
|
+
.catch((error) => {
|
|
514
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
515
|
+
logger.warn("control.payment.clawtip.activation_wait.failed", "ClawTip activation wait failed", { errorMessage });
|
|
516
|
+
})
|
|
517
|
+
.finally(() => {
|
|
518
|
+
if (this.clawtipActivationWaitCancelToken === cancelToken) {
|
|
519
|
+
this.clawtipActivationWaitCancelToken = undefined;
|
|
520
|
+
this.clawtipActivationWait = undefined;
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
clawtipRechargeQr() {
|
|
525
|
+
const payment = this.tokenStore.getPayment("clawtip");
|
|
526
|
+
const resourceUrl = readConfigString(payment?.config, "resourceUrl");
|
|
527
|
+
const orderNo = readConfigString(payment?.config, "orderNo") || "clawtip-recharge";
|
|
528
|
+
const mediaPath = path.join(this.clawtipStaticDir(), CLAWTIP_RECHARGE_QR_FILE);
|
|
529
|
+
if (!fs.existsSync(mediaPath)) {
|
|
530
|
+
throw new Error(`ClawTip fixed recharge QR image is missing: ${mediaPath}`);
|
|
531
|
+
}
|
|
532
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
533
|
+
return {
|
|
534
|
+
ok: true,
|
|
535
|
+
kind: "recharge",
|
|
536
|
+
method: "clawtip",
|
|
537
|
+
orderNo,
|
|
538
|
+
qrImageUrl: this.clawtipPublicUrl(CLAWTIP_RECHARGE_QR_FILE),
|
|
539
|
+
sourceImagePath: mediaPath,
|
|
540
|
+
staticImagePath: mediaPath,
|
|
541
|
+
resourceUrl,
|
|
542
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
543
|
+
walletConfigPresent: walletConfig.exists,
|
|
544
|
+
requiresWalletAuth: false,
|
|
545
|
+
payCredentialWritten: readConfigBoolean(payment?.config, "payCredentialWritten")
|
|
546
|
+
};
|
|
547
|
+
}
|
|
223
548
|
// v1.2 §18.9: stale-cache fallback. The buyer remembers the last
|
|
224
549
|
// successfully fetched registry document and reuses it when the
|
|
225
550
|
// bootstrap returns 413 (`X-TokenBuddy-Registry-Too-Large: 1`). This
|
|
@@ -259,6 +584,7 @@ export class TokenbuddyDaemon {
|
|
|
259
584
|
}
|
|
260
585
|
}
|
|
261
586
|
runtimeSummary() {
|
|
587
|
+
this.refreshSellerRoutingConfig();
|
|
262
588
|
return {
|
|
263
589
|
status: "running",
|
|
264
590
|
pid: process.pid,
|
|
@@ -363,11 +689,13 @@ export class TokenbuddyDaemon {
|
|
|
363
689
|
// v1.2: registry is the source of truth for routing. We rebuild the
|
|
364
690
|
// model-index once per request (cheap; index lookup is in-memory) so
|
|
365
691
|
// the response always reflects the latest seller list. The previous
|
|
366
|
-
// "fetchSellerManifest per
|
|
692
|
+
// "fetchSellerManifest per request" path is removed in favor of
|
|
367
693
|
// pulling `models` directly off the registry entries.
|
|
368
694
|
const registry = await this.fetchRegistry();
|
|
369
|
-
const routing = this.
|
|
695
|
+
const routing = resolveSellerRoutingForModel(this.refreshSellerRoutingConfig(), modelId);
|
|
370
696
|
const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
|
|
697
|
+
this.sellerPool.ensureRegistrySellers(registrySellers);
|
|
698
|
+
this.scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod);
|
|
371
699
|
const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
|
|
372
700
|
const planned = planSellerRouteSet({
|
|
373
701
|
modelId,
|
|
@@ -380,8 +708,12 @@ export class TokenbuddyDaemon {
|
|
|
380
708
|
sellerId: entry.sellerId,
|
|
381
709
|
healthScore: entry.healthScore,
|
|
382
710
|
avgLatencyMs: entry.avgLatencyMs,
|
|
383
|
-
|
|
384
|
-
|
|
711
|
+
ttftMs: entry.ttftMs,
|
|
712
|
+
avgInferenceMs: entry.avgInferenceMs,
|
|
713
|
+
circuit: entry.circuit,
|
|
714
|
+
capacityBlockedUntil: entry.capacityBlockedUntil
|
|
715
|
+
})),
|
|
716
|
+
now: Date.now()
|
|
385
717
|
});
|
|
386
718
|
if (planned.routes.length === 0) {
|
|
387
719
|
throw new Error(`no compatible seller for ${endpoint} model ${modelId}`);
|
|
@@ -408,7 +740,109 @@ export class TokenbuddyDaemon {
|
|
|
408
740
|
sellerCount: routes.length,
|
|
409
741
|
sellers: routes.map((route) => route.seller.id)
|
|
410
742
|
});
|
|
411
|
-
return routes;
|
|
743
|
+
return { routes, plan: planned, paymentMethod };
|
|
744
|
+
}
|
|
745
|
+
refreshSellerRoutingConfig() {
|
|
746
|
+
const storedRouting = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)
|
|
747
|
+
?.config;
|
|
748
|
+
const nextRouting = mergeSellerRoutingConfig(storedRouting, this.config.sellerRouting);
|
|
749
|
+
if (!sameSellerRouting(this.sellerRouting, nextRouting)) {
|
|
750
|
+
const previous = this.sellerRouting;
|
|
751
|
+
this.sellerRouting = nextRouting;
|
|
752
|
+
this.selectionMode = selectionModeForRouting(nextRouting);
|
|
753
|
+
this.selectedSellerId = selectedSellerIdForRouting(nextRouting);
|
|
754
|
+
logger.info("routing.config.reloaded", "seller routing config reloaded", {
|
|
755
|
+
previousMode: previous.mode,
|
|
756
|
+
previousScorer: previous.scorer,
|
|
757
|
+
sellerRoutingMode: nextRouting.mode,
|
|
758
|
+
sellerRoutingScorer: nextRouting.scorer,
|
|
759
|
+
selectedSellerId: this.selectedSellerId
|
|
760
|
+
});
|
|
761
|
+
void this.runRoutingPrewarmSweep(nextRouting);
|
|
762
|
+
}
|
|
763
|
+
return this.sellerRouting;
|
|
764
|
+
}
|
|
765
|
+
async runRoutingPrewarmSweep(routing) {
|
|
766
|
+
const focusSet = this.resolveFocusSet();
|
|
767
|
+
const routingPrewarmKey = `${routingKey(routing)}\u0001${focusSet.join("\u0001")}`;
|
|
768
|
+
if (focusSet.length === 0) {
|
|
769
|
+
logger.info("prewarm.routing.skipped", "no focus set configured after routing reload; relying on lazy prewarms", {
|
|
770
|
+
sellerRoutingMode: routing.mode,
|
|
771
|
+
sellerRoutingScorer: routing.scorer
|
|
772
|
+
});
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (this.lastRoutingPrewarmKey === routingPrewarmKey) {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
this.lastRoutingPrewarmKey = routingPrewarmKey;
|
|
779
|
+
logger.info("prewarm.routing.scheduled", "routing reload prewarm sweep scheduled", {
|
|
780
|
+
sellerRoutingMode: routing.mode,
|
|
781
|
+
sellerRoutingScorer: routing.scorer,
|
|
782
|
+
focusSetSize: focusSet.length,
|
|
783
|
+
focusSet: focusSet.slice(0, 20)
|
|
784
|
+
});
|
|
785
|
+
try {
|
|
786
|
+
await this.fetchRegistry();
|
|
787
|
+
const paymentMethod = this.defaultPaymentMethod();
|
|
788
|
+
for (const modelId of focusSet) {
|
|
789
|
+
this.schedulePrewarmForModel({
|
|
790
|
+
modelId,
|
|
791
|
+
reason: "explicit",
|
|
792
|
+
protocol: this.resolvePrewarmProtocol(modelId, paymentMethod),
|
|
793
|
+
paymentMethod
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
catch (err) {
|
|
798
|
+
logger.warn("prewarm.routing.failed", "routing reload prewarm sweep failed", {
|
|
799
|
+
sellerRoutingMode: routing.mode,
|
|
800
|
+
sellerRoutingScorer: routing.scorer,
|
|
801
|
+
errorMessage: err instanceof Error ? err.message : String(err)
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod) {
|
|
806
|
+
const freshness = this.prewarmCache.freshness(modelId, protocol, paymentMethod);
|
|
807
|
+
if (freshness.present && !freshness.expired) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const key = prewarmKey(modelId, protocol, paymentMethod);
|
|
811
|
+
if (this.lazyPrewarmKeys.has(key)) {
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
this.lazyPrewarmKeys.add(key);
|
|
815
|
+
logger.info("prewarm.lazy.scheduled", "lazy prewarm scheduled for requested model", {
|
|
816
|
+
modelId,
|
|
817
|
+
protocol,
|
|
818
|
+
paymentMethod,
|
|
819
|
+
freshnessState: freshness.state
|
|
820
|
+
});
|
|
821
|
+
this.schedulePrewarmForModel({
|
|
822
|
+
modelId,
|
|
823
|
+
reason: "lazy",
|
|
824
|
+
protocol,
|
|
825
|
+
paymentMethod
|
|
826
|
+
}).finally(() => {
|
|
827
|
+
this.lazyPrewarmKeys.delete(key);
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
schedulePrewarmForModel(input) {
|
|
831
|
+
if (!input.protocol || !input.paymentMethod) {
|
|
832
|
+
logger.warn("prewarm.schedule.skipped", "prewarm schedule skipped because protocol or payment method is missing", {
|
|
833
|
+
modelId: input.modelId,
|
|
834
|
+
reason: input.reason,
|
|
835
|
+
protocol: input.protocol,
|
|
836
|
+
paymentMethod: input.paymentMethod
|
|
837
|
+
});
|
|
838
|
+
return Promise.resolve();
|
|
839
|
+
}
|
|
840
|
+
return this.prewarmScheduler.schedulePrewarm({
|
|
841
|
+
modelId: input.modelId,
|
|
842
|
+
reason: input.reason,
|
|
843
|
+
protocol: input.protocol,
|
|
844
|
+
paymentMethod: input.paymentMethod
|
|
845
|
+
});
|
|
412
846
|
}
|
|
413
847
|
failoverErrorMessage(error) {
|
|
414
848
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -422,13 +856,16 @@ export class TokenbuddyDaemon {
|
|
|
422
856
|
* caller side because it short-circuits the failure path with a
|
|
423
857
|
* re-purchase.
|
|
424
858
|
*/
|
|
425
|
-
classifyFailureStatus(status) {
|
|
859
|
+
classifyFailureStatus(status, bodyText) {
|
|
426
860
|
if (status === 401 || status === 403) {
|
|
427
861
|
return "auth_invalid";
|
|
428
862
|
}
|
|
429
863
|
if (status === 402) {
|
|
430
864
|
return "insufficient_funds";
|
|
431
865
|
}
|
|
866
|
+
if (status === 429 && isBusyCapacityErrorBody(bodyText)) {
|
|
867
|
+
return "busy_capacity";
|
|
868
|
+
}
|
|
432
869
|
if (status === 400 || status === 404 || status === 422) {
|
|
433
870
|
return "hard_4xx";
|
|
434
871
|
}
|
|
@@ -561,7 +998,7 @@ export class TokenbuddyDaemon {
|
|
|
561
998
|
}
|
|
562
999
|
return parseSellerSettlementObject(raw);
|
|
563
1000
|
}
|
|
564
|
-
recordReconciledInference(route, endpoint, requestId, usage, settlement, prompt, response) {
|
|
1001
|
+
recordReconciledInference(route, endpoint, requestId, usage, settlement, prompt, response, extras) {
|
|
565
1002
|
if (settlement) {
|
|
566
1003
|
this.tokenStore.reconcileTokenBalance({
|
|
567
1004
|
sellerKey: route.seller.id,
|
|
@@ -589,7 +1026,14 @@ export class TokenbuddyDaemon {
|
|
|
589
1026
|
balanceSnapshotMicros: settlement?.remainingCreditMicros,
|
|
590
1027
|
balanceSource: settlement ? "seller_authoritative" : "estimated",
|
|
591
1028
|
prompt,
|
|
592
|
-
response
|
|
1029
|
+
response,
|
|
1030
|
+
ttftMs: extras?.ttftMs,
|
|
1031
|
+
fallbackCount: extras?.fallbackCount,
|
|
1032
|
+
routeReason: extras?.routeReason,
|
|
1033
|
+
falloverChain: extras?.falloverChain,
|
|
1034
|
+
upstreamStatus: extras?.upstreamStatus,
|
|
1035
|
+
durationMs: extras?.durationMs,
|
|
1036
|
+
paymentMethod: extras?.paymentMethod
|
|
593
1037
|
});
|
|
594
1038
|
logger.info("inference.ledger.recorded", "safe inference ledger recorded", {
|
|
595
1039
|
requestId: settlement?.requestId || requestId,
|
|
@@ -604,7 +1048,14 @@ export class TokenbuddyDaemon {
|
|
|
604
1048
|
promptTokens: usage.promptTokens,
|
|
605
1049
|
completionTokens: usage.completionTokens,
|
|
606
1050
|
balanceSnapshotMicros: settlement?.remainingCreditMicros,
|
|
607
|
-
balanceSource: settlement ? "seller_authoritative" : "estimated"
|
|
1051
|
+
balanceSource: settlement ? "seller_authoritative" : "estimated",
|
|
1052
|
+
ttftMs: extras?.ttftMs,
|
|
1053
|
+
fallbackCount: extras?.fallbackCount,
|
|
1054
|
+
routeReason: extras?.routeReason,
|
|
1055
|
+
falloverChain: extras?.falloverChain,
|
|
1056
|
+
upstreamStatus: extras?.upstreamStatus,
|
|
1057
|
+
durationMs: extras?.durationMs,
|
|
1058
|
+
paymentMethod: extras?.paymentMethod
|
|
608
1059
|
});
|
|
609
1060
|
}
|
|
610
1061
|
async refreshSellerBalance(route, token, balanceSource) {
|
|
@@ -1074,6 +1525,11 @@ export class TokenbuddyDaemon {
|
|
|
1074
1525
|
}
|
|
1075
1526
|
async forwardProxyRequest(endpoint, req, res) {
|
|
1076
1527
|
const startedAt = Date.now();
|
|
1528
|
+
let firstByteAt = null;
|
|
1529
|
+
const markFirstByte = () => {
|
|
1530
|
+
if (firstByteAt === null)
|
|
1531
|
+
firstByteAt = Date.now();
|
|
1532
|
+
};
|
|
1077
1533
|
const body = req.body || {};
|
|
1078
1534
|
const { requestedModelId, resolvedModelId } = this.resolveRouteModelId(endpoint, body);
|
|
1079
1535
|
const modelId = resolvedModelId;
|
|
@@ -1084,13 +1540,20 @@ export class TokenbuddyDaemon {
|
|
|
1084
1540
|
return;
|
|
1085
1541
|
}
|
|
1086
1542
|
try {
|
|
1087
|
-
const routes = await this.selectSellerRoutes(endpoint, modelId);
|
|
1543
|
+
const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId);
|
|
1544
|
+
const upstreamStatusFromHeaders = (h) => {
|
|
1545
|
+
const raw = h.get("x-tokenbuddy-upstream-status") || h.get("x-upstream-status");
|
|
1546
|
+
if (!raw)
|
|
1547
|
+
return undefined;
|
|
1548
|
+
return raw === "healthy" || raw === "degraded" || raw === "unhealthy" || raw === "unknown" ? raw : "unknown";
|
|
1549
|
+
};
|
|
1088
1550
|
let lastError;
|
|
1089
1551
|
for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
|
|
1090
1552
|
const route = routes[routeIndex];
|
|
1091
1553
|
const sellerKey = route.seller.id;
|
|
1092
1554
|
logger.info("route.selected", "seller route selected", {
|
|
1093
1555
|
sellerKey,
|
|
1556
|
+
sellerId: sellerKey,
|
|
1094
1557
|
model: modelId,
|
|
1095
1558
|
endpoint,
|
|
1096
1559
|
protocol: route.protocol,
|
|
@@ -1247,7 +1710,7 @@ export class TokenbuddyDaemon {
|
|
|
1247
1710
|
status: upstreamResponse.status,
|
|
1248
1711
|
durationMs: Date.now() - startedAt
|
|
1249
1712
|
});
|
|
1250
|
-
const kind = this.classifyFailureStatus(upstreamResponse.status);
|
|
1713
|
+
const kind = this.classifyFailureStatus(upstreamResponse.status, errorBody);
|
|
1251
1714
|
const decision = this.routeFailover.decide({
|
|
1252
1715
|
sellerId: sellerKey,
|
|
1253
1716
|
status: upstreamResponse.status,
|
|
@@ -1317,6 +1780,7 @@ export class TokenbuddyDaemon {
|
|
|
1317
1780
|
// 缺 event: 行)由卖方修,buyer 不兜底。
|
|
1318
1781
|
const sellerChunk = settlementExtractor.push(chunk);
|
|
1319
1782
|
if (sellerChunk.length > 0) {
|
|
1783
|
+
markFirstByte();
|
|
1320
1784
|
res.write(sellerChunk);
|
|
1321
1785
|
}
|
|
1322
1786
|
}
|
|
@@ -1328,21 +1792,40 @@ export class TokenbuddyDaemon {
|
|
|
1328
1792
|
if (decoderTail.length > 0) {
|
|
1329
1793
|
const sellerTail = settlementExtractor.push(decoderTail);
|
|
1330
1794
|
if (sellerTail.length > 0) {
|
|
1795
|
+
markFirstByte();
|
|
1331
1796
|
res.write(sellerTail);
|
|
1332
1797
|
}
|
|
1333
1798
|
}
|
|
1334
1799
|
const settlementTrailing = settlementExtractor.finish();
|
|
1335
1800
|
if (settlementTrailing.downstream.length > 0) {
|
|
1801
|
+
markFirstByte();
|
|
1336
1802
|
res.write(settlementTrailing.downstream);
|
|
1337
1803
|
}
|
|
1338
1804
|
res.end();
|
|
1339
|
-
this.recordReconciledInference(route, endpoint, requestId, { promptTokens: 0, completionTokens: 0, billedMicros: Math.max(1, bytes) }, this.parseSellerSettlementSummary(upstreamResponse.headers) ?? settlementTrailing.settlement ?? settlementExtractor.current(), this.inferPromptForHash(body)
|
|
1805
|
+
this.recordReconciledInference(route, endpoint, requestId, { promptTokens: 0, completionTokens: 0, billedMicros: Math.max(1, bytes) }, this.parseSellerSettlementSummary(upstreamResponse.headers) ?? settlementTrailing.settlement ?? settlementExtractor.current(), this.inferPromptForHash(body), undefined, {
|
|
1806
|
+
ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
|
|
1807
|
+
fallbackCount: routeIndex,
|
|
1808
|
+
routeReason: plan.reason,
|
|
1809
|
+
falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
|
|
1810
|
+
upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
|
|
1811
|
+
durationMs: Date.now() - startedAt,
|
|
1812
|
+
paymentMethod
|
|
1813
|
+
});
|
|
1340
1814
|
return;
|
|
1341
1815
|
}
|
|
1342
1816
|
const responseBody = await upstreamResponse.text();
|
|
1817
|
+
markFirstByte();
|
|
1343
1818
|
res.send(responseBody);
|
|
1344
1819
|
const usage = this.readUsage(responseBody);
|
|
1345
|
-
this.recordReconciledInference(route, endpoint, requestId, usage, this.parseSellerSettlementSummary(upstreamResponse.headers), this.inferPromptForHash(body), responseBody
|
|
1820
|
+
this.recordReconciledInference(route, endpoint, requestId, usage, this.parseSellerSettlementSummary(upstreamResponse.headers), this.inferPromptForHash(body), responseBody, {
|
|
1821
|
+
ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
|
|
1822
|
+
fallbackCount: routeIndex,
|
|
1823
|
+
routeReason: plan.reason,
|
|
1824
|
+
falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
|
|
1825
|
+
upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
|
|
1826
|
+
durationMs: Date.now() - startedAt,
|
|
1827
|
+
paymentMethod
|
|
1828
|
+
});
|
|
1346
1829
|
return;
|
|
1347
1830
|
}
|
|
1348
1831
|
catch (routeError) {
|
|
@@ -1437,9 +1920,41 @@ export class TokenbuddyDaemon {
|
|
|
1437
1920
|
controlApp.get("/payments", (req, res) => {
|
|
1438
1921
|
logger.info("control.payments.requested", "control payments requested", {});
|
|
1439
1922
|
res.status(200).json({
|
|
1440
|
-
payments: this.tokenStore.listPayments()
|
|
1923
|
+
payments: this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir))
|
|
1441
1924
|
});
|
|
1442
1925
|
});
|
|
1926
|
+
controlApp.post("/payments/clawtip/activate", async (req, res) => {
|
|
1927
|
+
try {
|
|
1928
|
+
const qr = await this.startClawtipActivationQr();
|
|
1929
|
+
logger.info("control.payment.clawtip.activate_qr.created", "ClawTip activation QR copied for tb-ui", {
|
|
1930
|
+
orderNo: qr.orderNo,
|
|
1931
|
+
qrImageUrl: qr.qrImageUrl,
|
|
1932
|
+
walletConfigPresent: qr.walletConfigPresent
|
|
1933
|
+
});
|
|
1934
|
+
res.status(200).json(qr);
|
|
1935
|
+
}
|
|
1936
|
+
catch (error) {
|
|
1937
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1938
|
+
logger.warn("control.payment.clawtip.activate_qr.failed", "ClawTip activation QR failed", { errorMessage });
|
|
1939
|
+
res.status(500).json({ error: { code: "clawtip_activate_qr_failed", message: errorMessage } });
|
|
1940
|
+
}
|
|
1941
|
+
});
|
|
1942
|
+
controlApp.post("/payments/clawtip/recharge", (req, res) => {
|
|
1943
|
+
try {
|
|
1944
|
+
const qr = this.clawtipRechargeQr();
|
|
1945
|
+
logger.info("control.payment.clawtip.recharge_qr.created", "ClawTip fixed recharge QR served for tb-ui", {
|
|
1946
|
+
orderNo: qr.orderNo,
|
|
1947
|
+
qrImageUrl: qr.qrImageUrl,
|
|
1948
|
+
walletConfigPresent: qr.walletConfigPresent
|
|
1949
|
+
});
|
|
1950
|
+
res.status(200).json(qr);
|
|
1951
|
+
}
|
|
1952
|
+
catch (error) {
|
|
1953
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1954
|
+
logger.warn("control.payment.clawtip.recharge_qr.failed", "ClawTip recharge QR failed", { errorMessage });
|
|
1955
|
+
res.status(500).json({ error: { code: "clawtip_recharge_qr_failed", message: errorMessage } });
|
|
1956
|
+
}
|
|
1957
|
+
});
|
|
1443
1958
|
controlApp.get("/ledger/purchases", (req, res) => {
|
|
1444
1959
|
logger.info("control.ledger.requested", "control purchase ledger requested", {
|
|
1445
1960
|
ledger: "purchases"
|
|
@@ -1578,6 +2093,41 @@ export class TokenbuddyDaemon {
|
|
|
1578
2093
|
});
|
|
1579
2094
|
}
|
|
1580
2095
|
});
|
|
2096
|
+
controlApp.get("/providers/status", (_req, res) => {
|
|
2097
|
+
try {
|
|
2098
|
+
const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
|
|
2099
|
+
const clients = [
|
|
2100
|
+
...providerStatuses,
|
|
2101
|
+
buildCustomClientToolStatus(this.activeProxyPort()),
|
|
2102
|
+
];
|
|
2103
|
+
const configuredCount = clients.filter((client) => client.configured).length;
|
|
2104
|
+
const detectedCount = clients.filter((client) => client.detected && client.status !== "manual").length;
|
|
2105
|
+
logger.info("provider.status.requested", "provider status requested", {
|
|
2106
|
+
clientCount: clients.length,
|
|
2107
|
+
configuredCount,
|
|
2108
|
+
detectedCount
|
|
2109
|
+
});
|
|
2110
|
+
res.status(200).json({
|
|
2111
|
+
clients,
|
|
2112
|
+
summary: {
|
|
2113
|
+
configuredCount,
|
|
2114
|
+
detectedCount,
|
|
2115
|
+
totalCount: clients.length,
|
|
2116
|
+
installCommand: "tb init"
|
|
2117
|
+
}
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
catch (error) {
|
|
2121
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2122
|
+
logger.warn("provider.status.failed", "provider status failed", { errorMessage });
|
|
2123
|
+
res.status(400).json({
|
|
2124
|
+
error: {
|
|
2125
|
+
code: "provider_status_failed",
|
|
2126
|
+
message: errorMessage
|
|
2127
|
+
}
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
});
|
|
1581
2131
|
controlApp.post("/providers/install/preview", (req, res) => {
|
|
1582
2132
|
try {
|
|
1583
2133
|
const changes = previewProviderInstall({
|
|
@@ -1655,6 +2205,169 @@ export class TokenbuddyDaemon {
|
|
|
1655
2205
|
});
|
|
1656
2206
|
}
|
|
1657
2207
|
});
|
|
2208
|
+
// ─────────────────────────────────────────────────────────────────
|
|
2209
|
+
// tb-ui v1: 控制平面写端点(PR-0)
|
|
2210
|
+
// 所有端点都热生效,不需重启 daemon。改完 store 即下次推理生效。
|
|
2211
|
+
// ─────────────────────────────────────────────────────────────────
|
|
2212
|
+
// 1) GET /routing/strategy — 读当前路由策略 + 来源
|
|
2213
|
+
controlApp.get("/routing/strategy", (req, res) => {
|
|
2214
|
+
try {
|
|
2215
|
+
const stored = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)?.config;
|
|
2216
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2217
|
+
const source = stored !== undefined ? "store" : this.config.sellerRouting ? "config" : "default";
|
|
2218
|
+
logger.info("routing.strategy.read", "routing strategy read", {
|
|
2219
|
+
source,
|
|
2220
|
+
mode: current.mode,
|
|
2221
|
+
scorer: current.scorer
|
|
2222
|
+
});
|
|
2223
|
+
res.status(200).json({ strategy: current, source });
|
|
2224
|
+
}
|
|
2225
|
+
catch (error) {
|
|
2226
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2227
|
+
logger.warn("routing.strategy.read_failed", "routing strategy read failed", { errorMessage });
|
|
2228
|
+
res.status(500).json({ error: { code: "routing_strategy_read_failed", message: errorMessage } });
|
|
2229
|
+
}
|
|
2230
|
+
});
|
|
2231
|
+
// 2) GET /routing/preview — 算「假如改完会怎样」,不改 state
|
|
2232
|
+
// query: modelId? protocol? paymentMethod? mode? scorer? sellerId? sellerIds?(逗号分隔)
|
|
2233
|
+
controlApp.get("/routing/preview", (req, res) => {
|
|
2234
|
+
try {
|
|
2235
|
+
const override = buildRoutingConfigFromQuery(req.query);
|
|
2236
|
+
const result = this.buildRoutingPreview({
|
|
2237
|
+
modelId: typeof req.query.modelId === "string" ? req.query.modelId : undefined,
|
|
2238
|
+
protocol: typeof req.query.protocol === "string" ? req.query.protocol : undefined,
|
|
2239
|
+
paymentMethod: typeof req.query.paymentMethod === "string" ? req.query.paymentMethod : undefined,
|
|
2240
|
+
routing: override ?? undefined
|
|
2241
|
+
});
|
|
2242
|
+
if ("error" in result.plan) {
|
|
2243
|
+
res.status(409).json({
|
|
2244
|
+
error: { code: result.plan.error, message: `cannot preview routing: ${result.plan.error}` },
|
|
2245
|
+
modelId: result.modelId,
|
|
2246
|
+
protocol: result.protocol,
|
|
2247
|
+
paymentMethod: result.paymentMethod
|
|
2248
|
+
});
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
res.status(200).json({
|
|
2252
|
+
modelId: result.modelId,
|
|
2253
|
+
protocol: result.protocol,
|
|
2254
|
+
paymentMethod: result.paymentMethod,
|
|
2255
|
+
plan: result.plan
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
catch (error) {
|
|
2259
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2260
|
+
logger.warn("routing.preview.failed", "routing preview failed", { errorMessage });
|
|
2261
|
+
res.status(400).json({ error: { code: "routing_preview_failed", message: errorMessage } });
|
|
2262
|
+
}
|
|
2263
|
+
});
|
|
2264
|
+
// 3) PUT /routing/strategy — 写策略 + 热更新 + 返回 preview
|
|
2265
|
+
controlApp.put("/routing/strategy", (req, res) => {
|
|
2266
|
+
try {
|
|
2267
|
+
const body = (req.body ?? {});
|
|
2268
|
+
const normalized = normalizeSellerRoutingConfig(body);
|
|
2269
|
+
// 必填字段再次校验(normalize 会回退 default,但 PUT 必须显式)
|
|
2270
|
+
assertSellerRoutingConfig(normalized);
|
|
2271
|
+
this.tokenStore.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, normalized);
|
|
2272
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2273
|
+
logger.info("routing.strategy.applied", "routing strategy applied", {
|
|
2274
|
+
mode: current.mode,
|
|
2275
|
+
scorer: current.scorer,
|
|
2276
|
+
sellerId: current.sellerId,
|
|
2277
|
+
sellerIds: current.sellerIds
|
|
2278
|
+
});
|
|
2279
|
+
const preview = this.buildRoutingPreview({ routing: current });
|
|
2280
|
+
const previewPayload = "error" in preview.plan
|
|
2281
|
+
? { error: preview.plan.error }
|
|
2282
|
+
: {
|
|
2283
|
+
modelId: preview.modelId,
|
|
2284
|
+
protocol: preview.protocol,
|
|
2285
|
+
paymentMethod: preview.paymentMethod,
|
|
2286
|
+
...preview.plan
|
|
2287
|
+
};
|
|
2288
|
+
res.status(200).json({ applied: true, strategy: current, preview: previewPayload });
|
|
2289
|
+
}
|
|
2290
|
+
catch (error) {
|
|
2291
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2292
|
+
logger.warn("routing.strategy.apply_failed", "routing strategy apply failed", { errorMessage });
|
|
2293
|
+
res.status(400).json({ error: { code: "routing_strategy_apply_failed", message: errorMessage } });
|
|
2294
|
+
}
|
|
2295
|
+
});
|
|
2296
|
+
// 4) PUT /prewarm/focus-set — 设置 explicit focus set(覆盖 config.warmupModels / env)
|
|
2297
|
+
// body: { models: ["claude-3-5-sonnet", "gpt-4o"], clear?: false }
|
|
2298
|
+
// clear=true 时 models 数组可省略;表示回退 env / historical
|
|
2299
|
+
controlApp.put("/prewarm/focus-set", (req, res) => {
|
|
2300
|
+
try {
|
|
2301
|
+
const body = (req.body ?? {});
|
|
2302
|
+
const clear = body.clear === true;
|
|
2303
|
+
if (clear) {
|
|
2304
|
+
const result = this.applyFocusSet(null);
|
|
2305
|
+
res.status(200).json({ ok: true, ...result });
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
if (!Array.isArray(body.models)) {
|
|
2309
|
+
res.status(400).json({
|
|
2310
|
+
error: { code: "invalid_focus_set", message: "focus-set body must have a string[] `models` field, or `clear: true`" }
|
|
2311
|
+
});
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
const models = body.models
|
|
2315
|
+
.filter((m) => typeof m === "string")
|
|
2316
|
+
.map((m) => m.trim())
|
|
2317
|
+
.filter(Boolean);
|
|
2318
|
+
const result = this.applyFocusSet(models);
|
|
2319
|
+
res.status(200).json({ ok: true, ...result });
|
|
2320
|
+
}
|
|
2321
|
+
catch (error) {
|
|
2322
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2323
|
+
logger.warn("focus_set.apply_failed", "focus set apply failed", { errorMessage });
|
|
2324
|
+
res.status(400).json({ error: { code: "focus_set_apply_failed", message: errorMessage } });
|
|
2325
|
+
}
|
|
2326
|
+
});
|
|
2327
|
+
// 5) POST /daemon/restart — 优雅重启 tb-proxyd(调子进程 `tb daemon restart`)
|
|
2328
|
+
// 现有 CLI 子命令(cli.ts:1129)。detached 模式让子进程独立于 daemon 生命周期。
|
|
2329
|
+
controlApp.post("/daemon/restart", (req, res) => {
|
|
2330
|
+
try {
|
|
2331
|
+
const child = spawn("tb", ["daemon", "restart"], {
|
|
2332
|
+
detached: true,
|
|
2333
|
+
stdio: "ignore"
|
|
2334
|
+
});
|
|
2335
|
+
child.unref();
|
|
2336
|
+
logger.info("daemon.restart.scheduled", "daemon restart scheduled via tb CLI");
|
|
2337
|
+
res.status(202).json({ ok: true, message: "restart scheduled" });
|
|
2338
|
+
}
|
|
2339
|
+
catch (error) {
|
|
2340
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2341
|
+
logger.warn("daemon.restart.failed", "daemon restart failed", { errorMessage });
|
|
2342
|
+
res.status(500).json({ error: { code: "daemon_restart_failed", message: errorMessage } });
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
this.ensureClawtipStaticAssets();
|
|
2346
|
+
controlApp.use(CLAWTIP_STATIC_ROUTE, express.static(this.clawtipStaticDir(), { index: false, fallthrough: false }));
|
|
2347
|
+
// ────────────────────────────────────────────────────────────
|
|
2348
|
+
// tb-ui v1: 静态托管 SPA(dist 由 `npm run build --workspace tb-ui` 生成)
|
|
2349
|
+
// 必须在所有 API 路由**之后**才挂载,这样:
|
|
2350
|
+
// - `/health` `/ledger/purchases` 等 API 路径仍由上面 17+ 个端点处理
|
|
2351
|
+
// - 真实静态文件(`/index.html` `/assets/index-abc.js` 等)由 express.static 服务
|
|
2352
|
+
// - 未匹配路径(`/overview` `/routing` 等 React Router 路径)走 SPA fallback 回 index.html
|
|
2353
|
+
// ────────────────────────────────────────────────────────────
|
|
2354
|
+
const uiDir = resolveUiDir();
|
|
2355
|
+
if (uiDir && fs.existsSync(uiDir) && fs.existsSync(path.join(uiDir, "index.html"))) {
|
|
2356
|
+
controlApp.use(express.static(uiDir, { index: "index.html", fallthrough: true }));
|
|
2357
|
+
// SPA fallback: React Router 用 BrowserRouter(history mode),客户端路径如
|
|
2358
|
+
// /overview /routing /ledger 不对应任何文件,必须回 index.html 让 React Router 接管。
|
|
2359
|
+
// Express 已经按注册顺序匹配了所有 17+ 个 API 路由,这里只接住"什么都没匹配"的 GET。
|
|
2360
|
+
controlApp.get(/^(?!\/(?:health|status|payments|sellers|models|ledger|routing|prewarm|v1\.2|daemon|providers|static)\/).*/, (_req, res) => {
|
|
2361
|
+
res.sendFile(path.join(uiDir, "index.html"));
|
|
2362
|
+
});
|
|
2363
|
+
logger.info("ui.static.attached", "tb-ui dist attached to control plane SPA fallback", { uiDir });
|
|
2364
|
+
}
|
|
2365
|
+
else {
|
|
2366
|
+
logger.warn("ui.static.missing", "tb-ui dist not found; only control plane API will be served", {
|
|
2367
|
+
uiDir,
|
|
2368
|
+
hint: "run `npm run build --workspace tb-ui` then restart"
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
1658
2371
|
this.controlServer = controlApp.listen(this.config.controlPort);
|
|
1659
2372
|
// 2. Proxy Plane Server (17821)
|
|
1660
2373
|
const proxyApp = express();
|
|
@@ -1717,9 +2430,13 @@ export class TokenbuddyDaemon {
|
|
|
1717
2430
|
/**
|
|
1718
2431
|
* v1.2 §18.4: build the focus set from the explicit config, the env
|
|
1719
2432
|
* override, and the historical usage in the buyer store. The order of
|
|
1720
|
-
* precedence: explicit
|
|
2433
|
+
* precedence: explicit `currentFocusSet` (set via `PUT /prewarm/focus-set`)
|
|
2434
|
+
* > explicit config > env > historical > empty.
|
|
1721
2435
|
*/
|
|
1722
2436
|
resolveFocusSet() {
|
|
2437
|
+
if (this.currentFocusSet !== null) {
|
|
2438
|
+
return this.currentFocusSet;
|
|
2439
|
+
}
|
|
1723
2440
|
const explicit = this.config.warmupModels ?? [];
|
|
1724
2441
|
if (explicit.length > 0) {
|
|
1725
2442
|
return explicit;
|
|
@@ -1731,6 +2448,86 @@ export class TokenbuddyDaemon {
|
|
|
1731
2448
|
}
|
|
1732
2449
|
return this.tokenStore.recentModels(7, 5);
|
|
1733
2450
|
}
|
|
2451
|
+
/**
|
|
2452
|
+
* tb-ui v1 `PUT /prewarm/focus-set` 调用的统一入口。`models === null`
|
|
2453
|
+
* 表示「清除 explicit focus set,回退 env/historical」。
|
|
2454
|
+
* 写 store + 触发 `runRoutingPrewarmSweep`,**热生效**(不需重启 daemon)。
|
|
2455
|
+
*/
|
|
2456
|
+
applyFocusSet(models) {
|
|
2457
|
+
if (models === null) {
|
|
2458
|
+
this.tokenStore.removeDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY);
|
|
2459
|
+
this.currentFocusSet = null;
|
|
2460
|
+
}
|
|
2461
|
+
else {
|
|
2462
|
+
const deduped = Array.from(new Set(models.map((m) => m.trim()).filter(Boolean)));
|
|
2463
|
+
this.tokenStore.saveDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY, { models: deduped });
|
|
2464
|
+
this.currentFocusSet = deduped;
|
|
2465
|
+
}
|
|
2466
|
+
// 触发路由重读 + 预热 sweep(如果当前 routing 已是焦点集合的依赖)。
|
|
2467
|
+
this.refreshSellerRoutingConfig();
|
|
2468
|
+
const focusSet = this.resolveFocusSet();
|
|
2469
|
+
const source = this.currentFocusSet !== null
|
|
2470
|
+
? "explicit"
|
|
2471
|
+
: (this.config.warmupModels?.length ?? 0) > 0
|
|
2472
|
+
? "explicit"
|
|
2473
|
+
: (process.env.TB_BUYER_WARMUP_MODELS?.trim() ?? "").length > 0
|
|
2474
|
+
? "env"
|
|
2475
|
+
: focusSet.length > 0
|
|
2476
|
+
? "historical"
|
|
2477
|
+
: "empty";
|
|
2478
|
+
logger.info("focus_set.applied", "explicit focus set applied", {
|
|
2479
|
+
source,
|
|
2480
|
+
focusSetSize: focusSet.length,
|
|
2481
|
+
focusSet: focusSet.slice(0, 20)
|
|
2482
|
+
});
|
|
2483
|
+
return { focusSet, source };
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* tb-ui v1 `GET /routing/preview` 和 `PUT /routing/strategy` 复用的 preview 计算。
|
|
2487
|
+
* 接受任意 routing 覆盖(来自 request body)算「假如改成这个,路由会是啥」。
|
|
2488
|
+
* 不修改任何内部 state,**纯函数式**。
|
|
2489
|
+
*/
|
|
2490
|
+
buildRoutingPreview(input) {
|
|
2491
|
+
const registry = this.lastRegistrySnapshot;
|
|
2492
|
+
const focusFirst = this.resolveFocusSet()[0];
|
|
2493
|
+
const registryFirst = registry?.sellers[0]?.models?.[0];
|
|
2494
|
+
const modelId = input.modelId?.trim() || focusFirst || registryFirst || "";
|
|
2495
|
+
const protocol = input.protocol?.trim() || "chat_completions";
|
|
2496
|
+
const paymentMethod = input.paymentMethod?.trim() || this.defaultPaymentMethod() || "clawtip";
|
|
2497
|
+
if (!modelId) {
|
|
2498
|
+
return { modelId, protocol, paymentMethod, plan: { error: "no_focus_model_available" } };
|
|
2499
|
+
}
|
|
2500
|
+
if (!registry) {
|
|
2501
|
+
return { modelId, protocol, paymentMethod, plan: { error: "registry_not_loaded" } };
|
|
2502
|
+
}
|
|
2503
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2504
|
+
const routing = input.routing
|
|
2505
|
+
? mergeSellerRoutingConfig(current, input.routing)
|
|
2506
|
+
: current;
|
|
2507
|
+
const resolvedRouting = resolveSellerRoutingForModel(routing, modelId);
|
|
2508
|
+
const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
|
|
2509
|
+
this.sellerPool.ensureRegistrySellers(registrySellers);
|
|
2510
|
+
const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
|
|
2511
|
+
const plan = planSellerRouteSet({
|
|
2512
|
+
modelId,
|
|
2513
|
+
protocol,
|
|
2514
|
+
paymentMethod,
|
|
2515
|
+
registrySellers,
|
|
2516
|
+
routing: resolvedRouting,
|
|
2517
|
+
prewarmCandidates: this.prewarmCache.get(modelId, protocol, paymentMethod)?.candidates,
|
|
2518
|
+
sellerMetrics: Array.from(poolById.values()).map((entry) => ({
|
|
2519
|
+
sellerId: entry.sellerId,
|
|
2520
|
+
healthScore: entry.healthScore,
|
|
2521
|
+
avgLatencyMs: entry.avgLatencyMs,
|
|
2522
|
+
ttftMs: entry.ttftMs,
|
|
2523
|
+
avgInferenceMs: entry.avgInferenceMs,
|
|
2524
|
+
circuit: entry.circuit,
|
|
2525
|
+
capacityBlockedUntil: entry.capacityBlockedUntil
|
|
2526
|
+
})),
|
|
2527
|
+
now: Date.now()
|
|
2528
|
+
});
|
|
2529
|
+
return { modelId, protocol, paymentMethod, plan };
|
|
2530
|
+
}
|
|
1734
2531
|
async runStartupPrewarmSweep() {
|
|
1735
2532
|
const focusSet = this.resolveFocusSet();
|
|
1736
2533
|
if (focusSet.length === 0) {
|
|
@@ -1745,7 +2542,7 @@ export class TokenbuddyDaemon {
|
|
|
1745
2542
|
await this.fetchRegistry();
|
|
1746
2543
|
await this.prewarmScheduler.runStartupPrewarm(focusSet.map((modelId) => ({
|
|
1747
2544
|
modelId,
|
|
1748
|
-
protocol: this.resolvePrewarmProtocol(modelId)
|
|
2545
|
+
protocol: this.resolvePrewarmProtocol(modelId, this.defaultPaymentMethod())
|
|
1749
2546
|
})));
|
|
1750
2547
|
}
|
|
1751
2548
|
catch (err) {
|
|
@@ -1754,15 +2551,18 @@ export class TokenbuddyDaemon {
|
|
|
1754
2551
|
});
|
|
1755
2552
|
}
|
|
1756
2553
|
}
|
|
1757
|
-
resolvePrewarmProtocol(modelId) {
|
|
2554
|
+
resolvePrewarmProtocol(modelId, paymentMethod = "clawtip") {
|
|
1758
2555
|
for (const protocol of ["chat_completions", "messages", "responses"]) {
|
|
1759
|
-
if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod
|
|
2556
|
+
if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod }).length > 0) {
|
|
1760
2557
|
return protocol;
|
|
1761
2558
|
}
|
|
1762
2559
|
}
|
|
1763
2560
|
return undefined;
|
|
1764
2561
|
}
|
|
1765
2562
|
stop() {
|
|
2563
|
+
if (this.clawtipActivationWaitCancelToken) {
|
|
2564
|
+
this.clawtipActivationWaitCancelToken.cancelled = true;
|
|
2565
|
+
}
|
|
1766
2566
|
if (this.controlServer)
|
|
1767
2567
|
this.controlServer.close();
|
|
1768
2568
|
if (this.proxyServer)
|
|
@@ -1770,5 +2570,166 @@ export class TokenbuddyDaemon {
|
|
|
1770
2570
|
void this.prewarmScheduler.stop();
|
|
1771
2571
|
this.tokenStore.close();
|
|
1772
2572
|
}
|
|
2573
|
+
/**
|
|
2574
|
+
* @internal — test-only seam to inject a registry snapshot without
|
|
2575
|
+
* hitting the network. Used by `tests/control-plane-ui-endpoints.test.ts`
|
|
2576
|
+
* to drive `buildRoutingPreview` deterministically. Production code
|
|
2577
|
+
* must NOT call this; the real `fetchRegistry()` populates the snapshot.
|
|
2578
|
+
*/
|
|
2579
|
+
setLastRegistrySnapshotForTest(snapshot) {
|
|
2580
|
+
this.lastRegistrySnapshot = snapshot;
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
function selectionModeForRouting(routing) {
|
|
2584
|
+
return routing.mode === "fullAuto" ? "auto" : "manual";
|
|
2585
|
+
}
|
|
2586
|
+
function withLiveClawtipWalletState(payment, home) {
|
|
2587
|
+
if (payment.method !== "clawtip") {
|
|
2588
|
+
return payment;
|
|
2589
|
+
}
|
|
2590
|
+
const walletConfig = inspectOpenClawWalletConfig(home);
|
|
2591
|
+
return {
|
|
2592
|
+
...payment,
|
|
2593
|
+
enabled: payment.enabled && walletConfig.exists,
|
|
2594
|
+
config: {
|
|
2595
|
+
...(payment.config ?? {}),
|
|
2596
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
2597
|
+
walletConfigPresent: walletConfig.exists,
|
|
2598
|
+
nearbyWalletConfigPaths: walletConfig.alternatePaths
|
|
2599
|
+
}
|
|
2600
|
+
};
|
|
2601
|
+
}
|
|
2602
|
+
function normalizeClawtipActivationPayment(bootstrap) {
|
|
2603
|
+
if (!bootstrap.payment?.orderNo || !bootstrap.payment.indicator || !bootstrap.payment.resourceUrl) {
|
|
2604
|
+
throw new Error("ClawTip bootstrap response missing orderNo, indicator, or resourceUrl.");
|
|
2605
|
+
}
|
|
2606
|
+
return {
|
|
2607
|
+
orderNo: bootstrap.payment.orderNo,
|
|
2608
|
+
amountFen: bootstrap.payment.amountFen ?? bootstrap.activationFeeFen ?? 1,
|
|
2609
|
+
payTo: bootstrap.payment.payTo,
|
|
2610
|
+
encryptedData: bootstrap.payment.encryptedData,
|
|
2611
|
+
indicator: bootstrap.payment.indicator,
|
|
2612
|
+
slug: bootstrap.payment.slug,
|
|
2613
|
+
skillId: bootstrap.payment.skillId,
|
|
2614
|
+
description: bootstrap.payment.description,
|
|
2615
|
+
resourceUrl: bootstrap.payment.resourceUrl,
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
function safeQrExtension(filePath) {
|
|
2619
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
2620
|
+
if (extension === ".jpg" || extension === ".jpeg" || extension === ".webp") {
|
|
2621
|
+
return extension;
|
|
2622
|
+
}
|
|
2623
|
+
return ".png";
|
|
2624
|
+
}
|
|
2625
|
+
function safeStaticFileSegment(value) {
|
|
2626
|
+
return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "clawtip";
|
|
2627
|
+
}
|
|
2628
|
+
function readConfigString(config, key) {
|
|
2629
|
+
const value = config?.[key];
|
|
2630
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
2631
|
+
}
|
|
2632
|
+
function readConfigBoolean(config, key) {
|
|
2633
|
+
return config?.[key] === true;
|
|
2634
|
+
}
|
|
2635
|
+
function selectedSellerIdForRouting(routing) {
|
|
2636
|
+
return routing.mode === "fixed" ? routing.sellerId : undefined;
|
|
2637
|
+
}
|
|
2638
|
+
function routingKey(routing) {
|
|
2639
|
+
const fixedByModel = Object.entries(routing.fixedByModel ?? {})
|
|
2640
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
2641
|
+
.map(([modelId, sellerId]) => `${modelId}:${sellerId}`);
|
|
2642
|
+
return [
|
|
2643
|
+
routing.mode,
|
|
2644
|
+
routing.scorer,
|
|
2645
|
+
routing.sellerId ?? "",
|
|
2646
|
+
...(routing.sellerIds ?? []),
|
|
2647
|
+
...fixedByModel
|
|
2648
|
+
].join("\u0001");
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* 从 query string 构造 `BuyerSellerRoutingConfig` override(用于 GET /routing/preview)。
|
|
2652
|
+
* 接受 `mode` / `scorer` / `sellerId` / `sellerIds`(逗号分隔)。
|
|
2653
|
+
* 任一字段缺失返回 `undefined`,调用方走「用当前 routing」分支。
|
|
2654
|
+
* mode / scorer 非法抛 400,由端点 handler 捕获。
|
|
2655
|
+
*/
|
|
2656
|
+
function buildRoutingConfigFromQuery(query) {
|
|
2657
|
+
const mode = typeof query.mode === "string" ? query.mode.trim() : "";
|
|
2658
|
+
const scorer = typeof query.scorer === "string" ? query.scorer.trim() : "";
|
|
2659
|
+
const sellerId = typeof query.sellerId === "string" ? query.sellerId.trim() : "";
|
|
2660
|
+
const sellerIdsRaw = typeof query.sellerIds === "string" ? query.sellerIds.trim() : "";
|
|
2661
|
+
const fixedByModelRaw = typeof query.fixedByModel === "string" ? query.fixedByModel.trim() : "";
|
|
2662
|
+
if (!mode && !scorer && !sellerId && !sellerIdsRaw && !fixedByModelRaw) {
|
|
2663
|
+
return undefined;
|
|
2664
|
+
}
|
|
2665
|
+
const override = {};
|
|
2666
|
+
if (mode) {
|
|
2667
|
+
if (mode !== "fixed" && mode !== "fixedSet" && mode !== "fullAuto") {
|
|
2668
|
+
throw new Error("mode must be fixed, fixedSet, or fullAuto");
|
|
2669
|
+
}
|
|
2670
|
+
override.mode = mode;
|
|
2671
|
+
}
|
|
2672
|
+
if (scorer) {
|
|
2673
|
+
if (scorer !== "speed" && scorer !== "discount" && scorer !== "balanced") {
|
|
2674
|
+
throw new Error("scorer must be speed, discount, or balanced");
|
|
2675
|
+
}
|
|
2676
|
+
override.scorer = scorer;
|
|
2677
|
+
}
|
|
2678
|
+
if (sellerId) {
|
|
2679
|
+
override.sellerId = sellerId;
|
|
2680
|
+
}
|
|
2681
|
+
if (sellerIdsRaw) {
|
|
2682
|
+
override.sellerIds = parseSellerIdList(sellerIdsRaw);
|
|
2683
|
+
}
|
|
2684
|
+
if (fixedByModelRaw) {
|
|
2685
|
+
override.fixedByModel = parseFixedByModel(fixedByModelRaw);
|
|
2686
|
+
}
|
|
2687
|
+
return override;
|
|
2688
|
+
}
|
|
2689
|
+
function sameSellerRouting(a, b) {
|
|
2690
|
+
return a.mode === b.mode
|
|
2691
|
+
&& a.scorer === b.scorer
|
|
2692
|
+
&& optionalStringEqual(a.sellerId, b.sellerId)
|
|
2693
|
+
&& stringArraysEqual(a.sellerIds ?? [], b.sellerIds ?? [])
|
|
2694
|
+
&& fixedByModelEqual(a.fixedByModel ?? {}, b.fixedByModel ?? {});
|
|
2695
|
+
}
|
|
2696
|
+
function optionalStringEqual(a, b) {
|
|
2697
|
+
return (a ?? "") === (b ?? "");
|
|
2698
|
+
}
|
|
2699
|
+
function stringArraysEqual(a, b) {
|
|
2700
|
+
if (a.length !== b.length) {
|
|
2701
|
+
return false;
|
|
2702
|
+
}
|
|
2703
|
+
return a.every((entry, index) => entry === b[index]);
|
|
2704
|
+
}
|
|
2705
|
+
function resolveSellerRoutingForModel(routing, modelId) {
|
|
2706
|
+
if (routing.mode !== "fixed") {
|
|
2707
|
+
return routing;
|
|
2708
|
+
}
|
|
2709
|
+
const fixedSellerId = routing.fixedByModel?.[modelId]?.trim() || routing.sellerId;
|
|
2710
|
+
return {
|
|
2711
|
+
mode: "fixed",
|
|
2712
|
+
scorer: routing.scorer,
|
|
2713
|
+
sellerId: fixedSellerId,
|
|
2714
|
+
fixedByModel: routing.fixedByModel
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
function parseFixedByModel(value) {
|
|
2718
|
+
const entries = value
|
|
2719
|
+
.split(",")
|
|
2720
|
+
.map((entry) => entry.split(":"))
|
|
2721
|
+
.filter((parts) => parts.length === 2)
|
|
2722
|
+
.map(([modelId, sellerId]) => [modelId.trim(), sellerId.trim()])
|
|
2723
|
+
.filter(([modelId, sellerId]) => modelId.length > 0 && sellerId.length > 0);
|
|
2724
|
+
return Object.fromEntries(entries);
|
|
2725
|
+
}
|
|
2726
|
+
function fixedByModelEqual(a, b) {
|
|
2727
|
+
const aEntries = Object.entries(a).sort(([left], [right]) => left.localeCompare(right));
|
|
2728
|
+
const bEntries = Object.entries(b).sort(([left], [right]) => left.localeCompare(right));
|
|
2729
|
+
return aEntries.length === bEntries.length
|
|
2730
|
+
&& aEntries.every(([modelId, sellerId], index) => {
|
|
2731
|
+
const [otherModelId, otherSellerId] = bEntries[index] ?? [];
|
|
2732
|
+
return modelId === otherModelId && sellerId === otherSellerId;
|
|
2733
|
+
});
|
|
1773
2734
|
}
|
|
1774
2735
|
//# sourceMappingURL=daemon.js.map
|