@tokenbuddy/tokenbuddy 1.0.14 → 1.0.16
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 +1007 -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 +1159 -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/static/ui/assets/index-UMiTTeo8.css +1 -0
- package/static/ui/assets/index-YHs-Ca0f.js +206 -0
- package/static/ui/assets/index-YHs-Ca0f.js.map +1 -0
- package/static/ui/icons/apple-touch-icon.png +0 -0
- package/static/ui/icons/tokenbuddy-192.png +0 -0
- package/static/ui/icons/tokenbuddy-512.png +0 -0
- package/static/ui/index.html +21 -0
- package/static/ui/manifest.webmanifest +28 -0
- package/static/ui/sw.js +59 -0
- package/tests/control-plane-ui-endpoints.test.ts +589 -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,101 @@ 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 currentModuleDir() {
|
|
29
|
+
if (typeof __dirname !== "undefined") {
|
|
30
|
+
return __dirname;
|
|
31
|
+
}
|
|
32
|
+
const stack = new Error().stack || "";
|
|
33
|
+
const fileUrlMatch = stack.match(/(file:\/\/\/[^)\n]+\/daemon\.js):\d+:\d+/);
|
|
34
|
+
if (fileUrlMatch) {
|
|
35
|
+
return path.dirname(new URL(fileUrlMatch[1]).pathname);
|
|
36
|
+
}
|
|
37
|
+
const filePathMatch = stack.match(/(\/[^)\n]+\/daemon\.(?:js|ts)):\d+:\d+/);
|
|
38
|
+
if (filePathMatch) {
|
|
39
|
+
return path.dirname(filePathMatch[1]);
|
|
40
|
+
}
|
|
41
|
+
return process.cwd();
|
|
42
|
+
}
|
|
43
|
+
function clientToolStatusFromProvider(provider) {
|
|
44
|
+
return {
|
|
45
|
+
id: provider.id,
|
|
46
|
+
name: provider.name,
|
|
47
|
+
status: provider.status,
|
|
48
|
+
detected: provider.detected,
|
|
49
|
+
configured: provider.configured,
|
|
50
|
+
configPath: provider.configPath,
|
|
51
|
+
commandName: provider.commandName,
|
|
52
|
+
reason: provider.reason,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function buildCustomClientToolStatus(proxyPort) {
|
|
56
|
+
const openaiBaseUrl = `http://127.0.0.1:${proxyPort}/v1`;
|
|
57
|
+
const anthropicBaseUrl = `http://127.0.0.1:${proxyPort}`;
|
|
58
|
+
return {
|
|
59
|
+
id: "custom",
|
|
60
|
+
name: "Custom client",
|
|
61
|
+
status: "manual",
|
|
62
|
+
detected: true,
|
|
63
|
+
configured: false,
|
|
64
|
+
reason: `OpenAI-compatible: ${openaiBaseUrl} · Anthropic-compatible: ${anthropicBaseUrl}`,
|
|
65
|
+
manualConfig: {
|
|
66
|
+
openaiBaseUrl,
|
|
67
|
+
anthropicBaseUrl,
|
|
68
|
+
apiKey: "TOKENBUDDY_PROXY",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 解析 `tb-ui` 构建产物目录(daemon 静态托管 SPA 用)。
|
|
74
|
+
* 优先级:env TB_UI_DIR > npm 包内 static/ui > workspace tb-ui/dist。
|
|
75
|
+
* 找不到时记录 warning 仍允许 daemon 启动(纯 API 模式);静态请求会 404。
|
|
76
|
+
*/
|
|
77
|
+
function resolveUiDir() {
|
|
78
|
+
if (process.env.TB_UI_DIR)
|
|
79
|
+
return process.env.TB_UI_DIR;
|
|
80
|
+
const here = currentModuleDir();
|
|
81
|
+
const bundledUiDirs = [
|
|
82
|
+
path.resolve(here, "../../static/ui"),
|
|
83
|
+
path.resolve(here, "../static/ui")
|
|
84
|
+
];
|
|
85
|
+
for (const bundledUiDir of bundledUiDirs) {
|
|
86
|
+
if (fs.existsSync(path.join(bundledUiDir, "index.html"))) {
|
|
87
|
+
return bundledUiDir;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
// require.resolve 在 npm workspaces 装好时能找到 tb-ui/package.json
|
|
92
|
+
const pkgPath = require.resolve("tb-ui/package.json");
|
|
93
|
+
return path.join(path.dirname(pkgPath), "dist");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// fallback: monorepo 假设 daemon dist 跟 tb-ui/dist 在同 root
|
|
97
|
+
return path.resolve(here, "../../tb-ui/dist");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
19
100
|
function numericHeaderField(value) {
|
|
20
101
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
21
102
|
return value;
|
|
@@ -123,6 +204,55 @@ function summarizeProxyBody(body) {
|
|
|
123
204
|
temperaturePresent: data.temperature !== undefined
|
|
124
205
|
};
|
|
125
206
|
}
|
|
207
|
+
function finiteNumber(value) {
|
|
208
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
212
|
+
const parsed = Number(value);
|
|
213
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
function readErrorCode(bodyText) {
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(bodyText);
|
|
220
|
+
const code = parsed.error?.code ?? parsed.code;
|
|
221
|
+
return typeof code === "string" ? code : undefined;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function isBusyCapacityErrorBody(bodyText) {
|
|
228
|
+
if (!bodyText) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
return readErrorCode(bodyText) === ErrorCode.BusyCapacity;
|
|
232
|
+
}
|
|
233
|
+
function capacityBlockedUntilFromHealth(body, now) {
|
|
234
|
+
const capacity = body.capacity;
|
|
235
|
+
if (!capacity) {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
const activeConnections = finiteNumber(capacity.activeConnections ?? capacity.active_connections);
|
|
239
|
+
const maxConnections = finiteNumber(capacity.maxConnections ?? capacity.max_connections);
|
|
240
|
+
const queueDepth = finiteNumber(capacity.queueDepth ?? capacity.queue_depth);
|
|
241
|
+
const maxQueueDepth = finiteNumber(capacity.maxQueueDepth ?? capacity.max_queue_depth);
|
|
242
|
+
if (activeConnections === undefined ||
|
|
243
|
+
maxConnections === undefined ||
|
|
244
|
+
queueDepth === undefined ||
|
|
245
|
+
maxQueueDepth === undefined) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
if (maxConnections <= 0) {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
const connectionsFull = activeConnections >= maxConnections;
|
|
252
|
+
const queueUnavailable = maxQueueDepth <= 0;
|
|
253
|
+
const queueFull = queueUnavailable || queueDepth >= maxQueueDepth;
|
|
254
|
+
return connectionsFull && queueFull ? now + SELLER_CAPACITY_BLOCK_MS : undefined;
|
|
255
|
+
}
|
|
126
256
|
function reorderDefaultSellerFirst(sellers, defaultSellerId) {
|
|
127
257
|
if (!defaultSellerId) {
|
|
128
258
|
return sellers;
|
|
@@ -146,6 +276,15 @@ export class TokenbuddyDaemon {
|
|
|
146
276
|
selectionMode;
|
|
147
277
|
selectedSellerId;
|
|
148
278
|
sellerRouting;
|
|
279
|
+
lastRoutingPrewarmKey;
|
|
280
|
+
lazyPrewarmKeys = new Set();
|
|
281
|
+
clawtipActivationWait;
|
|
282
|
+
clawtipActivationWaitCancelToken;
|
|
283
|
+
/**
|
|
284
|
+
* tb-ui v1 控制平面 `PUT /prewarm/focus-set` 写入的 explicit focus set。
|
|
285
|
+
* 优先级最高;`null` 表示回退到 env / historical(与 `resolveFocusSet()` 原行为一致)。
|
|
286
|
+
*/
|
|
287
|
+
currentFocusSet = null;
|
|
149
288
|
activePurchases = new Map();
|
|
150
289
|
// v1.2 fallback pipeline: model-index, prewarm-cache, credit-tracker,
|
|
151
290
|
// pool, and route-failover together replace the v1
|
|
@@ -170,10 +309,17 @@ export class TokenbuddyDaemon {
|
|
|
170
309
|
this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
|
|
171
310
|
const storedRouting = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)
|
|
172
311
|
?.config;
|
|
312
|
+
const storedFocusSet = this.tokenStore.getDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY)
|
|
313
|
+
?.config;
|
|
173
314
|
this.config = config;
|
|
174
315
|
this.sellerRouting = mergeSellerRoutingConfig(storedRouting, config.sellerRouting);
|
|
175
|
-
this.selectionMode = this.sellerRouting
|
|
176
|
-
this.selectedSellerId = this.sellerRouting
|
|
316
|
+
this.selectionMode = selectionModeForRouting(this.sellerRouting);
|
|
317
|
+
this.selectedSellerId = selectedSellerIdForRouting(this.sellerRouting);
|
|
318
|
+
// tb-ui v1: explicit focus set 优先于 env / historical
|
|
319
|
+
if (storedFocusSet && Array.isArray(storedFocusSet.models)) {
|
|
320
|
+
const deduped = Array.from(new Set(storedFocusSet.models.map((m) => m.trim()).filter(Boolean)));
|
|
321
|
+
this.currentFocusSet = deduped.length > 0 ? deduped : null;
|
|
322
|
+
}
|
|
177
323
|
// v1.2 §18.5: scheduler is created here (not in the field initializer)
|
|
178
324
|
// because it needs the config-derived prober + idle interval.
|
|
179
325
|
Object.assign(this, {
|
|
@@ -204,7 +350,22 @@ export class TokenbuddyDaemon {
|
|
|
204
350
|
if (!res.ok) {
|
|
205
351
|
return { ok: false, latencyMs: Date.now() - startedAt, httpStatus: res.status, errorMessage: `health returned ${res.status}` };
|
|
206
352
|
}
|
|
207
|
-
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
const body = await res.json();
|
|
355
|
+
const upstream = body.upstream;
|
|
356
|
+
const upstreamErrorClass = upstream?.lastErrorClass ?? upstream?.last_error_class;
|
|
357
|
+
return {
|
|
358
|
+
ok: true,
|
|
359
|
+
latencyMs: now - startedAt,
|
|
360
|
+
httpStatus: res.status,
|
|
361
|
+
upstreamStatus: typeof upstream?.status === "string"
|
|
362
|
+
? upstream.status
|
|
363
|
+
: undefined,
|
|
364
|
+
upstreamErrorClass: typeof upstreamErrorClass === "string"
|
|
365
|
+
? upstreamErrorClass
|
|
366
|
+
: undefined,
|
|
367
|
+
capacityBlockedUntil: capacityBlockedUntilFromHealth(body, now)
|
|
368
|
+
};
|
|
208
369
|
}
|
|
209
370
|
catch (err) {
|
|
210
371
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -220,6 +381,193 @@ export class TokenbuddyDaemon {
|
|
|
220
381
|
const address = this.proxyServer?.address?.();
|
|
221
382
|
return typeof address === "object" && address ? address.port : this.config.proxyPort;
|
|
222
383
|
}
|
|
384
|
+
clawtipStaticDir() {
|
|
385
|
+
return path.join(path.dirname(this.config.dbPath), "static", "clawtip");
|
|
386
|
+
}
|
|
387
|
+
bundledClawtipStaticDir() {
|
|
388
|
+
if (this.config.clawtipBundledStaticDir === false) {
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
if (typeof this.config.clawtipBundledStaticDir === "string") {
|
|
392
|
+
return fs.existsSync(this.config.clawtipBundledStaticDir) ? this.config.clawtipBundledStaticDir : undefined;
|
|
393
|
+
}
|
|
394
|
+
const here = currentModuleDir();
|
|
395
|
+
const candidates = [
|
|
396
|
+
path.resolve(here, "../static/clawtip"),
|
|
397
|
+
path.resolve(here, "../../static/clawtip"),
|
|
398
|
+
path.resolve(process.cwd(), "packages/tokenbuddy-cli/static/clawtip")
|
|
399
|
+
];
|
|
400
|
+
return candidates.find((candidate) => fs.existsSync(candidate));
|
|
401
|
+
}
|
|
402
|
+
clawtipPublicUrl(fileName) {
|
|
403
|
+
return `${CLAWTIP_STATIC_ROUTE}/${encodeURIComponent(fileName)}`;
|
|
404
|
+
}
|
|
405
|
+
ensureClawtipStaticAssets() {
|
|
406
|
+
const outputDir = this.clawtipStaticDir();
|
|
407
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
408
|
+
const rechargeOutputPath = path.join(outputDir, CLAWTIP_RECHARGE_QR_FILE);
|
|
409
|
+
if (fs.existsSync(rechargeOutputPath)) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const bundledDir = this.bundledClawtipStaticDir();
|
|
413
|
+
const rechargeSourcePath = bundledDir ? path.join(bundledDir, CLAWTIP_RECHARGE_QR_FILE) : undefined;
|
|
414
|
+
if (rechargeSourcePath && fs.existsSync(rechargeSourcePath)) {
|
|
415
|
+
fs.copyFileSync(rechargeSourcePath, rechargeOutputPath);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
copyClawtipQrToStatic(mediaPath, orderNo) {
|
|
419
|
+
if (!fs.existsSync(mediaPath)) {
|
|
420
|
+
throw new Error(`ClawTip QR image does not exist: ${mediaPath}`);
|
|
421
|
+
}
|
|
422
|
+
const extension = safeQrExtension(mediaPath);
|
|
423
|
+
const fileName = `${safeStaticFileSegment(orderNo)}-${Date.now()}${extension}`;
|
|
424
|
+
const outputDir = this.clawtipStaticDir();
|
|
425
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
426
|
+
const outputPath = path.join(outputDir, fileName);
|
|
427
|
+
fs.copyFileSync(mediaPath, outputPath);
|
|
428
|
+
return {
|
|
429
|
+
fileName,
|
|
430
|
+
path: outputPath,
|
|
431
|
+
url: this.clawtipPublicUrl(fileName)
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
async startClawtipActivationQr() {
|
|
435
|
+
const bootstrapUrl = process.env.TOKENBUDDY_BOOTSTRAP_URL || "https://tb-wallet-bootstrap.fly.dev";
|
|
436
|
+
const fetchBootstrap = this.config.clawtipBootstrapFetcher || fetchClawtipBootstrap;
|
|
437
|
+
const bootstrap = await fetchBootstrap(bootstrapUrl);
|
|
438
|
+
const payment = normalizeClawtipActivationPayment(bootstrap);
|
|
439
|
+
const startBootstrap = this.config.clawtipWalletBootstrapStarter || startClawtipWalletBootstrap;
|
|
440
|
+
const activation = await startBootstrap(payment, { home: this.config.clawtipHomeDir });
|
|
441
|
+
if (!activation.parsedOutput.mediaPath) {
|
|
442
|
+
throw new Error("ClawTip activation did not return a QR image.");
|
|
443
|
+
}
|
|
444
|
+
const staticQr = this.copyClawtipQrToStatic(activation.parsedOutput.mediaPath, payment.orderNo);
|
|
445
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
446
|
+
const existingPayment = this.tokenStore.getPayment("clawtip");
|
|
447
|
+
this.tokenStore.savePayment({
|
|
448
|
+
method: "clawtip",
|
|
449
|
+
enabled: walletConfig.exists,
|
|
450
|
+
isDefault: existingPayment?.isDefault ?? true,
|
|
451
|
+
config: {
|
|
452
|
+
...(existingPayment?.config ?? {}),
|
|
453
|
+
bootstrapUrl,
|
|
454
|
+
orderNo: payment.orderNo,
|
|
455
|
+
amountFen: payment.amountFen,
|
|
456
|
+
indicator: payment.indicator,
|
|
457
|
+
slug: payment.slug,
|
|
458
|
+
skillId: payment.skillId,
|
|
459
|
+
description: payment.description,
|
|
460
|
+
resourceUrl: payment.resourceUrl,
|
|
461
|
+
activationOrderFile: activation.orderFile,
|
|
462
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
463
|
+
walletConfigPresent: walletConfig.exists,
|
|
464
|
+
activationQrImagePath: activation.parsedOutput.mediaPath,
|
|
465
|
+
activationQrImageUrl: staticQr.url,
|
|
466
|
+
authUrl: activation.parsedOutput.authUrl,
|
|
467
|
+
clawtipId: activation.parsedOutput.clawtipId,
|
|
468
|
+
payCredentialWritten: Boolean(activation.payCredential),
|
|
469
|
+
activationCompletedBy: activation.payCredential
|
|
470
|
+
? (walletConfig.exists ? "payCredential+wallet-config" : "payCredential")
|
|
471
|
+
: walletConfig.exists ? "wallet-config" : "pending-wallet-scan"
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
this.scheduleClawtipActivationWait(activation.parsedOutput.clawtipId);
|
|
475
|
+
return {
|
|
476
|
+
ok: true,
|
|
477
|
+
kind: "activate",
|
|
478
|
+
method: "clawtip",
|
|
479
|
+
orderNo: payment.orderNo,
|
|
480
|
+
amountFen: payment.amountFen,
|
|
481
|
+
qrImageUrl: staticQr.url,
|
|
482
|
+
sourceImagePath: activation.parsedOutput.mediaPath,
|
|
483
|
+
staticImagePath: staticQr.path,
|
|
484
|
+
authUrl: activation.parsedOutput.authUrl,
|
|
485
|
+
clawtipId: activation.parsedOutput.clawtipId,
|
|
486
|
+
orderFile: activation.orderFile,
|
|
487
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
488
|
+
walletConfigPresent: walletConfig.exists,
|
|
489
|
+
requiresWalletAuth: activation.parsedOutput.requiresWalletAuth,
|
|
490
|
+
payCredentialWritten: Boolean(activation.payCredential)
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
scheduleClawtipActivationWait(clawtipId) {
|
|
494
|
+
if (this.clawtipActivationWait) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const cancelToken = { cancelled: false };
|
|
498
|
+
this.clawtipActivationWaitCancelToken = cancelToken;
|
|
499
|
+
const waitForActivation = this.config.clawtipActivationWaiter || waitForClawtipActivationConfirmation;
|
|
500
|
+
this.clawtipActivationWait = waitForActivation({
|
|
501
|
+
clawtipId,
|
|
502
|
+
inspectWalletConfig: () => inspectOpenClawWalletConfig(this.config.clawtipHomeDir),
|
|
503
|
+
isCancelled: () => cancelToken.cancelled,
|
|
504
|
+
cancel: () => undefined
|
|
505
|
+
})
|
|
506
|
+
.then((walletRegistered) => {
|
|
507
|
+
if (cancelToken.cancelled) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (!walletRegistered) {
|
|
511
|
+
logger.info("control.payment.clawtip.activation_wait.pending", "ClawTip activation wait ended before wallet registration");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
515
|
+
const payment = this.tokenStore.getPayment("clawtip");
|
|
516
|
+
if (!payment || payment.method !== "clawtip") {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
this.tokenStore.savePayment({
|
|
520
|
+
...payment,
|
|
521
|
+
enabled: walletConfig.exists,
|
|
522
|
+
config: {
|
|
523
|
+
...(payment.config ?? {}),
|
|
524
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
525
|
+
walletConfigPresent: walletConfig.exists,
|
|
526
|
+
activationCompletedBy: walletConfig.exists
|
|
527
|
+
? "wallet-config"
|
|
528
|
+
: readConfigString(payment.config, "activationCompletedBy") ?? "pending-wallet-scan"
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
logger.info("control.payment.clawtip.activation_wait.completed", "ClawTip activation wait completed", {
|
|
532
|
+
walletRegistered,
|
|
533
|
+
walletConfigPresent: walletConfig.exists
|
|
534
|
+
});
|
|
535
|
+
})
|
|
536
|
+
.catch((error) => {
|
|
537
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
538
|
+
logger.warn("control.payment.clawtip.activation_wait.failed", "ClawTip activation wait failed", { errorMessage });
|
|
539
|
+
})
|
|
540
|
+
.finally(() => {
|
|
541
|
+
if (this.clawtipActivationWaitCancelToken === cancelToken) {
|
|
542
|
+
this.clawtipActivationWaitCancelToken = undefined;
|
|
543
|
+
this.clawtipActivationWait = undefined;
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
clawtipRechargeQr() {
|
|
548
|
+
const payment = this.tokenStore.getPayment("clawtip");
|
|
549
|
+
const resourceUrl = readConfigString(payment?.config, "resourceUrl");
|
|
550
|
+
const orderNo = readConfigString(payment?.config, "orderNo") || "clawtip-recharge";
|
|
551
|
+
const mediaPath = path.join(this.clawtipStaticDir(), CLAWTIP_RECHARGE_QR_FILE);
|
|
552
|
+
if (!fs.existsSync(mediaPath)) {
|
|
553
|
+
throw new Error(`ClawTip fixed recharge QR image is missing: ${mediaPath}`);
|
|
554
|
+
}
|
|
555
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
556
|
+
return {
|
|
557
|
+
ok: true,
|
|
558
|
+
kind: "recharge",
|
|
559
|
+
method: "clawtip",
|
|
560
|
+
orderNo,
|
|
561
|
+
qrImageUrl: this.clawtipPublicUrl(CLAWTIP_RECHARGE_QR_FILE),
|
|
562
|
+
sourceImagePath: mediaPath,
|
|
563
|
+
staticImagePath: mediaPath,
|
|
564
|
+
resourceUrl,
|
|
565
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
566
|
+
walletConfigPresent: walletConfig.exists,
|
|
567
|
+
requiresWalletAuth: false,
|
|
568
|
+
payCredentialWritten: readConfigBoolean(payment?.config, "payCredentialWritten")
|
|
569
|
+
};
|
|
570
|
+
}
|
|
223
571
|
// v1.2 §18.9: stale-cache fallback. The buyer remembers the last
|
|
224
572
|
// successfully fetched registry document and reuses it when the
|
|
225
573
|
// bootstrap returns 413 (`X-TokenBuddy-Registry-Too-Large: 1`). This
|
|
@@ -259,6 +607,7 @@ export class TokenbuddyDaemon {
|
|
|
259
607
|
}
|
|
260
608
|
}
|
|
261
609
|
runtimeSummary() {
|
|
610
|
+
this.refreshSellerRoutingConfig();
|
|
262
611
|
return {
|
|
263
612
|
status: "running",
|
|
264
613
|
pid: process.pid,
|
|
@@ -363,11 +712,13 @@ export class TokenbuddyDaemon {
|
|
|
363
712
|
// v1.2: registry is the source of truth for routing. We rebuild the
|
|
364
713
|
// model-index once per request (cheap; index lookup is in-memory) so
|
|
365
714
|
// the response always reflects the latest seller list. The previous
|
|
366
|
-
// "fetchSellerManifest per
|
|
715
|
+
// "fetchSellerManifest per request" path is removed in favor of
|
|
367
716
|
// pulling `models` directly off the registry entries.
|
|
368
717
|
const registry = await this.fetchRegistry();
|
|
369
|
-
const routing = this.
|
|
718
|
+
const routing = resolveSellerRoutingForModel(this.refreshSellerRoutingConfig(), modelId);
|
|
370
719
|
const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
|
|
720
|
+
this.sellerPool.ensureRegistrySellers(registrySellers);
|
|
721
|
+
this.scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod);
|
|
371
722
|
const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
|
|
372
723
|
const planned = planSellerRouteSet({
|
|
373
724
|
modelId,
|
|
@@ -380,8 +731,12 @@ export class TokenbuddyDaemon {
|
|
|
380
731
|
sellerId: entry.sellerId,
|
|
381
732
|
healthScore: entry.healthScore,
|
|
382
733
|
avgLatencyMs: entry.avgLatencyMs,
|
|
383
|
-
|
|
384
|
-
|
|
734
|
+
ttftMs: entry.ttftMs,
|
|
735
|
+
avgInferenceMs: entry.avgInferenceMs,
|
|
736
|
+
circuit: entry.circuit,
|
|
737
|
+
capacityBlockedUntil: entry.capacityBlockedUntil
|
|
738
|
+
})),
|
|
739
|
+
now: Date.now()
|
|
385
740
|
});
|
|
386
741
|
if (planned.routes.length === 0) {
|
|
387
742
|
throw new Error(`no compatible seller for ${endpoint} model ${modelId}`);
|
|
@@ -408,7 +763,109 @@ export class TokenbuddyDaemon {
|
|
|
408
763
|
sellerCount: routes.length,
|
|
409
764
|
sellers: routes.map((route) => route.seller.id)
|
|
410
765
|
});
|
|
411
|
-
return routes;
|
|
766
|
+
return { routes, plan: planned, paymentMethod };
|
|
767
|
+
}
|
|
768
|
+
refreshSellerRoutingConfig() {
|
|
769
|
+
const storedRouting = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)
|
|
770
|
+
?.config;
|
|
771
|
+
const nextRouting = mergeSellerRoutingConfig(storedRouting, this.config.sellerRouting);
|
|
772
|
+
if (!sameSellerRouting(this.sellerRouting, nextRouting)) {
|
|
773
|
+
const previous = this.sellerRouting;
|
|
774
|
+
this.sellerRouting = nextRouting;
|
|
775
|
+
this.selectionMode = selectionModeForRouting(nextRouting);
|
|
776
|
+
this.selectedSellerId = selectedSellerIdForRouting(nextRouting);
|
|
777
|
+
logger.info("routing.config.reloaded", "seller routing config reloaded", {
|
|
778
|
+
previousMode: previous.mode,
|
|
779
|
+
previousScorer: previous.scorer,
|
|
780
|
+
sellerRoutingMode: nextRouting.mode,
|
|
781
|
+
sellerRoutingScorer: nextRouting.scorer,
|
|
782
|
+
selectedSellerId: this.selectedSellerId
|
|
783
|
+
});
|
|
784
|
+
void this.runRoutingPrewarmSweep(nextRouting);
|
|
785
|
+
}
|
|
786
|
+
return this.sellerRouting;
|
|
787
|
+
}
|
|
788
|
+
async runRoutingPrewarmSweep(routing) {
|
|
789
|
+
const focusSet = this.resolveFocusSet();
|
|
790
|
+
const routingPrewarmKey = `${routingKey(routing)}\u0001${focusSet.join("\u0001")}`;
|
|
791
|
+
if (focusSet.length === 0) {
|
|
792
|
+
logger.info("prewarm.routing.skipped", "no focus set configured after routing reload; relying on lazy prewarms", {
|
|
793
|
+
sellerRoutingMode: routing.mode,
|
|
794
|
+
sellerRoutingScorer: routing.scorer
|
|
795
|
+
});
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (this.lastRoutingPrewarmKey === routingPrewarmKey) {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
this.lastRoutingPrewarmKey = routingPrewarmKey;
|
|
802
|
+
logger.info("prewarm.routing.scheduled", "routing reload prewarm sweep scheduled", {
|
|
803
|
+
sellerRoutingMode: routing.mode,
|
|
804
|
+
sellerRoutingScorer: routing.scorer,
|
|
805
|
+
focusSetSize: focusSet.length,
|
|
806
|
+
focusSet: focusSet.slice(0, 20)
|
|
807
|
+
});
|
|
808
|
+
try {
|
|
809
|
+
await this.fetchRegistry();
|
|
810
|
+
const paymentMethod = this.defaultPaymentMethod();
|
|
811
|
+
for (const modelId of focusSet) {
|
|
812
|
+
this.schedulePrewarmForModel({
|
|
813
|
+
modelId,
|
|
814
|
+
reason: "explicit",
|
|
815
|
+
protocol: this.resolvePrewarmProtocol(modelId, paymentMethod),
|
|
816
|
+
paymentMethod
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
logger.warn("prewarm.routing.failed", "routing reload prewarm sweep failed", {
|
|
822
|
+
sellerRoutingMode: routing.mode,
|
|
823
|
+
sellerRoutingScorer: routing.scorer,
|
|
824
|
+
errorMessage: err instanceof Error ? err.message : String(err)
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod) {
|
|
829
|
+
const freshness = this.prewarmCache.freshness(modelId, protocol, paymentMethod);
|
|
830
|
+
if (freshness.present && !freshness.expired) {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const key = prewarmKey(modelId, protocol, paymentMethod);
|
|
834
|
+
if (this.lazyPrewarmKeys.has(key)) {
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
this.lazyPrewarmKeys.add(key);
|
|
838
|
+
logger.info("prewarm.lazy.scheduled", "lazy prewarm scheduled for requested model", {
|
|
839
|
+
modelId,
|
|
840
|
+
protocol,
|
|
841
|
+
paymentMethod,
|
|
842
|
+
freshnessState: freshness.state
|
|
843
|
+
});
|
|
844
|
+
this.schedulePrewarmForModel({
|
|
845
|
+
modelId,
|
|
846
|
+
reason: "lazy",
|
|
847
|
+
protocol,
|
|
848
|
+
paymentMethod
|
|
849
|
+
}).finally(() => {
|
|
850
|
+
this.lazyPrewarmKeys.delete(key);
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
schedulePrewarmForModel(input) {
|
|
854
|
+
if (!input.protocol || !input.paymentMethod) {
|
|
855
|
+
logger.warn("prewarm.schedule.skipped", "prewarm schedule skipped because protocol or payment method is missing", {
|
|
856
|
+
modelId: input.modelId,
|
|
857
|
+
reason: input.reason,
|
|
858
|
+
protocol: input.protocol,
|
|
859
|
+
paymentMethod: input.paymentMethod
|
|
860
|
+
});
|
|
861
|
+
return Promise.resolve();
|
|
862
|
+
}
|
|
863
|
+
return this.prewarmScheduler.schedulePrewarm({
|
|
864
|
+
modelId: input.modelId,
|
|
865
|
+
reason: input.reason,
|
|
866
|
+
protocol: input.protocol,
|
|
867
|
+
paymentMethod: input.paymentMethod
|
|
868
|
+
});
|
|
412
869
|
}
|
|
413
870
|
failoverErrorMessage(error) {
|
|
414
871
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -422,13 +879,16 @@ export class TokenbuddyDaemon {
|
|
|
422
879
|
* caller side because it short-circuits the failure path with a
|
|
423
880
|
* re-purchase.
|
|
424
881
|
*/
|
|
425
|
-
classifyFailureStatus(status) {
|
|
882
|
+
classifyFailureStatus(status, bodyText) {
|
|
426
883
|
if (status === 401 || status === 403) {
|
|
427
884
|
return "auth_invalid";
|
|
428
885
|
}
|
|
429
886
|
if (status === 402) {
|
|
430
887
|
return "insufficient_funds";
|
|
431
888
|
}
|
|
889
|
+
if (status === 429 && isBusyCapacityErrorBody(bodyText)) {
|
|
890
|
+
return "busy_capacity";
|
|
891
|
+
}
|
|
432
892
|
if (status === 400 || status === 404 || status === 422) {
|
|
433
893
|
return "hard_4xx";
|
|
434
894
|
}
|
|
@@ -561,7 +1021,7 @@ export class TokenbuddyDaemon {
|
|
|
561
1021
|
}
|
|
562
1022
|
return parseSellerSettlementObject(raw);
|
|
563
1023
|
}
|
|
564
|
-
recordReconciledInference(route, endpoint, requestId, usage, settlement, prompt, response) {
|
|
1024
|
+
recordReconciledInference(route, endpoint, requestId, usage, settlement, prompt, response, extras) {
|
|
565
1025
|
if (settlement) {
|
|
566
1026
|
this.tokenStore.reconcileTokenBalance({
|
|
567
1027
|
sellerKey: route.seller.id,
|
|
@@ -589,7 +1049,14 @@ export class TokenbuddyDaemon {
|
|
|
589
1049
|
balanceSnapshotMicros: settlement?.remainingCreditMicros,
|
|
590
1050
|
balanceSource: settlement ? "seller_authoritative" : "estimated",
|
|
591
1051
|
prompt,
|
|
592
|
-
response
|
|
1052
|
+
response,
|
|
1053
|
+
ttftMs: extras?.ttftMs,
|
|
1054
|
+
fallbackCount: extras?.fallbackCount,
|
|
1055
|
+
routeReason: extras?.routeReason,
|
|
1056
|
+
falloverChain: extras?.falloverChain,
|
|
1057
|
+
upstreamStatus: extras?.upstreamStatus,
|
|
1058
|
+
durationMs: extras?.durationMs,
|
|
1059
|
+
paymentMethod: extras?.paymentMethod
|
|
593
1060
|
});
|
|
594
1061
|
logger.info("inference.ledger.recorded", "safe inference ledger recorded", {
|
|
595
1062
|
requestId: settlement?.requestId || requestId,
|
|
@@ -604,7 +1071,14 @@ export class TokenbuddyDaemon {
|
|
|
604
1071
|
promptTokens: usage.promptTokens,
|
|
605
1072
|
completionTokens: usage.completionTokens,
|
|
606
1073
|
balanceSnapshotMicros: settlement?.remainingCreditMicros,
|
|
607
|
-
balanceSource: settlement ? "seller_authoritative" : "estimated"
|
|
1074
|
+
balanceSource: settlement ? "seller_authoritative" : "estimated",
|
|
1075
|
+
ttftMs: extras?.ttftMs,
|
|
1076
|
+
fallbackCount: extras?.fallbackCount,
|
|
1077
|
+
routeReason: extras?.routeReason,
|
|
1078
|
+
falloverChain: extras?.falloverChain,
|
|
1079
|
+
upstreamStatus: extras?.upstreamStatus,
|
|
1080
|
+
durationMs: extras?.durationMs,
|
|
1081
|
+
paymentMethod: extras?.paymentMethod
|
|
608
1082
|
});
|
|
609
1083
|
}
|
|
610
1084
|
async refreshSellerBalance(route, token, balanceSource) {
|
|
@@ -1074,6 +1548,11 @@ export class TokenbuddyDaemon {
|
|
|
1074
1548
|
}
|
|
1075
1549
|
async forwardProxyRequest(endpoint, req, res) {
|
|
1076
1550
|
const startedAt = Date.now();
|
|
1551
|
+
let firstByteAt = null;
|
|
1552
|
+
const markFirstByte = () => {
|
|
1553
|
+
if (firstByteAt === null)
|
|
1554
|
+
firstByteAt = Date.now();
|
|
1555
|
+
};
|
|
1077
1556
|
const body = req.body || {};
|
|
1078
1557
|
const { requestedModelId, resolvedModelId } = this.resolveRouteModelId(endpoint, body);
|
|
1079
1558
|
const modelId = resolvedModelId;
|
|
@@ -1084,13 +1563,20 @@ export class TokenbuddyDaemon {
|
|
|
1084
1563
|
return;
|
|
1085
1564
|
}
|
|
1086
1565
|
try {
|
|
1087
|
-
const routes = await this.selectSellerRoutes(endpoint, modelId);
|
|
1566
|
+
const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId);
|
|
1567
|
+
const upstreamStatusFromHeaders = (h) => {
|
|
1568
|
+
const raw = h.get("x-tokenbuddy-upstream-status") || h.get("x-upstream-status");
|
|
1569
|
+
if (!raw)
|
|
1570
|
+
return undefined;
|
|
1571
|
+
return raw === "healthy" || raw === "degraded" || raw === "unhealthy" || raw === "unknown" ? raw : "unknown";
|
|
1572
|
+
};
|
|
1088
1573
|
let lastError;
|
|
1089
1574
|
for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
|
|
1090
1575
|
const route = routes[routeIndex];
|
|
1091
1576
|
const sellerKey = route.seller.id;
|
|
1092
1577
|
logger.info("route.selected", "seller route selected", {
|
|
1093
1578
|
sellerKey,
|
|
1579
|
+
sellerId: sellerKey,
|
|
1094
1580
|
model: modelId,
|
|
1095
1581
|
endpoint,
|
|
1096
1582
|
protocol: route.protocol,
|
|
@@ -1247,7 +1733,7 @@ export class TokenbuddyDaemon {
|
|
|
1247
1733
|
status: upstreamResponse.status,
|
|
1248
1734
|
durationMs: Date.now() - startedAt
|
|
1249
1735
|
});
|
|
1250
|
-
const kind = this.classifyFailureStatus(upstreamResponse.status);
|
|
1736
|
+
const kind = this.classifyFailureStatus(upstreamResponse.status, errorBody);
|
|
1251
1737
|
const decision = this.routeFailover.decide({
|
|
1252
1738
|
sellerId: sellerKey,
|
|
1253
1739
|
status: upstreamResponse.status,
|
|
@@ -1317,6 +1803,7 @@ export class TokenbuddyDaemon {
|
|
|
1317
1803
|
// 缺 event: 行)由卖方修,buyer 不兜底。
|
|
1318
1804
|
const sellerChunk = settlementExtractor.push(chunk);
|
|
1319
1805
|
if (sellerChunk.length > 0) {
|
|
1806
|
+
markFirstByte();
|
|
1320
1807
|
res.write(sellerChunk);
|
|
1321
1808
|
}
|
|
1322
1809
|
}
|
|
@@ -1328,21 +1815,40 @@ export class TokenbuddyDaemon {
|
|
|
1328
1815
|
if (decoderTail.length > 0) {
|
|
1329
1816
|
const sellerTail = settlementExtractor.push(decoderTail);
|
|
1330
1817
|
if (sellerTail.length > 0) {
|
|
1818
|
+
markFirstByte();
|
|
1331
1819
|
res.write(sellerTail);
|
|
1332
1820
|
}
|
|
1333
1821
|
}
|
|
1334
1822
|
const settlementTrailing = settlementExtractor.finish();
|
|
1335
1823
|
if (settlementTrailing.downstream.length > 0) {
|
|
1824
|
+
markFirstByte();
|
|
1336
1825
|
res.write(settlementTrailing.downstream);
|
|
1337
1826
|
}
|
|
1338
1827
|
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)
|
|
1828
|
+
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, {
|
|
1829
|
+
ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
|
|
1830
|
+
fallbackCount: routeIndex,
|
|
1831
|
+
routeReason: plan.reason,
|
|
1832
|
+
falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
|
|
1833
|
+
upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
|
|
1834
|
+
durationMs: Date.now() - startedAt,
|
|
1835
|
+
paymentMethod
|
|
1836
|
+
});
|
|
1340
1837
|
return;
|
|
1341
1838
|
}
|
|
1342
1839
|
const responseBody = await upstreamResponse.text();
|
|
1840
|
+
markFirstByte();
|
|
1343
1841
|
res.send(responseBody);
|
|
1344
1842
|
const usage = this.readUsage(responseBody);
|
|
1345
|
-
this.recordReconciledInference(route, endpoint, requestId, usage, this.parseSellerSettlementSummary(upstreamResponse.headers), this.inferPromptForHash(body), responseBody
|
|
1843
|
+
this.recordReconciledInference(route, endpoint, requestId, usage, this.parseSellerSettlementSummary(upstreamResponse.headers), this.inferPromptForHash(body), responseBody, {
|
|
1844
|
+
ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
|
|
1845
|
+
fallbackCount: routeIndex,
|
|
1846
|
+
routeReason: plan.reason,
|
|
1847
|
+
falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
|
|
1848
|
+
upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
|
|
1849
|
+
durationMs: Date.now() - startedAt,
|
|
1850
|
+
paymentMethod
|
|
1851
|
+
});
|
|
1346
1852
|
return;
|
|
1347
1853
|
}
|
|
1348
1854
|
catch (routeError) {
|
|
@@ -1437,9 +1943,41 @@ export class TokenbuddyDaemon {
|
|
|
1437
1943
|
controlApp.get("/payments", (req, res) => {
|
|
1438
1944
|
logger.info("control.payments.requested", "control payments requested", {});
|
|
1439
1945
|
res.status(200).json({
|
|
1440
|
-
payments: this.tokenStore.listPayments()
|
|
1946
|
+
payments: this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir))
|
|
1441
1947
|
});
|
|
1442
1948
|
});
|
|
1949
|
+
controlApp.post("/payments/clawtip/activate", async (req, res) => {
|
|
1950
|
+
try {
|
|
1951
|
+
const qr = await this.startClawtipActivationQr();
|
|
1952
|
+
logger.info("control.payment.clawtip.activate_qr.created", "ClawTip activation QR copied for tb-ui", {
|
|
1953
|
+
orderNo: qr.orderNo,
|
|
1954
|
+
qrImageUrl: qr.qrImageUrl,
|
|
1955
|
+
walletConfigPresent: qr.walletConfigPresent
|
|
1956
|
+
});
|
|
1957
|
+
res.status(200).json(qr);
|
|
1958
|
+
}
|
|
1959
|
+
catch (error) {
|
|
1960
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1961
|
+
logger.warn("control.payment.clawtip.activate_qr.failed", "ClawTip activation QR failed", { errorMessage });
|
|
1962
|
+
res.status(500).json({ error: { code: "clawtip_activate_qr_failed", message: errorMessage } });
|
|
1963
|
+
}
|
|
1964
|
+
});
|
|
1965
|
+
controlApp.post("/payments/clawtip/recharge", (req, res) => {
|
|
1966
|
+
try {
|
|
1967
|
+
const qr = this.clawtipRechargeQr();
|
|
1968
|
+
logger.info("control.payment.clawtip.recharge_qr.created", "ClawTip fixed recharge QR served for tb-ui", {
|
|
1969
|
+
orderNo: qr.orderNo,
|
|
1970
|
+
qrImageUrl: qr.qrImageUrl,
|
|
1971
|
+
walletConfigPresent: qr.walletConfigPresent
|
|
1972
|
+
});
|
|
1973
|
+
res.status(200).json(qr);
|
|
1974
|
+
}
|
|
1975
|
+
catch (error) {
|
|
1976
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1977
|
+
logger.warn("control.payment.clawtip.recharge_qr.failed", "ClawTip recharge QR failed", { errorMessage });
|
|
1978
|
+
res.status(500).json({ error: { code: "clawtip_recharge_qr_failed", message: errorMessage } });
|
|
1979
|
+
}
|
|
1980
|
+
});
|
|
1443
1981
|
controlApp.get("/ledger/purchases", (req, res) => {
|
|
1444
1982
|
logger.info("control.ledger.requested", "control purchase ledger requested", {
|
|
1445
1983
|
ledger: "purchases"
|
|
@@ -1578,6 +2116,41 @@ export class TokenbuddyDaemon {
|
|
|
1578
2116
|
});
|
|
1579
2117
|
}
|
|
1580
2118
|
});
|
|
2119
|
+
controlApp.get("/providers/status", (_req, res) => {
|
|
2120
|
+
try {
|
|
2121
|
+
const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
|
|
2122
|
+
const clients = [
|
|
2123
|
+
...providerStatuses,
|
|
2124
|
+
buildCustomClientToolStatus(this.activeProxyPort()),
|
|
2125
|
+
];
|
|
2126
|
+
const configuredCount = clients.filter((client) => client.configured).length;
|
|
2127
|
+
const detectedCount = clients.filter((client) => client.detected && client.status !== "manual").length;
|
|
2128
|
+
logger.info("provider.status.requested", "provider status requested", {
|
|
2129
|
+
clientCount: clients.length,
|
|
2130
|
+
configuredCount,
|
|
2131
|
+
detectedCount
|
|
2132
|
+
});
|
|
2133
|
+
res.status(200).json({
|
|
2134
|
+
clients,
|
|
2135
|
+
summary: {
|
|
2136
|
+
configuredCount,
|
|
2137
|
+
detectedCount,
|
|
2138
|
+
totalCount: clients.length,
|
|
2139
|
+
installCommand: "tb init"
|
|
2140
|
+
}
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
catch (error) {
|
|
2144
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2145
|
+
logger.warn("provider.status.failed", "provider status failed", { errorMessage });
|
|
2146
|
+
res.status(400).json({
|
|
2147
|
+
error: {
|
|
2148
|
+
code: "provider_status_failed",
|
|
2149
|
+
message: errorMessage
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
});
|
|
1581
2154
|
controlApp.post("/providers/install/preview", (req, res) => {
|
|
1582
2155
|
try {
|
|
1583
2156
|
const changes = previewProviderInstall({
|
|
@@ -1655,6 +2228,169 @@ export class TokenbuddyDaemon {
|
|
|
1655
2228
|
});
|
|
1656
2229
|
}
|
|
1657
2230
|
});
|
|
2231
|
+
// ─────────────────────────────────────────────────────────────────
|
|
2232
|
+
// tb-ui v1: 控制平面写端点(PR-0)
|
|
2233
|
+
// 所有端点都热生效,不需重启 daemon。改完 store 即下次推理生效。
|
|
2234
|
+
// ─────────────────────────────────────────────────────────────────
|
|
2235
|
+
// 1) GET /routing/strategy — 读当前路由策略 + 来源
|
|
2236
|
+
controlApp.get("/routing/strategy", (req, res) => {
|
|
2237
|
+
try {
|
|
2238
|
+
const stored = this.tokenStore.getDaemonRuntimeConfig(ROUTING_CONFIG_KEY)?.config;
|
|
2239
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2240
|
+
const source = stored !== undefined ? "store" : this.config.sellerRouting ? "config" : "default";
|
|
2241
|
+
logger.info("routing.strategy.read", "routing strategy read", {
|
|
2242
|
+
source,
|
|
2243
|
+
mode: current.mode,
|
|
2244
|
+
scorer: current.scorer
|
|
2245
|
+
});
|
|
2246
|
+
res.status(200).json({ strategy: current, source });
|
|
2247
|
+
}
|
|
2248
|
+
catch (error) {
|
|
2249
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2250
|
+
logger.warn("routing.strategy.read_failed", "routing strategy read failed", { errorMessage });
|
|
2251
|
+
res.status(500).json({ error: { code: "routing_strategy_read_failed", message: errorMessage } });
|
|
2252
|
+
}
|
|
2253
|
+
});
|
|
2254
|
+
// 2) GET /routing/preview — 算「假如改完会怎样」,不改 state
|
|
2255
|
+
// query: modelId? protocol? paymentMethod? mode? scorer? sellerId? sellerIds?(逗号分隔)
|
|
2256
|
+
controlApp.get("/routing/preview", (req, res) => {
|
|
2257
|
+
try {
|
|
2258
|
+
const override = buildRoutingConfigFromQuery(req.query);
|
|
2259
|
+
const result = this.buildRoutingPreview({
|
|
2260
|
+
modelId: typeof req.query.modelId === "string" ? req.query.modelId : undefined,
|
|
2261
|
+
protocol: typeof req.query.protocol === "string" ? req.query.protocol : undefined,
|
|
2262
|
+
paymentMethod: typeof req.query.paymentMethod === "string" ? req.query.paymentMethod : undefined,
|
|
2263
|
+
routing: override ?? undefined
|
|
2264
|
+
});
|
|
2265
|
+
if ("error" in result.plan) {
|
|
2266
|
+
res.status(409).json({
|
|
2267
|
+
error: { code: result.plan.error, message: `cannot preview routing: ${result.plan.error}` },
|
|
2268
|
+
modelId: result.modelId,
|
|
2269
|
+
protocol: result.protocol,
|
|
2270
|
+
paymentMethod: result.paymentMethod
|
|
2271
|
+
});
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
res.status(200).json({
|
|
2275
|
+
modelId: result.modelId,
|
|
2276
|
+
protocol: result.protocol,
|
|
2277
|
+
paymentMethod: result.paymentMethod,
|
|
2278
|
+
plan: result.plan
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
catch (error) {
|
|
2282
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2283
|
+
logger.warn("routing.preview.failed", "routing preview failed", { errorMessage });
|
|
2284
|
+
res.status(400).json({ error: { code: "routing_preview_failed", message: errorMessage } });
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
// 3) PUT /routing/strategy — 写策略 + 热更新 + 返回 preview
|
|
2288
|
+
controlApp.put("/routing/strategy", (req, res) => {
|
|
2289
|
+
try {
|
|
2290
|
+
const body = (req.body ?? {});
|
|
2291
|
+
const normalized = normalizeSellerRoutingConfig(body);
|
|
2292
|
+
// 必填字段再次校验(normalize 会回退 default,但 PUT 必须显式)
|
|
2293
|
+
assertSellerRoutingConfig(normalized);
|
|
2294
|
+
this.tokenStore.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, normalized);
|
|
2295
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2296
|
+
logger.info("routing.strategy.applied", "routing strategy applied", {
|
|
2297
|
+
mode: current.mode,
|
|
2298
|
+
scorer: current.scorer,
|
|
2299
|
+
sellerId: current.sellerId,
|
|
2300
|
+
sellerIds: current.sellerIds
|
|
2301
|
+
});
|
|
2302
|
+
const preview = this.buildRoutingPreview({ routing: current });
|
|
2303
|
+
const previewPayload = "error" in preview.plan
|
|
2304
|
+
? { error: preview.plan.error }
|
|
2305
|
+
: {
|
|
2306
|
+
modelId: preview.modelId,
|
|
2307
|
+
protocol: preview.protocol,
|
|
2308
|
+
paymentMethod: preview.paymentMethod,
|
|
2309
|
+
...preview.plan
|
|
2310
|
+
};
|
|
2311
|
+
res.status(200).json({ applied: true, strategy: current, preview: previewPayload });
|
|
2312
|
+
}
|
|
2313
|
+
catch (error) {
|
|
2314
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2315
|
+
logger.warn("routing.strategy.apply_failed", "routing strategy apply failed", { errorMessage });
|
|
2316
|
+
res.status(400).json({ error: { code: "routing_strategy_apply_failed", message: errorMessage } });
|
|
2317
|
+
}
|
|
2318
|
+
});
|
|
2319
|
+
// 4) PUT /prewarm/focus-set — 设置 explicit focus set(覆盖 config.warmupModels / env)
|
|
2320
|
+
// body: { models: ["claude-3-5-sonnet", "gpt-4o"], clear?: false }
|
|
2321
|
+
// clear=true 时 models 数组可省略;表示回退 env / historical
|
|
2322
|
+
controlApp.put("/prewarm/focus-set", (req, res) => {
|
|
2323
|
+
try {
|
|
2324
|
+
const body = (req.body ?? {});
|
|
2325
|
+
const clear = body.clear === true;
|
|
2326
|
+
if (clear) {
|
|
2327
|
+
const result = this.applyFocusSet(null);
|
|
2328
|
+
res.status(200).json({ ok: true, ...result });
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
if (!Array.isArray(body.models)) {
|
|
2332
|
+
res.status(400).json({
|
|
2333
|
+
error: { code: "invalid_focus_set", message: "focus-set body must have a string[] `models` field, or `clear: true`" }
|
|
2334
|
+
});
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
const models = body.models
|
|
2338
|
+
.filter((m) => typeof m === "string")
|
|
2339
|
+
.map((m) => m.trim())
|
|
2340
|
+
.filter(Boolean);
|
|
2341
|
+
const result = this.applyFocusSet(models);
|
|
2342
|
+
res.status(200).json({ ok: true, ...result });
|
|
2343
|
+
}
|
|
2344
|
+
catch (error) {
|
|
2345
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2346
|
+
logger.warn("focus_set.apply_failed", "focus set apply failed", { errorMessage });
|
|
2347
|
+
res.status(400).json({ error: { code: "focus_set_apply_failed", message: errorMessage } });
|
|
2348
|
+
}
|
|
2349
|
+
});
|
|
2350
|
+
// 5) POST /daemon/restart — 优雅重启 tb-proxyd(调子进程 `tb daemon restart`)
|
|
2351
|
+
// 现有 CLI 子命令(cli.ts:1129)。detached 模式让子进程独立于 daemon 生命周期。
|
|
2352
|
+
controlApp.post("/daemon/restart", (req, res) => {
|
|
2353
|
+
try {
|
|
2354
|
+
const child = spawn("tb", ["daemon", "restart"], {
|
|
2355
|
+
detached: true,
|
|
2356
|
+
stdio: "ignore"
|
|
2357
|
+
});
|
|
2358
|
+
child.unref();
|
|
2359
|
+
logger.info("daemon.restart.scheduled", "daemon restart scheduled via tb CLI");
|
|
2360
|
+
res.status(202).json({ ok: true, message: "restart scheduled" });
|
|
2361
|
+
}
|
|
2362
|
+
catch (error) {
|
|
2363
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2364
|
+
logger.warn("daemon.restart.failed", "daemon restart failed", { errorMessage });
|
|
2365
|
+
res.status(500).json({ error: { code: "daemon_restart_failed", message: errorMessage } });
|
|
2366
|
+
}
|
|
2367
|
+
});
|
|
2368
|
+
this.ensureClawtipStaticAssets();
|
|
2369
|
+
controlApp.use(CLAWTIP_STATIC_ROUTE, express.static(this.clawtipStaticDir(), { index: false, fallthrough: false }));
|
|
2370
|
+
// ────────────────────────────────────────────────────────────
|
|
2371
|
+
// tb-ui v1: 静态托管 SPA(dist 由 `npm run build --workspace tb-ui` 生成)
|
|
2372
|
+
// 必须在所有 API 路由**之后**才挂载,这样:
|
|
2373
|
+
// - `/health` `/ledger/purchases` 等 API 路径仍由上面 17+ 个端点处理
|
|
2374
|
+
// - 真实静态文件(`/index.html` `/assets/index-abc.js` 等)由 express.static 服务
|
|
2375
|
+
// - 未匹配路径(`/overview` `/routing` 等 React Router 路径)走 SPA fallback 回 index.html
|
|
2376
|
+
// ────────────────────────────────────────────────────────────
|
|
2377
|
+
const uiDir = resolveUiDir();
|
|
2378
|
+
if (uiDir && fs.existsSync(uiDir) && fs.existsSync(path.join(uiDir, "index.html"))) {
|
|
2379
|
+
controlApp.use(express.static(uiDir, { index: "index.html", fallthrough: true }));
|
|
2380
|
+
// SPA fallback: React Router 用 BrowserRouter(history mode),客户端路径如
|
|
2381
|
+
// /overview /routing /ledger 不对应任何文件,必须回 index.html 让 React Router 接管。
|
|
2382
|
+
// Express 已经按注册顺序匹配了所有 17+ 个 API 路由,这里只接住"什么都没匹配"的 GET。
|
|
2383
|
+
controlApp.get(/^(?!\/(?:health|status|payments|sellers|models|ledger|routing|prewarm|v1\.2|daemon|providers|static)\/).*/, (_req, res) => {
|
|
2384
|
+
res.sendFile(path.join(uiDir, "index.html"));
|
|
2385
|
+
});
|
|
2386
|
+
logger.info("ui.static.attached", "tb-ui dist attached to control plane SPA fallback", { uiDir });
|
|
2387
|
+
}
|
|
2388
|
+
else {
|
|
2389
|
+
logger.warn("ui.static.missing", "tb-ui dist not found; only control plane API will be served", {
|
|
2390
|
+
uiDir,
|
|
2391
|
+
hint: "run `npm run build --workspace tb-ui` then restart"
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
1658
2394
|
this.controlServer = controlApp.listen(this.config.controlPort);
|
|
1659
2395
|
// 2. Proxy Plane Server (17821)
|
|
1660
2396
|
const proxyApp = express();
|
|
@@ -1717,9 +2453,13 @@ export class TokenbuddyDaemon {
|
|
|
1717
2453
|
/**
|
|
1718
2454
|
* v1.2 §18.4: build the focus set from the explicit config, the env
|
|
1719
2455
|
* override, and the historical usage in the buyer store. The order of
|
|
1720
|
-
* precedence: explicit
|
|
2456
|
+
* precedence: explicit `currentFocusSet` (set via `PUT /prewarm/focus-set`)
|
|
2457
|
+
* > explicit config > env > historical > empty.
|
|
1721
2458
|
*/
|
|
1722
2459
|
resolveFocusSet() {
|
|
2460
|
+
if (this.currentFocusSet !== null) {
|
|
2461
|
+
return this.currentFocusSet;
|
|
2462
|
+
}
|
|
1723
2463
|
const explicit = this.config.warmupModels ?? [];
|
|
1724
2464
|
if (explicit.length > 0) {
|
|
1725
2465
|
return explicit;
|
|
@@ -1731,6 +2471,86 @@ export class TokenbuddyDaemon {
|
|
|
1731
2471
|
}
|
|
1732
2472
|
return this.tokenStore.recentModels(7, 5);
|
|
1733
2473
|
}
|
|
2474
|
+
/**
|
|
2475
|
+
* tb-ui v1 `PUT /prewarm/focus-set` 调用的统一入口。`models === null`
|
|
2476
|
+
* 表示「清除 explicit focus set,回退 env/historical」。
|
|
2477
|
+
* 写 store + 触发 `runRoutingPrewarmSweep`,**热生效**(不需重启 daemon)。
|
|
2478
|
+
*/
|
|
2479
|
+
applyFocusSet(models) {
|
|
2480
|
+
if (models === null) {
|
|
2481
|
+
this.tokenStore.removeDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY);
|
|
2482
|
+
this.currentFocusSet = null;
|
|
2483
|
+
}
|
|
2484
|
+
else {
|
|
2485
|
+
const deduped = Array.from(new Set(models.map((m) => m.trim()).filter(Boolean)));
|
|
2486
|
+
this.tokenStore.saveDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY, { models: deduped });
|
|
2487
|
+
this.currentFocusSet = deduped;
|
|
2488
|
+
}
|
|
2489
|
+
// 触发路由重读 + 预热 sweep(如果当前 routing 已是焦点集合的依赖)。
|
|
2490
|
+
this.refreshSellerRoutingConfig();
|
|
2491
|
+
const focusSet = this.resolveFocusSet();
|
|
2492
|
+
const source = this.currentFocusSet !== null
|
|
2493
|
+
? "explicit"
|
|
2494
|
+
: (this.config.warmupModels?.length ?? 0) > 0
|
|
2495
|
+
? "explicit"
|
|
2496
|
+
: (process.env.TB_BUYER_WARMUP_MODELS?.trim() ?? "").length > 0
|
|
2497
|
+
? "env"
|
|
2498
|
+
: focusSet.length > 0
|
|
2499
|
+
? "historical"
|
|
2500
|
+
: "empty";
|
|
2501
|
+
logger.info("focus_set.applied", "explicit focus set applied", {
|
|
2502
|
+
source,
|
|
2503
|
+
focusSetSize: focusSet.length,
|
|
2504
|
+
focusSet: focusSet.slice(0, 20)
|
|
2505
|
+
});
|
|
2506
|
+
return { focusSet, source };
|
|
2507
|
+
}
|
|
2508
|
+
/**
|
|
2509
|
+
* tb-ui v1 `GET /routing/preview` 和 `PUT /routing/strategy` 复用的 preview 计算。
|
|
2510
|
+
* 接受任意 routing 覆盖(来自 request body)算「假如改成这个,路由会是啥」。
|
|
2511
|
+
* 不修改任何内部 state,**纯函数式**。
|
|
2512
|
+
*/
|
|
2513
|
+
buildRoutingPreview(input) {
|
|
2514
|
+
const registry = this.lastRegistrySnapshot;
|
|
2515
|
+
const focusFirst = this.resolveFocusSet()[0];
|
|
2516
|
+
const registryFirst = registry?.sellers[0]?.models?.[0];
|
|
2517
|
+
const modelId = input.modelId?.trim() || focusFirst || registryFirst || "";
|
|
2518
|
+
const protocol = input.protocol?.trim() || "chat_completions";
|
|
2519
|
+
const paymentMethod = input.paymentMethod?.trim() || this.defaultPaymentMethod() || "clawtip";
|
|
2520
|
+
if (!modelId) {
|
|
2521
|
+
return { modelId, protocol, paymentMethod, plan: { error: "no_focus_model_available" } };
|
|
2522
|
+
}
|
|
2523
|
+
if (!registry) {
|
|
2524
|
+
return { modelId, protocol, paymentMethod, plan: { error: "registry_not_loaded" } };
|
|
2525
|
+
}
|
|
2526
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2527
|
+
const routing = input.routing
|
|
2528
|
+
? mergeSellerRoutingConfig(current, input.routing)
|
|
2529
|
+
: current;
|
|
2530
|
+
const resolvedRouting = resolveSellerRoutingForModel(routing, modelId);
|
|
2531
|
+
const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
|
|
2532
|
+
this.sellerPool.ensureRegistrySellers(registrySellers);
|
|
2533
|
+
const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
|
|
2534
|
+
const plan = planSellerRouteSet({
|
|
2535
|
+
modelId,
|
|
2536
|
+
protocol,
|
|
2537
|
+
paymentMethod,
|
|
2538
|
+
registrySellers,
|
|
2539
|
+
routing: resolvedRouting,
|
|
2540
|
+
prewarmCandidates: this.prewarmCache.get(modelId, protocol, paymentMethod)?.candidates,
|
|
2541
|
+
sellerMetrics: Array.from(poolById.values()).map((entry) => ({
|
|
2542
|
+
sellerId: entry.sellerId,
|
|
2543
|
+
healthScore: entry.healthScore,
|
|
2544
|
+
avgLatencyMs: entry.avgLatencyMs,
|
|
2545
|
+
ttftMs: entry.ttftMs,
|
|
2546
|
+
avgInferenceMs: entry.avgInferenceMs,
|
|
2547
|
+
circuit: entry.circuit,
|
|
2548
|
+
capacityBlockedUntil: entry.capacityBlockedUntil
|
|
2549
|
+
})),
|
|
2550
|
+
now: Date.now()
|
|
2551
|
+
});
|
|
2552
|
+
return { modelId, protocol, paymentMethod, plan };
|
|
2553
|
+
}
|
|
1734
2554
|
async runStartupPrewarmSweep() {
|
|
1735
2555
|
const focusSet = this.resolveFocusSet();
|
|
1736
2556
|
if (focusSet.length === 0) {
|
|
@@ -1745,7 +2565,7 @@ export class TokenbuddyDaemon {
|
|
|
1745
2565
|
await this.fetchRegistry();
|
|
1746
2566
|
await this.prewarmScheduler.runStartupPrewarm(focusSet.map((modelId) => ({
|
|
1747
2567
|
modelId,
|
|
1748
|
-
protocol: this.resolvePrewarmProtocol(modelId)
|
|
2568
|
+
protocol: this.resolvePrewarmProtocol(modelId, this.defaultPaymentMethod())
|
|
1749
2569
|
})));
|
|
1750
2570
|
}
|
|
1751
2571
|
catch (err) {
|
|
@@ -1754,15 +2574,18 @@ export class TokenbuddyDaemon {
|
|
|
1754
2574
|
});
|
|
1755
2575
|
}
|
|
1756
2576
|
}
|
|
1757
|
-
resolvePrewarmProtocol(modelId) {
|
|
2577
|
+
resolvePrewarmProtocol(modelId, paymentMethod = "clawtip") {
|
|
1758
2578
|
for (const protocol of ["chat_completions", "messages", "responses"]) {
|
|
1759
|
-
if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod
|
|
2579
|
+
if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod }).length > 0) {
|
|
1760
2580
|
return protocol;
|
|
1761
2581
|
}
|
|
1762
2582
|
}
|
|
1763
2583
|
return undefined;
|
|
1764
2584
|
}
|
|
1765
2585
|
stop() {
|
|
2586
|
+
if (this.clawtipActivationWaitCancelToken) {
|
|
2587
|
+
this.clawtipActivationWaitCancelToken.cancelled = true;
|
|
2588
|
+
}
|
|
1766
2589
|
if (this.controlServer)
|
|
1767
2590
|
this.controlServer.close();
|
|
1768
2591
|
if (this.proxyServer)
|
|
@@ -1770,5 +2593,166 @@ export class TokenbuddyDaemon {
|
|
|
1770
2593
|
void this.prewarmScheduler.stop();
|
|
1771
2594
|
this.tokenStore.close();
|
|
1772
2595
|
}
|
|
2596
|
+
/**
|
|
2597
|
+
* @internal — test-only seam to inject a registry snapshot without
|
|
2598
|
+
* hitting the network. Used by `tests/control-plane-ui-endpoints.test.ts`
|
|
2599
|
+
* to drive `buildRoutingPreview` deterministically. Production code
|
|
2600
|
+
* must NOT call this; the real `fetchRegistry()` populates the snapshot.
|
|
2601
|
+
*/
|
|
2602
|
+
setLastRegistrySnapshotForTest(snapshot) {
|
|
2603
|
+
this.lastRegistrySnapshot = snapshot;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
function selectionModeForRouting(routing) {
|
|
2607
|
+
return routing.mode === "fullAuto" ? "auto" : "manual";
|
|
2608
|
+
}
|
|
2609
|
+
function withLiveClawtipWalletState(payment, home) {
|
|
2610
|
+
if (payment.method !== "clawtip") {
|
|
2611
|
+
return payment;
|
|
2612
|
+
}
|
|
2613
|
+
const walletConfig = inspectOpenClawWalletConfig(home);
|
|
2614
|
+
return {
|
|
2615
|
+
...payment,
|
|
2616
|
+
enabled: payment.enabled && walletConfig.exists,
|
|
2617
|
+
config: {
|
|
2618
|
+
...(payment.config ?? {}),
|
|
2619
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
2620
|
+
walletConfigPresent: walletConfig.exists,
|
|
2621
|
+
nearbyWalletConfigPaths: walletConfig.alternatePaths
|
|
2622
|
+
}
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
function normalizeClawtipActivationPayment(bootstrap) {
|
|
2626
|
+
if (!bootstrap.payment?.orderNo || !bootstrap.payment.indicator || !bootstrap.payment.resourceUrl) {
|
|
2627
|
+
throw new Error("ClawTip bootstrap response missing orderNo, indicator, or resourceUrl.");
|
|
2628
|
+
}
|
|
2629
|
+
return {
|
|
2630
|
+
orderNo: bootstrap.payment.orderNo,
|
|
2631
|
+
amountFen: bootstrap.payment.amountFen ?? bootstrap.activationFeeFen ?? 1,
|
|
2632
|
+
payTo: bootstrap.payment.payTo,
|
|
2633
|
+
encryptedData: bootstrap.payment.encryptedData,
|
|
2634
|
+
indicator: bootstrap.payment.indicator,
|
|
2635
|
+
slug: bootstrap.payment.slug,
|
|
2636
|
+
skillId: bootstrap.payment.skillId,
|
|
2637
|
+
description: bootstrap.payment.description,
|
|
2638
|
+
resourceUrl: bootstrap.payment.resourceUrl,
|
|
2639
|
+
};
|
|
2640
|
+
}
|
|
2641
|
+
function safeQrExtension(filePath) {
|
|
2642
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
2643
|
+
if (extension === ".jpg" || extension === ".jpeg" || extension === ".webp") {
|
|
2644
|
+
return extension;
|
|
2645
|
+
}
|
|
2646
|
+
return ".png";
|
|
2647
|
+
}
|
|
2648
|
+
function safeStaticFileSegment(value) {
|
|
2649
|
+
return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "clawtip";
|
|
2650
|
+
}
|
|
2651
|
+
function readConfigString(config, key) {
|
|
2652
|
+
const value = config?.[key];
|
|
2653
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
2654
|
+
}
|
|
2655
|
+
function readConfigBoolean(config, key) {
|
|
2656
|
+
return config?.[key] === true;
|
|
2657
|
+
}
|
|
2658
|
+
function selectedSellerIdForRouting(routing) {
|
|
2659
|
+
return routing.mode === "fixed" ? routing.sellerId : undefined;
|
|
2660
|
+
}
|
|
2661
|
+
function routingKey(routing) {
|
|
2662
|
+
const fixedByModel = Object.entries(routing.fixedByModel ?? {})
|
|
2663
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
2664
|
+
.map(([modelId, sellerId]) => `${modelId}:${sellerId}`);
|
|
2665
|
+
return [
|
|
2666
|
+
routing.mode,
|
|
2667
|
+
routing.scorer,
|
|
2668
|
+
routing.sellerId ?? "",
|
|
2669
|
+
...(routing.sellerIds ?? []),
|
|
2670
|
+
...fixedByModel
|
|
2671
|
+
].join("\u0001");
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* 从 query string 构造 `BuyerSellerRoutingConfig` override(用于 GET /routing/preview)。
|
|
2675
|
+
* 接受 `mode` / `scorer` / `sellerId` / `sellerIds`(逗号分隔)。
|
|
2676
|
+
* 任一字段缺失返回 `undefined`,调用方走「用当前 routing」分支。
|
|
2677
|
+
* mode / scorer 非法抛 400,由端点 handler 捕获。
|
|
2678
|
+
*/
|
|
2679
|
+
function buildRoutingConfigFromQuery(query) {
|
|
2680
|
+
const mode = typeof query.mode === "string" ? query.mode.trim() : "";
|
|
2681
|
+
const scorer = typeof query.scorer === "string" ? query.scorer.trim() : "";
|
|
2682
|
+
const sellerId = typeof query.sellerId === "string" ? query.sellerId.trim() : "";
|
|
2683
|
+
const sellerIdsRaw = typeof query.sellerIds === "string" ? query.sellerIds.trim() : "";
|
|
2684
|
+
const fixedByModelRaw = typeof query.fixedByModel === "string" ? query.fixedByModel.trim() : "";
|
|
2685
|
+
if (!mode && !scorer && !sellerId && !sellerIdsRaw && !fixedByModelRaw) {
|
|
2686
|
+
return undefined;
|
|
2687
|
+
}
|
|
2688
|
+
const override = {};
|
|
2689
|
+
if (mode) {
|
|
2690
|
+
if (mode !== "fixed" && mode !== "fixedSet" && mode !== "fullAuto") {
|
|
2691
|
+
throw new Error("mode must be fixed, fixedSet, or fullAuto");
|
|
2692
|
+
}
|
|
2693
|
+
override.mode = mode;
|
|
2694
|
+
}
|
|
2695
|
+
if (scorer) {
|
|
2696
|
+
if (scorer !== "speed" && scorer !== "discount" && scorer !== "balanced") {
|
|
2697
|
+
throw new Error("scorer must be speed, discount, or balanced");
|
|
2698
|
+
}
|
|
2699
|
+
override.scorer = scorer;
|
|
2700
|
+
}
|
|
2701
|
+
if (sellerId) {
|
|
2702
|
+
override.sellerId = sellerId;
|
|
2703
|
+
}
|
|
2704
|
+
if (sellerIdsRaw) {
|
|
2705
|
+
override.sellerIds = parseSellerIdList(sellerIdsRaw);
|
|
2706
|
+
}
|
|
2707
|
+
if (fixedByModelRaw) {
|
|
2708
|
+
override.fixedByModel = parseFixedByModel(fixedByModelRaw);
|
|
2709
|
+
}
|
|
2710
|
+
return override;
|
|
2711
|
+
}
|
|
2712
|
+
function sameSellerRouting(a, b) {
|
|
2713
|
+
return a.mode === b.mode
|
|
2714
|
+
&& a.scorer === b.scorer
|
|
2715
|
+
&& optionalStringEqual(a.sellerId, b.sellerId)
|
|
2716
|
+
&& stringArraysEqual(a.sellerIds ?? [], b.sellerIds ?? [])
|
|
2717
|
+
&& fixedByModelEqual(a.fixedByModel ?? {}, b.fixedByModel ?? {});
|
|
2718
|
+
}
|
|
2719
|
+
function optionalStringEqual(a, b) {
|
|
2720
|
+
return (a ?? "") === (b ?? "");
|
|
2721
|
+
}
|
|
2722
|
+
function stringArraysEqual(a, b) {
|
|
2723
|
+
if (a.length !== b.length) {
|
|
2724
|
+
return false;
|
|
2725
|
+
}
|
|
2726
|
+
return a.every((entry, index) => entry === b[index]);
|
|
2727
|
+
}
|
|
2728
|
+
function resolveSellerRoutingForModel(routing, modelId) {
|
|
2729
|
+
if (routing.mode !== "fixed") {
|
|
2730
|
+
return routing;
|
|
2731
|
+
}
|
|
2732
|
+
const fixedSellerId = routing.fixedByModel?.[modelId]?.trim() || routing.sellerId;
|
|
2733
|
+
return {
|
|
2734
|
+
mode: "fixed",
|
|
2735
|
+
scorer: routing.scorer,
|
|
2736
|
+
sellerId: fixedSellerId,
|
|
2737
|
+
fixedByModel: routing.fixedByModel
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
function parseFixedByModel(value) {
|
|
2741
|
+
const entries = value
|
|
2742
|
+
.split(",")
|
|
2743
|
+
.map((entry) => entry.split(":"))
|
|
2744
|
+
.filter((parts) => parts.length === 2)
|
|
2745
|
+
.map(([modelId, sellerId]) => [modelId.trim(), sellerId.trim()])
|
|
2746
|
+
.filter(([modelId, sellerId]) => modelId.length > 0 && sellerId.length > 0);
|
|
2747
|
+
return Object.fromEntries(entries);
|
|
2748
|
+
}
|
|
2749
|
+
function fixedByModelEqual(a, b) {
|
|
2750
|
+
const aEntries = Object.entries(a).sort(([left], [right]) => left.localeCompare(right));
|
|
2751
|
+
const bEntries = Object.entries(b).sort(([left], [right]) => left.localeCompare(right));
|
|
2752
|
+
return aEntries.length === bEntries.length
|
|
2753
|
+
&& aEntries.every(([modelId, sellerId], index) => {
|
|
2754
|
+
const [otherModelId, otherSellerId] = bEntries[index] ?? [];
|
|
2755
|
+
return modelId === otherModelId && sellerId === otherSellerId;
|
|
2756
|
+
});
|
|
1773
2757
|
}
|
|
1774
2758
|
//# sourceMappingURL=daemon.js.map
|