@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/src/daemon.ts
CHANGED
|
@@ -2,15 +2,30 @@ import express, { Request, Response } 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";
|
|
5
6
|
import { AddressInfo } from "net";
|
|
7
|
+
import { ErrorCode } from "@tokenbuddy/contracts";
|
|
6
8
|
import { createModuleLogger } from "@tokenbuddy/logging";
|
|
7
|
-
import { BuyerStore } from "./buyer-store.js";
|
|
9
|
+
import { BuyerStore, type PaymentConfig } from "./buyer-store.js";
|
|
10
|
+
import { fetchClawtipBootstrap } from "./clawtip-bootstrap.js";
|
|
11
|
+
import type { ClawtipBootstrapResponse } from "./clawtip-bootstrap.js";
|
|
12
|
+
import {
|
|
13
|
+
inspectOpenClawWalletConfig,
|
|
14
|
+
} from "./init-payment-options.js";
|
|
15
|
+
import {
|
|
16
|
+
startClawtipWalletBootstrap,
|
|
17
|
+
waitForClawtipActivationConfirmation,
|
|
18
|
+
type ClawtipBootstrapPayment,
|
|
19
|
+
type ParsedClawtipOutput,
|
|
20
|
+
type WaitForClawtipActivationOptions,
|
|
21
|
+
} from "./init-clawtip-activation.js";
|
|
8
22
|
import {
|
|
9
23
|
applyProviderInstall,
|
|
10
24
|
detectProviders,
|
|
11
25
|
previewProviderInstall,
|
|
12
26
|
rollbackProviderInstall,
|
|
13
27
|
type ClaudeCodeModelMappingConfig,
|
|
28
|
+
type ProviderCandidate,
|
|
14
29
|
} from "./provider-install.js";
|
|
15
30
|
import {
|
|
16
31
|
discoverSellerBackedModels,
|
|
@@ -25,21 +40,122 @@ import {
|
|
|
25
40
|
type SellerRegistryDocument,
|
|
26
41
|
} from "./seller-catalog.js";
|
|
27
42
|
import { ModelIndex } from "./model-index.js";
|
|
28
|
-
import { PrewarmCache } from "./prewarm-cache.js";
|
|
43
|
+
import { PrewarmCache, prewarmKey } from "./prewarm-cache.js";
|
|
29
44
|
import { CreditTracker } from "./credit-tracker.js";
|
|
30
45
|
import { SellerPool, type FailureKind } from "./seller-pool.js";
|
|
31
46
|
import { RouteFailover, type FailoverDecision, type RouteCandidate } from "./route-failover.js";
|
|
32
|
-
import { PrewarmScheduler, type SellerProber } from "./prewarm-scheduler.js";
|
|
47
|
+
import { PrewarmScheduler, type PrewarmReason, type SellerProber } from "./prewarm-scheduler.js";
|
|
33
48
|
import type { PoolEntry } from "./seller-pool.js";
|
|
34
49
|
import { planSellerRouteSet } from "./seller-route-planner.js";
|
|
50
|
+
import type { SellerRoutePlan } from "./seller-route-planner.js";
|
|
35
51
|
import {
|
|
52
|
+
assertSellerRoutingConfig,
|
|
36
53
|
mergeSellerRoutingConfig,
|
|
54
|
+
normalizeSellerRoutingConfig,
|
|
55
|
+
parseSellerIdList,
|
|
37
56
|
ROUTING_CONFIG_KEY,
|
|
38
57
|
type BuyerSellerRoutingConfig
|
|
39
58
|
} from "./seller-routing-config.js";
|
|
40
59
|
|
|
41
60
|
const logger = createModuleLogger("tb-proxyd");
|
|
61
|
+
const FOCUS_SET_CONFIG_KEY = "focus-set";
|
|
42
62
|
const PROXY_JSON_BODY_LIMIT = "10mb";
|
|
63
|
+
const SELLER_CAPACITY_BLOCK_MS = 2_000;
|
|
64
|
+
const CLAWTIP_STATIC_ROUTE = "/static/clawtip";
|
|
65
|
+
const CLAWTIP_RECHARGE_QR_FILE = "recharge.png";
|
|
66
|
+
|
|
67
|
+
function currentModuleDir(): string {
|
|
68
|
+
if (typeof __dirname !== "undefined") {
|
|
69
|
+
return __dirname;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const stack = new Error().stack || "";
|
|
73
|
+
const fileUrlMatch = stack.match(/(file:\/\/\/[^)\n]+\/daemon\.js):\d+:\d+/);
|
|
74
|
+
if (fileUrlMatch) {
|
|
75
|
+
return path.dirname(new URL(fileUrlMatch[1]).pathname);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const filePathMatch = stack.match(/(\/[^)\n]+\/daemon\.(?:js|ts)):\d+:\d+/);
|
|
79
|
+
if (filePathMatch) {
|
|
80
|
+
return path.dirname(filePathMatch[1]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return process.cwd();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ClientToolStatus {
|
|
87
|
+
id: string;
|
|
88
|
+
name: string;
|
|
89
|
+
status: ProviderCandidate["status"] | "manual";
|
|
90
|
+
detected: boolean;
|
|
91
|
+
configured: boolean;
|
|
92
|
+
configPath?: string;
|
|
93
|
+
commandName?: string;
|
|
94
|
+
reason: string;
|
|
95
|
+
manualConfig?: {
|
|
96
|
+
openaiBaseUrl: string;
|
|
97
|
+
anthropicBaseUrl: string;
|
|
98
|
+
apiKey: string;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function clientToolStatusFromProvider(provider: ProviderCandidate): ClientToolStatus {
|
|
103
|
+
return {
|
|
104
|
+
id: provider.id,
|
|
105
|
+
name: provider.name,
|
|
106
|
+
status: provider.status,
|
|
107
|
+
detected: provider.detected,
|
|
108
|
+
configured: provider.configured,
|
|
109
|
+
configPath: provider.configPath,
|
|
110
|
+
commandName: provider.commandName,
|
|
111
|
+
reason: provider.reason,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildCustomClientToolStatus(proxyPort: number): ClientToolStatus {
|
|
116
|
+
const openaiBaseUrl = `http://127.0.0.1:${proxyPort}/v1`;
|
|
117
|
+
const anthropicBaseUrl = `http://127.0.0.1:${proxyPort}`;
|
|
118
|
+
return {
|
|
119
|
+
id: "custom",
|
|
120
|
+
name: "Custom client",
|
|
121
|
+
status: "manual",
|
|
122
|
+
detected: true,
|
|
123
|
+
configured: false,
|
|
124
|
+
reason: `OpenAI-compatible: ${openaiBaseUrl} · Anthropic-compatible: ${anthropicBaseUrl}`,
|
|
125
|
+
manualConfig: {
|
|
126
|
+
openaiBaseUrl,
|
|
127
|
+
anthropicBaseUrl,
|
|
128
|
+
apiKey: "TOKENBUDDY_PROXY",
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 解析 `tb-ui` 构建产物目录(daemon 静态托管 SPA 用)。
|
|
135
|
+
* 优先级:env TB_UI_DIR > npm 包内 static/ui > workspace tb-ui/dist。
|
|
136
|
+
* 找不到时记录 warning 仍允许 daemon 启动(纯 API 模式);静态请求会 404。
|
|
137
|
+
*/
|
|
138
|
+
function resolveUiDir(): string | undefined {
|
|
139
|
+
if (process.env.TB_UI_DIR) return process.env.TB_UI_DIR;
|
|
140
|
+
const here = currentModuleDir();
|
|
141
|
+
const bundledUiDirs = [
|
|
142
|
+
path.resolve(here, "../../static/ui"),
|
|
143
|
+
path.resolve(here, "../static/ui")
|
|
144
|
+
];
|
|
145
|
+
for (const bundledUiDir of bundledUiDirs) {
|
|
146
|
+
if (fs.existsSync(path.join(bundledUiDir, "index.html"))) {
|
|
147
|
+
return bundledUiDir;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
// require.resolve 在 npm workspaces 装好时能找到 tb-ui/package.json
|
|
152
|
+
const pkgPath = require.resolve("tb-ui/package.json");
|
|
153
|
+
return path.join(path.dirname(pkgPath), "dist");
|
|
154
|
+
} catch {
|
|
155
|
+
// fallback: monorepo 假设 daemon dist 跟 tb-ui/dist 在同 root
|
|
156
|
+
return path.resolve(here, "../../tb-ui/dist");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
43
159
|
|
|
44
160
|
/**
|
|
45
161
|
* buyer 端守护进程(`tb-proxyd`)的配置。
|
|
@@ -56,6 +172,21 @@ export interface DaemonConfig {
|
|
|
56
172
|
sellerRegistryUrl: string;
|
|
57
173
|
/** 路由策略覆盖(与 `TB_SELLER_ROUTING_*` env 合并) */
|
|
58
174
|
sellerRouting?: BuyerSellerRoutingConfig;
|
|
175
|
+
/** test-only override for ClawTip bootstrap payload fetch. */
|
|
176
|
+
clawtipBootstrapFetcher?: (bootstrapUrl: string) => Promise<ClawtipBootstrapResponse>;
|
|
177
|
+
/** test-only override for the existing tb init ClawTip activation runner. */
|
|
178
|
+
clawtipWalletBootstrapStarter?: (
|
|
179
|
+
payment: ClawtipBootstrapPayment,
|
|
180
|
+
options?: { home?: string }
|
|
181
|
+
) => Promise<{ orderFile: string; parsedOutput: ParsedClawtipOutput; payCredential?: string }>;
|
|
182
|
+
/** test-only override for waiting on the existing tb init ClawTip registration loop. */
|
|
183
|
+
clawtipActivationWaiter?: (options?: WaitForClawtipActivationOptions) => Promise<boolean>;
|
|
184
|
+
/** test-only override for bundled ClawTip static assets; false disables bundled assets. */
|
|
185
|
+
clawtipBundledStaticDir?: string | false;
|
|
186
|
+
/** test-only home override for OpenClaw wallet and QR discovery. */
|
|
187
|
+
clawtipHomeDir?: string;
|
|
188
|
+
/** test-only home override for provider/client configuration detection. */
|
|
189
|
+
providerHomeDir?: string;
|
|
59
190
|
/**
|
|
60
191
|
* v1.2 §18.4 预热 focus-set 覆盖。
|
|
61
192
|
* 缺省时由 BuyerStore 历史模型使用情况 + `TB_BUYER_WARMUP_MODELS` env 推导。
|
|
@@ -94,6 +225,24 @@ interface ProxyBodySummary {
|
|
|
94
225
|
temperaturePresent?: boolean;
|
|
95
226
|
}
|
|
96
227
|
|
|
228
|
+
interface SellerHealthBody {
|
|
229
|
+
upstream?: {
|
|
230
|
+
status?: unknown;
|
|
231
|
+
lastErrorClass?: unknown;
|
|
232
|
+
last_error_class?: unknown;
|
|
233
|
+
};
|
|
234
|
+
capacity?: {
|
|
235
|
+
activeConnections?: unknown;
|
|
236
|
+
active_connections?: unknown;
|
|
237
|
+
maxConnections?: unknown;
|
|
238
|
+
max_connections?: unknown;
|
|
239
|
+
queueDepth?: unknown;
|
|
240
|
+
queue_depth?: unknown;
|
|
241
|
+
maxQueueDepth?: unknown;
|
|
242
|
+
max_queue_depth?: unknown;
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
97
246
|
interface SellerSettlementSummary {
|
|
98
247
|
requestId: string;
|
|
99
248
|
settledMicros: number;
|
|
@@ -227,6 +376,60 @@ function summarizeProxyBody(body: unknown): ProxyBodySummary {
|
|
|
227
376
|
};
|
|
228
377
|
}
|
|
229
378
|
|
|
379
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
380
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
381
|
+
return value;
|
|
382
|
+
}
|
|
383
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
384
|
+
const parsed = Number(value);
|
|
385
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function readErrorCode(bodyText: string): string | undefined {
|
|
391
|
+
try {
|
|
392
|
+
const parsed = JSON.parse(bodyText) as { error?: { code?: unknown }; code?: unknown };
|
|
393
|
+
const code = parsed.error?.code ?? parsed.code;
|
|
394
|
+
return typeof code === "string" ? code : undefined;
|
|
395
|
+
} catch {
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isBusyCapacityErrorBody(bodyText: string | undefined): boolean {
|
|
401
|
+
if (!bodyText) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
return readErrorCode(bodyText) === ErrorCode.BusyCapacity;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function capacityBlockedUntilFromHealth(body: SellerHealthBody, now: number): number | undefined {
|
|
408
|
+
const capacity = body.capacity;
|
|
409
|
+
if (!capacity) {
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
const activeConnections = finiteNumber(capacity.activeConnections ?? capacity.active_connections);
|
|
413
|
+
const maxConnections = finiteNumber(capacity.maxConnections ?? capacity.max_connections);
|
|
414
|
+
const queueDepth = finiteNumber(capacity.queueDepth ?? capacity.queue_depth);
|
|
415
|
+
const maxQueueDepth = finiteNumber(capacity.maxQueueDepth ?? capacity.max_queue_depth);
|
|
416
|
+
if (
|
|
417
|
+
activeConnections === undefined ||
|
|
418
|
+
maxConnections === undefined ||
|
|
419
|
+
queueDepth === undefined ||
|
|
420
|
+
maxQueueDepth === undefined
|
|
421
|
+
) {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
if (maxConnections <= 0) {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
const connectionsFull = activeConnections >= maxConnections;
|
|
428
|
+
const queueUnavailable = maxQueueDepth <= 0;
|
|
429
|
+
const queueFull = queueUnavailable || queueDepth >= maxQueueDepth;
|
|
430
|
+
return connectionsFull && queueFull ? now + SELLER_CAPACITY_BLOCK_MS : undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
230
433
|
function reorderDefaultSellerFirst(sellers: RegistrySeller[], defaultSellerId: string | undefined): RegistrySeller[] {
|
|
231
434
|
if (!defaultSellerId) {
|
|
232
435
|
return sellers;
|
|
@@ -275,6 +478,15 @@ export class TokenbuddyDaemon {
|
|
|
275
478
|
private selectionMode: "auto" | "manual";
|
|
276
479
|
private selectedSellerId?: string;
|
|
277
480
|
private sellerRouting: BuyerSellerRoutingConfig;
|
|
481
|
+
private lastRoutingPrewarmKey?: string;
|
|
482
|
+
private readonly lazyPrewarmKeys = new Set<string>();
|
|
483
|
+
private clawtipActivationWait?: Promise<void>;
|
|
484
|
+
private clawtipActivationWaitCancelToken?: { cancelled: boolean };
|
|
485
|
+
/**
|
|
486
|
+
* tb-ui v1 控制平面 `PUT /prewarm/focus-set` 写入的 explicit focus set。
|
|
487
|
+
* 优先级最高;`null` 表示回退到 env / historical(与 `resolveFocusSet()` 原行为一致)。
|
|
488
|
+
*/
|
|
489
|
+
private currentFocusSet: string[] | null = null;
|
|
278
490
|
|
|
279
491
|
private activePurchases = new Map<string, Promise<string>>();
|
|
280
492
|
|
|
@@ -302,13 +514,20 @@ export class TokenbuddyDaemon {
|
|
|
302
514
|
this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
|
|
303
515
|
const storedRouting = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)
|
|
304
516
|
?.config;
|
|
517
|
+
const storedFocusSet = this.tokenStore.getDaemonRuntimeConfig<{ models?: string[] }>(FOCUS_SET_CONFIG_KEY)
|
|
518
|
+
?.config;
|
|
305
519
|
this.config = config;
|
|
306
520
|
this.sellerRouting = mergeSellerRoutingConfig(
|
|
307
521
|
storedRouting,
|
|
308
522
|
config.sellerRouting
|
|
309
523
|
);
|
|
310
|
-
this.selectionMode = this.sellerRouting
|
|
311
|
-
this.selectedSellerId = this.sellerRouting
|
|
524
|
+
this.selectionMode = selectionModeForRouting(this.sellerRouting);
|
|
525
|
+
this.selectedSellerId = selectedSellerIdForRouting(this.sellerRouting);
|
|
526
|
+
// tb-ui v1: explicit focus set 优先于 env / historical
|
|
527
|
+
if (storedFocusSet && Array.isArray(storedFocusSet.models)) {
|
|
528
|
+
const deduped = Array.from(new Set(storedFocusSet.models.map((m) => m.trim()).filter(Boolean)));
|
|
529
|
+
this.currentFocusSet = deduped.length > 0 ? deduped : null;
|
|
530
|
+
}
|
|
312
531
|
// v1.2 §18.5: scheduler is created here (not in the field initializer)
|
|
313
532
|
// because it needs the config-derived prober + idle interval.
|
|
314
533
|
Object.assign(this, {
|
|
@@ -339,7 +558,22 @@ export class TokenbuddyDaemon {
|
|
|
339
558
|
if (!res.ok) {
|
|
340
559
|
return { ok: false, latencyMs: Date.now() - startedAt, httpStatus: res.status, errorMessage: `health returned ${res.status}` };
|
|
341
560
|
}
|
|
342
|
-
|
|
561
|
+
const now = Date.now();
|
|
562
|
+
const body = await res.json() as SellerHealthBody;
|
|
563
|
+
const upstream = body.upstream;
|
|
564
|
+
const upstreamErrorClass = upstream?.lastErrorClass ?? upstream?.last_error_class;
|
|
565
|
+
return {
|
|
566
|
+
ok: true,
|
|
567
|
+
latencyMs: now - startedAt,
|
|
568
|
+
httpStatus: res.status,
|
|
569
|
+
upstreamStatus: typeof upstream?.status === "string"
|
|
570
|
+
? upstream.status as "healthy" | "degraded" | "unhealthy" | "unknown"
|
|
571
|
+
: undefined,
|
|
572
|
+
upstreamErrorClass: typeof upstreamErrorClass === "string"
|
|
573
|
+
? upstreamErrorClass
|
|
574
|
+
: undefined,
|
|
575
|
+
capacityBlockedUntil: capacityBlockedUntilFromHealth(body, now)
|
|
576
|
+
};
|
|
343
577
|
} catch (err) {
|
|
344
578
|
const message = err instanceof Error ? err.message : String(err);
|
|
345
579
|
return { ok: false, latencyMs: 0, errorMessage: message };
|
|
@@ -357,6 +591,201 @@ export class TokenbuddyDaemon {
|
|
|
357
591
|
return typeof address === "object" && address ? address.port : this.config.proxyPort;
|
|
358
592
|
}
|
|
359
593
|
|
|
594
|
+
private clawtipStaticDir(): string {
|
|
595
|
+
return path.join(path.dirname(this.config.dbPath), "static", "clawtip");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private bundledClawtipStaticDir(): string | undefined {
|
|
599
|
+
if (this.config.clawtipBundledStaticDir === false) {
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
if (typeof this.config.clawtipBundledStaticDir === "string") {
|
|
603
|
+
return fs.existsSync(this.config.clawtipBundledStaticDir) ? this.config.clawtipBundledStaticDir : undefined;
|
|
604
|
+
}
|
|
605
|
+
const here = currentModuleDir();
|
|
606
|
+
const candidates = [
|
|
607
|
+
path.resolve(here, "../static/clawtip"),
|
|
608
|
+
path.resolve(here, "../../static/clawtip"),
|
|
609
|
+
path.resolve(process.cwd(), "packages/tokenbuddy-cli/static/clawtip")
|
|
610
|
+
];
|
|
611
|
+
return candidates.find((candidate) => fs.existsSync(candidate));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private clawtipPublicUrl(fileName: string): string {
|
|
615
|
+
return `${CLAWTIP_STATIC_ROUTE}/${encodeURIComponent(fileName)}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private ensureClawtipStaticAssets(): void {
|
|
619
|
+
const outputDir = this.clawtipStaticDir();
|
|
620
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
621
|
+
const rechargeOutputPath = path.join(outputDir, CLAWTIP_RECHARGE_QR_FILE);
|
|
622
|
+
if (fs.existsSync(rechargeOutputPath)) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const bundledDir = this.bundledClawtipStaticDir();
|
|
626
|
+
const rechargeSourcePath = bundledDir ? path.join(bundledDir, CLAWTIP_RECHARGE_QR_FILE) : undefined;
|
|
627
|
+
if (rechargeSourcePath && fs.existsSync(rechargeSourcePath)) {
|
|
628
|
+
fs.copyFileSync(rechargeSourcePath, rechargeOutputPath);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private copyClawtipQrToStatic(mediaPath: string, orderNo: string): { fileName: string; url: string; path: string } {
|
|
633
|
+
if (!fs.existsSync(mediaPath)) {
|
|
634
|
+
throw new Error(`ClawTip QR image does not exist: ${mediaPath}`);
|
|
635
|
+
}
|
|
636
|
+
const extension = safeQrExtension(mediaPath);
|
|
637
|
+
const fileName = `${safeStaticFileSegment(orderNo)}-${Date.now()}${extension}`;
|
|
638
|
+
const outputDir = this.clawtipStaticDir();
|
|
639
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
640
|
+
const outputPath = path.join(outputDir, fileName);
|
|
641
|
+
fs.copyFileSync(mediaPath, outputPath);
|
|
642
|
+
return {
|
|
643
|
+
fileName,
|
|
644
|
+
path: outputPath,
|
|
645
|
+
url: this.clawtipPublicUrl(fileName)
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private async startClawtipActivationQr(): Promise<ClawtipQrResponse> {
|
|
650
|
+
const bootstrapUrl = process.env.TOKENBUDDY_BOOTSTRAP_URL || "https://tb-wallet-bootstrap.fly.dev";
|
|
651
|
+
const fetchBootstrap = this.config.clawtipBootstrapFetcher || fetchClawtipBootstrap;
|
|
652
|
+
const bootstrap = await fetchBootstrap(bootstrapUrl);
|
|
653
|
+
const payment = normalizeClawtipActivationPayment(bootstrap);
|
|
654
|
+
const startBootstrap = this.config.clawtipWalletBootstrapStarter || startClawtipWalletBootstrap;
|
|
655
|
+
const activation = await startBootstrap(payment, { home: this.config.clawtipHomeDir });
|
|
656
|
+
if (!activation.parsedOutput.mediaPath) {
|
|
657
|
+
throw new Error("ClawTip activation did not return a QR image.");
|
|
658
|
+
}
|
|
659
|
+
const staticQr = this.copyClawtipQrToStatic(activation.parsedOutput.mediaPath, payment.orderNo);
|
|
660
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
661
|
+
const existingPayment = this.tokenStore.getPayment("clawtip");
|
|
662
|
+
this.tokenStore.savePayment({
|
|
663
|
+
method: "clawtip",
|
|
664
|
+
enabled: walletConfig.exists,
|
|
665
|
+
isDefault: existingPayment?.isDefault ?? true,
|
|
666
|
+
config: {
|
|
667
|
+
...(existingPayment?.config ?? {}),
|
|
668
|
+
bootstrapUrl,
|
|
669
|
+
orderNo: payment.orderNo,
|
|
670
|
+
amountFen: payment.amountFen,
|
|
671
|
+
indicator: payment.indicator,
|
|
672
|
+
slug: payment.slug,
|
|
673
|
+
skillId: payment.skillId,
|
|
674
|
+
description: payment.description,
|
|
675
|
+
resourceUrl: payment.resourceUrl,
|
|
676
|
+
activationOrderFile: activation.orderFile,
|
|
677
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
678
|
+
walletConfigPresent: walletConfig.exists,
|
|
679
|
+
activationQrImagePath: activation.parsedOutput.mediaPath,
|
|
680
|
+
activationQrImageUrl: staticQr.url,
|
|
681
|
+
authUrl: activation.parsedOutput.authUrl,
|
|
682
|
+
clawtipId: activation.parsedOutput.clawtipId,
|
|
683
|
+
payCredentialWritten: Boolean(activation.payCredential),
|
|
684
|
+
activationCompletedBy: activation.payCredential
|
|
685
|
+
? (walletConfig.exists ? "payCredential+wallet-config" : "payCredential")
|
|
686
|
+
: walletConfig.exists ? "wallet-config" : "pending-wallet-scan"
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
this.scheduleClawtipActivationWait(activation.parsedOutput.clawtipId);
|
|
690
|
+
return {
|
|
691
|
+
ok: true,
|
|
692
|
+
kind: "activate",
|
|
693
|
+
method: "clawtip",
|
|
694
|
+
orderNo: payment.orderNo,
|
|
695
|
+
amountFen: payment.amountFen,
|
|
696
|
+
qrImageUrl: staticQr.url,
|
|
697
|
+
sourceImagePath: activation.parsedOutput.mediaPath,
|
|
698
|
+
staticImagePath: staticQr.path,
|
|
699
|
+
authUrl: activation.parsedOutput.authUrl,
|
|
700
|
+
clawtipId: activation.parsedOutput.clawtipId,
|
|
701
|
+
orderFile: activation.orderFile,
|
|
702
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
703
|
+
walletConfigPresent: walletConfig.exists,
|
|
704
|
+
requiresWalletAuth: activation.parsedOutput.requiresWalletAuth,
|
|
705
|
+
payCredentialWritten: Boolean(activation.payCredential)
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private scheduleClawtipActivationWait(clawtipId?: string): void {
|
|
710
|
+
if (this.clawtipActivationWait) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const cancelToken = { cancelled: false };
|
|
714
|
+
this.clawtipActivationWaitCancelToken = cancelToken;
|
|
715
|
+
const waitForActivation = this.config.clawtipActivationWaiter || waitForClawtipActivationConfirmation;
|
|
716
|
+
this.clawtipActivationWait = waitForActivation({
|
|
717
|
+
clawtipId,
|
|
718
|
+
inspectWalletConfig: () => inspectOpenClawWalletConfig(this.config.clawtipHomeDir),
|
|
719
|
+
isCancelled: () => cancelToken.cancelled,
|
|
720
|
+
cancel: () => undefined
|
|
721
|
+
})
|
|
722
|
+
.then((walletRegistered) => {
|
|
723
|
+
if (cancelToken.cancelled) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (!walletRegistered) {
|
|
727
|
+
logger.info("control.payment.clawtip.activation_wait.pending", "ClawTip activation wait ended before wallet registration");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
731
|
+
const payment = this.tokenStore.getPayment("clawtip");
|
|
732
|
+
if (!payment || payment.method !== "clawtip") {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
this.tokenStore.savePayment({
|
|
736
|
+
...payment,
|
|
737
|
+
enabled: walletConfig.exists,
|
|
738
|
+
config: {
|
|
739
|
+
...(payment.config ?? {}),
|
|
740
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
741
|
+
walletConfigPresent: walletConfig.exists,
|
|
742
|
+
activationCompletedBy: walletConfig.exists
|
|
743
|
+
? "wallet-config"
|
|
744
|
+
: readConfigString(payment.config, "activationCompletedBy") ?? "pending-wallet-scan"
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
logger.info("control.payment.clawtip.activation_wait.completed", "ClawTip activation wait completed", {
|
|
748
|
+
walletRegistered,
|
|
749
|
+
walletConfigPresent: walletConfig.exists
|
|
750
|
+
});
|
|
751
|
+
})
|
|
752
|
+
.catch((error: unknown) => {
|
|
753
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
754
|
+
logger.warn("control.payment.clawtip.activation_wait.failed", "ClawTip activation wait failed", { errorMessage });
|
|
755
|
+
})
|
|
756
|
+
.finally(() => {
|
|
757
|
+
if (this.clawtipActivationWaitCancelToken === cancelToken) {
|
|
758
|
+
this.clawtipActivationWaitCancelToken = undefined;
|
|
759
|
+
this.clawtipActivationWait = undefined;
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private clawtipRechargeQr(): ClawtipQrResponse {
|
|
765
|
+
const payment = this.tokenStore.getPayment("clawtip");
|
|
766
|
+
const resourceUrl = readConfigString(payment?.config, "resourceUrl");
|
|
767
|
+
const orderNo = readConfigString(payment?.config, "orderNo") || "clawtip-recharge";
|
|
768
|
+
const mediaPath = path.join(this.clawtipStaticDir(), CLAWTIP_RECHARGE_QR_FILE);
|
|
769
|
+
if (!fs.existsSync(mediaPath)) {
|
|
770
|
+
throw new Error(`ClawTip fixed recharge QR image is missing: ${mediaPath}`);
|
|
771
|
+
}
|
|
772
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
773
|
+
return {
|
|
774
|
+
ok: true,
|
|
775
|
+
kind: "recharge",
|
|
776
|
+
method: "clawtip",
|
|
777
|
+
orderNo,
|
|
778
|
+
qrImageUrl: this.clawtipPublicUrl(CLAWTIP_RECHARGE_QR_FILE),
|
|
779
|
+
sourceImagePath: mediaPath,
|
|
780
|
+
staticImagePath: mediaPath,
|
|
781
|
+
resourceUrl,
|
|
782
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
783
|
+
walletConfigPresent: walletConfig.exists,
|
|
784
|
+
requiresWalletAuth: false,
|
|
785
|
+
payCredentialWritten: readConfigBoolean(payment?.config, "payCredentialWritten")
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
360
789
|
// v1.2 §18.9: stale-cache fallback. The buyer remembers the last
|
|
361
790
|
// successfully fetched registry document and reuses it when the
|
|
362
791
|
// bootstrap returns 413 (`X-TokenBuddy-Registry-Too-Large: 1`). This
|
|
@@ -397,6 +826,7 @@ export class TokenbuddyDaemon {
|
|
|
397
826
|
}
|
|
398
827
|
|
|
399
828
|
private runtimeSummary() {
|
|
829
|
+
this.refreshSellerRoutingConfig();
|
|
400
830
|
return {
|
|
401
831
|
status: "running",
|
|
402
832
|
pid: process.pid,
|
|
@@ -502,7 +932,7 @@ export class TokenbuddyDaemon {
|
|
|
502
932
|
return payments.find((payment) => payment.isDefault)?.method || payments.find((payment) => payment.method === "mock")?.method;
|
|
503
933
|
}
|
|
504
934
|
|
|
505
|
-
private async selectSellerRoutes(endpoint: string, modelId: string): Promise<SellerRoute[]> {
|
|
935
|
+
private async selectSellerRoutes(endpoint: string, modelId: string): Promise<{ routes: SellerRoute[]; plan: SellerRoutePlan; paymentMethod: string }> {
|
|
506
936
|
const protocol = this.endpointProtocol(endpoint);
|
|
507
937
|
if (!protocol) {
|
|
508
938
|
throw new Error(`unsupported proxy endpoint: ${endpoint}`);
|
|
@@ -515,12 +945,14 @@ export class TokenbuddyDaemon {
|
|
|
515
945
|
// v1.2: registry is the source of truth for routing. We rebuild the
|
|
516
946
|
// model-index once per request (cheap; index lookup is in-memory) so
|
|
517
947
|
// the response always reflects the latest seller list. The previous
|
|
518
|
-
// "fetchSellerManifest per
|
|
948
|
+
// "fetchSellerManifest per request" path is removed in favor of
|
|
519
949
|
// pulling `models` directly off the registry entries.
|
|
520
950
|
const registry = await this.fetchRegistry();
|
|
521
951
|
|
|
522
|
-
const routing = this.
|
|
952
|
+
const routing = resolveSellerRoutingForModel(this.refreshSellerRoutingConfig(), modelId);
|
|
523
953
|
const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
|
|
954
|
+
this.sellerPool.ensureRegistrySellers(registrySellers);
|
|
955
|
+
this.scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod);
|
|
524
956
|
const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
|
|
525
957
|
const planned = planSellerRouteSet({
|
|
526
958
|
modelId,
|
|
@@ -533,8 +965,12 @@ export class TokenbuddyDaemon {
|
|
|
533
965
|
sellerId: entry.sellerId,
|
|
534
966
|
healthScore: entry.healthScore,
|
|
535
967
|
avgLatencyMs: entry.avgLatencyMs,
|
|
536
|
-
|
|
537
|
-
|
|
968
|
+
ttftMs: entry.ttftMs,
|
|
969
|
+
avgInferenceMs: entry.avgInferenceMs,
|
|
970
|
+
circuit: entry.circuit,
|
|
971
|
+
capacityBlockedUntil: entry.capacityBlockedUntil
|
|
972
|
+
})),
|
|
973
|
+
now: Date.now()
|
|
538
974
|
});
|
|
539
975
|
|
|
540
976
|
if (planned.routes.length === 0) {
|
|
@@ -564,7 +1000,122 @@ export class TokenbuddyDaemon {
|
|
|
564
1000
|
sellerCount: routes.length,
|
|
565
1001
|
sellers: routes.map((route) => route.seller.id)
|
|
566
1002
|
});
|
|
567
|
-
return routes;
|
|
1003
|
+
return { routes, plan: planned, paymentMethod };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private refreshSellerRoutingConfig(): BuyerSellerRoutingConfig {
|
|
1007
|
+
const storedRouting = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)
|
|
1008
|
+
?.config;
|
|
1009
|
+
const nextRouting = mergeSellerRoutingConfig(
|
|
1010
|
+
storedRouting,
|
|
1011
|
+
this.config.sellerRouting
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
if (!sameSellerRouting(this.sellerRouting, nextRouting)) {
|
|
1015
|
+
const previous = this.sellerRouting;
|
|
1016
|
+
this.sellerRouting = nextRouting;
|
|
1017
|
+
this.selectionMode = selectionModeForRouting(nextRouting);
|
|
1018
|
+
this.selectedSellerId = selectedSellerIdForRouting(nextRouting);
|
|
1019
|
+
logger.info("routing.config.reloaded", "seller routing config reloaded", {
|
|
1020
|
+
previousMode: previous.mode,
|
|
1021
|
+
previousScorer: previous.scorer,
|
|
1022
|
+
sellerRoutingMode: nextRouting.mode,
|
|
1023
|
+
sellerRoutingScorer: nextRouting.scorer,
|
|
1024
|
+
selectedSellerId: this.selectedSellerId
|
|
1025
|
+
});
|
|
1026
|
+
void this.runRoutingPrewarmSweep(nextRouting);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return this.sellerRouting;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private async runRoutingPrewarmSweep(routing: BuyerSellerRoutingConfig): Promise<void> {
|
|
1033
|
+
const focusSet = this.resolveFocusSet();
|
|
1034
|
+
const routingPrewarmKey = `${routingKey(routing)}\u0001${focusSet.join("\u0001")}`;
|
|
1035
|
+
if (focusSet.length === 0) {
|
|
1036
|
+
logger.info("prewarm.routing.skipped", "no focus set configured after routing reload; relying on lazy prewarms", {
|
|
1037
|
+
sellerRoutingMode: routing.mode,
|
|
1038
|
+
sellerRoutingScorer: routing.scorer
|
|
1039
|
+
});
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (this.lastRoutingPrewarmKey === routingPrewarmKey) {
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
this.lastRoutingPrewarmKey = routingPrewarmKey;
|
|
1046
|
+
logger.info("prewarm.routing.scheduled", "routing reload prewarm sweep scheduled", {
|
|
1047
|
+
sellerRoutingMode: routing.mode,
|
|
1048
|
+
sellerRoutingScorer: routing.scorer,
|
|
1049
|
+
focusSetSize: focusSet.length,
|
|
1050
|
+
focusSet: focusSet.slice(0, 20)
|
|
1051
|
+
});
|
|
1052
|
+
try {
|
|
1053
|
+
await this.fetchRegistry();
|
|
1054
|
+
const paymentMethod = this.defaultPaymentMethod();
|
|
1055
|
+
for (const modelId of focusSet) {
|
|
1056
|
+
this.schedulePrewarmForModel({
|
|
1057
|
+
modelId,
|
|
1058
|
+
reason: "explicit",
|
|
1059
|
+
protocol: this.resolvePrewarmProtocol(modelId, paymentMethod),
|
|
1060
|
+
paymentMethod
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
logger.warn("prewarm.routing.failed", "routing reload prewarm sweep failed", {
|
|
1065
|
+
sellerRoutingMode: routing.mode,
|
|
1066
|
+
sellerRoutingScorer: routing.scorer,
|
|
1067
|
+
errorMessage: err instanceof Error ? err.message : String(err)
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
private scheduleLazyPrewarmIfNeeded(modelId: string, protocol: string, paymentMethod: string): void {
|
|
1073
|
+
const freshness = this.prewarmCache.freshness(modelId, protocol, paymentMethod);
|
|
1074
|
+
if (freshness.present && !freshness.expired) {
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const key = prewarmKey(modelId, protocol, paymentMethod);
|
|
1078
|
+
if (this.lazyPrewarmKeys.has(key)) {
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
this.lazyPrewarmKeys.add(key);
|
|
1082
|
+
logger.info("prewarm.lazy.scheduled", "lazy prewarm scheduled for requested model", {
|
|
1083
|
+
modelId,
|
|
1084
|
+
protocol,
|
|
1085
|
+
paymentMethod,
|
|
1086
|
+
freshnessState: freshness.state
|
|
1087
|
+
});
|
|
1088
|
+
this.schedulePrewarmForModel({
|
|
1089
|
+
modelId,
|
|
1090
|
+
reason: "lazy",
|
|
1091
|
+
protocol,
|
|
1092
|
+
paymentMethod
|
|
1093
|
+
}).finally(() => {
|
|
1094
|
+
this.lazyPrewarmKeys.delete(key);
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
private schedulePrewarmForModel(input: {
|
|
1099
|
+
modelId: string;
|
|
1100
|
+
reason: PrewarmReason;
|
|
1101
|
+
protocol?: string;
|
|
1102
|
+
paymentMethod?: string;
|
|
1103
|
+
}): Promise<unknown> {
|
|
1104
|
+
if (!input.protocol || !input.paymentMethod) {
|
|
1105
|
+
logger.warn("prewarm.schedule.skipped", "prewarm schedule skipped because protocol or payment method is missing", {
|
|
1106
|
+
modelId: input.modelId,
|
|
1107
|
+
reason: input.reason,
|
|
1108
|
+
protocol: input.protocol,
|
|
1109
|
+
paymentMethod: input.paymentMethod
|
|
1110
|
+
});
|
|
1111
|
+
return Promise.resolve();
|
|
1112
|
+
}
|
|
1113
|
+
return this.prewarmScheduler.schedulePrewarm({
|
|
1114
|
+
modelId: input.modelId,
|
|
1115
|
+
reason: input.reason,
|
|
1116
|
+
protocol: input.protocol,
|
|
1117
|
+
paymentMethod: input.paymentMethod
|
|
1118
|
+
});
|
|
568
1119
|
}
|
|
569
1120
|
|
|
570
1121
|
private failoverErrorMessage(error: unknown): string {
|
|
@@ -580,13 +1131,16 @@ export class TokenbuddyDaemon {
|
|
|
580
1131
|
* caller side because it short-circuits the failure path with a
|
|
581
1132
|
* re-purchase.
|
|
582
1133
|
*/
|
|
583
|
-
private classifyFailureStatus(status: number): FailureKind {
|
|
1134
|
+
private classifyFailureStatus(status: number, bodyText?: string): FailureKind {
|
|
584
1135
|
if (status === 401 || status === 403) {
|
|
585
1136
|
return "auth_invalid";
|
|
586
1137
|
}
|
|
587
1138
|
if (status === 402) {
|
|
588
1139
|
return "insufficient_funds";
|
|
589
1140
|
}
|
|
1141
|
+
if (status === 429 && isBusyCapacityErrorBody(bodyText)) {
|
|
1142
|
+
return "busy_capacity";
|
|
1143
|
+
}
|
|
590
1144
|
if (status === 400 || status === 404 || status === 422) {
|
|
591
1145
|
return "hard_4xx";
|
|
592
1146
|
}
|
|
@@ -770,7 +1324,16 @@ export class TokenbuddyDaemon {
|
|
|
770
1324
|
usage: UsageSummary,
|
|
771
1325
|
settlement: SellerSettlementSummary | undefined,
|
|
772
1326
|
prompt: string | undefined,
|
|
773
|
-
response?: string
|
|
1327
|
+
response?: string,
|
|
1328
|
+
extras?: {
|
|
1329
|
+
ttftMs?: number;
|
|
1330
|
+
fallbackCount?: number;
|
|
1331
|
+
routeReason?: string;
|
|
1332
|
+
falloverChain?: string[];
|
|
1333
|
+
upstreamStatus?: string;
|
|
1334
|
+
durationMs?: number;
|
|
1335
|
+
paymentMethod?: string;
|
|
1336
|
+
}
|
|
774
1337
|
): void {
|
|
775
1338
|
if (settlement) {
|
|
776
1339
|
this.tokenStore.reconcileTokenBalance({
|
|
@@ -800,7 +1363,14 @@ export class TokenbuddyDaemon {
|
|
|
800
1363
|
balanceSnapshotMicros: settlement?.remainingCreditMicros,
|
|
801
1364
|
balanceSource: settlement ? "seller_authoritative" : "estimated",
|
|
802
1365
|
prompt,
|
|
803
|
-
response
|
|
1366
|
+
response,
|
|
1367
|
+
ttftMs: extras?.ttftMs,
|
|
1368
|
+
fallbackCount: extras?.fallbackCount,
|
|
1369
|
+
routeReason: extras?.routeReason,
|
|
1370
|
+
falloverChain: extras?.falloverChain,
|
|
1371
|
+
upstreamStatus: extras?.upstreamStatus,
|
|
1372
|
+
durationMs: extras?.durationMs,
|
|
1373
|
+
paymentMethod: extras?.paymentMethod
|
|
804
1374
|
});
|
|
805
1375
|
logger.info("inference.ledger.recorded", "safe inference ledger recorded", {
|
|
806
1376
|
requestId: settlement?.requestId || requestId,
|
|
@@ -815,7 +1385,14 @@ export class TokenbuddyDaemon {
|
|
|
815
1385
|
promptTokens: usage.promptTokens,
|
|
816
1386
|
completionTokens: usage.completionTokens,
|
|
817
1387
|
balanceSnapshotMicros: settlement?.remainingCreditMicros,
|
|
818
|
-
balanceSource: settlement ? "seller_authoritative" : "estimated"
|
|
1388
|
+
balanceSource: settlement ? "seller_authoritative" : "estimated",
|
|
1389
|
+
ttftMs: extras?.ttftMs,
|
|
1390
|
+
fallbackCount: extras?.fallbackCount,
|
|
1391
|
+
routeReason: extras?.routeReason,
|
|
1392
|
+
falloverChain: extras?.falloverChain,
|
|
1393
|
+
upstreamStatus: extras?.upstreamStatus,
|
|
1394
|
+
durationMs: extras?.durationMs,
|
|
1395
|
+
paymentMethod: extras?.paymentMethod
|
|
819
1396
|
});
|
|
820
1397
|
}
|
|
821
1398
|
|
|
@@ -1316,6 +1893,10 @@ export class TokenbuddyDaemon {
|
|
|
1316
1893
|
|
|
1317
1894
|
private async forwardProxyRequest(endpoint: string, req: Request, res: Response): Promise<void> {
|
|
1318
1895
|
const startedAt = Date.now();
|
|
1896
|
+
let firstByteAt: number | null = null;
|
|
1897
|
+
const markFirstByte = (): void => {
|
|
1898
|
+
if (firstByteAt === null) firstByteAt = Date.now();
|
|
1899
|
+
};
|
|
1319
1900
|
const body = req.body || {};
|
|
1320
1901
|
const { requestedModelId, resolvedModelId } = this.resolveRouteModelId(endpoint, body);
|
|
1321
1902
|
const modelId = resolvedModelId;
|
|
@@ -1328,7 +1909,12 @@ export class TokenbuddyDaemon {
|
|
|
1328
1909
|
}
|
|
1329
1910
|
|
|
1330
1911
|
try {
|
|
1331
|
-
const routes = await this.selectSellerRoutes(endpoint, modelId);
|
|
1912
|
+
const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId);
|
|
1913
|
+
const upstreamStatusFromHeaders = (h: Headers): string | undefined => {
|
|
1914
|
+
const raw = h.get("x-tokenbuddy-upstream-status") || h.get("x-upstream-status");
|
|
1915
|
+
if (!raw) return undefined;
|
|
1916
|
+
return raw === "healthy" || raw === "degraded" || raw === "unhealthy" || raw === "unknown" ? raw : "unknown";
|
|
1917
|
+
};
|
|
1332
1918
|
let lastError: unknown;
|
|
1333
1919
|
|
|
1334
1920
|
for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
|
|
@@ -1336,6 +1922,7 @@ export class TokenbuddyDaemon {
|
|
|
1336
1922
|
const sellerKey = route.seller.id;
|
|
1337
1923
|
logger.info("route.selected", "seller route selected", {
|
|
1338
1924
|
sellerKey,
|
|
1925
|
+
sellerId: sellerKey,
|
|
1339
1926
|
model: modelId,
|
|
1340
1927
|
endpoint,
|
|
1341
1928
|
protocol: route.protocol,
|
|
@@ -1493,7 +2080,7 @@ export class TokenbuddyDaemon {
|
|
|
1493
2080
|
status: upstreamResponse.status,
|
|
1494
2081
|
durationMs: Date.now() - startedAt
|
|
1495
2082
|
});
|
|
1496
|
-
const kind: FailureKind = this.classifyFailureStatus(upstreamResponse.status);
|
|
2083
|
+
const kind: FailureKind = this.classifyFailureStatus(upstreamResponse.status, errorBody);
|
|
1497
2084
|
const decision = this.routeFailover.decide(
|
|
1498
2085
|
{
|
|
1499
2086
|
sellerId: sellerKey,
|
|
@@ -1568,6 +2155,7 @@ export class TokenbuddyDaemon {
|
|
|
1568
2155
|
// 缺 event: 行)由卖方修,buyer 不兜底。
|
|
1569
2156
|
const sellerChunk = settlementExtractor.push(chunk);
|
|
1570
2157
|
if (sellerChunk.length > 0) {
|
|
2158
|
+
markFirstByte();
|
|
1571
2159
|
res.write(sellerChunk);
|
|
1572
2160
|
}
|
|
1573
2161
|
}
|
|
@@ -1579,11 +2167,13 @@ export class TokenbuddyDaemon {
|
|
|
1579
2167
|
if (decoderTail.length > 0) {
|
|
1580
2168
|
const sellerTail = settlementExtractor.push(decoderTail);
|
|
1581
2169
|
if (sellerTail.length > 0) {
|
|
2170
|
+
markFirstByte();
|
|
1582
2171
|
res.write(sellerTail);
|
|
1583
2172
|
}
|
|
1584
2173
|
}
|
|
1585
2174
|
const settlementTrailing = settlementExtractor.finish();
|
|
1586
2175
|
if (settlementTrailing.downstream.length > 0) {
|
|
2176
|
+
markFirstByte();
|
|
1587
2177
|
res.write(settlementTrailing.downstream);
|
|
1588
2178
|
}
|
|
1589
2179
|
res.end();
|
|
@@ -1593,12 +2183,23 @@ export class TokenbuddyDaemon {
|
|
|
1593
2183
|
requestId,
|
|
1594
2184
|
{ promptTokens: 0, completionTokens: 0, billedMicros: Math.max(1, bytes) },
|
|
1595
2185
|
this.parseSellerSettlementSummary(upstreamResponse.headers) ?? settlementTrailing.settlement ?? settlementExtractor.current(),
|
|
1596
|
-
this.inferPromptForHash(body)
|
|
2186
|
+
this.inferPromptForHash(body),
|
|
2187
|
+
undefined,
|
|
2188
|
+
{
|
|
2189
|
+
ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
|
|
2190
|
+
fallbackCount: routeIndex,
|
|
2191
|
+
routeReason: plan.reason,
|
|
2192
|
+
falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
|
|
2193
|
+
upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
|
|
2194
|
+
durationMs: Date.now() - startedAt,
|
|
2195
|
+
paymentMethod
|
|
2196
|
+
}
|
|
1597
2197
|
);
|
|
1598
2198
|
return;
|
|
1599
2199
|
}
|
|
1600
2200
|
|
|
1601
2201
|
const responseBody = await upstreamResponse.text();
|
|
2202
|
+
markFirstByte();
|
|
1602
2203
|
res.send(responseBody);
|
|
1603
2204
|
const usage = this.readUsage(responseBody);
|
|
1604
2205
|
this.recordReconciledInference(
|
|
@@ -1608,7 +2209,16 @@ export class TokenbuddyDaemon {
|
|
|
1608
2209
|
usage,
|
|
1609
2210
|
this.parseSellerSettlementSummary(upstreamResponse.headers),
|
|
1610
2211
|
this.inferPromptForHash(body),
|
|
1611
|
-
responseBody
|
|
2212
|
+
responseBody,
|
|
2213
|
+
{
|
|
2214
|
+
ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
|
|
2215
|
+
fallbackCount: routeIndex,
|
|
2216
|
+
routeReason: plan.reason,
|
|
2217
|
+
falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
|
|
2218
|
+
upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
|
|
2219
|
+
durationMs: Date.now() - startedAt,
|
|
2220
|
+
paymentMethod
|
|
2221
|
+
}
|
|
1612
2222
|
);
|
|
1613
2223
|
return;
|
|
1614
2224
|
} catch (routeError: unknown) {
|
|
@@ -1710,10 +2320,42 @@ export class TokenbuddyDaemon {
|
|
|
1710
2320
|
controlApp.get("/payments", (req, res) => {
|
|
1711
2321
|
logger.info("control.payments.requested", "control payments requested", {});
|
|
1712
2322
|
res.status(200).json({
|
|
1713
|
-
payments: this.tokenStore.listPayments()
|
|
2323
|
+
payments: this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir))
|
|
1714
2324
|
});
|
|
1715
2325
|
});
|
|
1716
2326
|
|
|
2327
|
+
controlApp.post("/payments/clawtip/activate", async (req, res) => {
|
|
2328
|
+
try {
|
|
2329
|
+
const qr = await this.startClawtipActivationQr();
|
|
2330
|
+
logger.info("control.payment.clawtip.activate_qr.created", "ClawTip activation QR copied for tb-ui", {
|
|
2331
|
+
orderNo: qr.orderNo,
|
|
2332
|
+
qrImageUrl: qr.qrImageUrl,
|
|
2333
|
+
walletConfigPresent: qr.walletConfigPresent
|
|
2334
|
+
});
|
|
2335
|
+
res.status(200).json(qr);
|
|
2336
|
+
} catch (error: unknown) {
|
|
2337
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2338
|
+
logger.warn("control.payment.clawtip.activate_qr.failed", "ClawTip activation QR failed", { errorMessage });
|
|
2339
|
+
res.status(500).json({ error: { code: "clawtip_activate_qr_failed", message: errorMessage } });
|
|
2340
|
+
}
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2343
|
+
controlApp.post("/payments/clawtip/recharge", (req, res) => {
|
|
2344
|
+
try {
|
|
2345
|
+
const qr = this.clawtipRechargeQr();
|
|
2346
|
+
logger.info("control.payment.clawtip.recharge_qr.created", "ClawTip fixed recharge QR served for tb-ui", {
|
|
2347
|
+
orderNo: qr.orderNo,
|
|
2348
|
+
qrImageUrl: qr.qrImageUrl,
|
|
2349
|
+
walletConfigPresent: qr.walletConfigPresent
|
|
2350
|
+
});
|
|
2351
|
+
res.status(200).json(qr);
|
|
2352
|
+
} catch (error: unknown) {
|
|
2353
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2354
|
+
logger.warn("control.payment.clawtip.recharge_qr.failed", "ClawTip recharge QR failed", { errorMessage });
|
|
2355
|
+
res.status(500).json({ error: { code: "clawtip_recharge_qr_failed", message: errorMessage } });
|
|
2356
|
+
}
|
|
2357
|
+
});
|
|
2358
|
+
|
|
1717
2359
|
controlApp.get("/ledger/purchases", (req, res) => {
|
|
1718
2360
|
logger.info("control.ledger.requested", "control purchase ledger requested", {
|
|
1719
2361
|
ledger: "purchases"
|
|
@@ -1855,6 +2497,41 @@ export class TokenbuddyDaemon {
|
|
|
1855
2497
|
}
|
|
1856
2498
|
});
|
|
1857
2499
|
|
|
2500
|
+
controlApp.get("/providers/status", (_req, res) => {
|
|
2501
|
+
try {
|
|
2502
|
+
const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
|
|
2503
|
+
const clients = [
|
|
2504
|
+
...providerStatuses,
|
|
2505
|
+
buildCustomClientToolStatus(this.activeProxyPort()),
|
|
2506
|
+
];
|
|
2507
|
+
const configuredCount = clients.filter((client) => client.configured).length;
|
|
2508
|
+
const detectedCount = clients.filter((client) => client.detected && client.status !== "manual").length;
|
|
2509
|
+
logger.info("provider.status.requested", "provider status requested", {
|
|
2510
|
+
clientCount: clients.length,
|
|
2511
|
+
configuredCount,
|
|
2512
|
+
detectedCount
|
|
2513
|
+
});
|
|
2514
|
+
res.status(200).json({
|
|
2515
|
+
clients,
|
|
2516
|
+
summary: {
|
|
2517
|
+
configuredCount,
|
|
2518
|
+
detectedCount,
|
|
2519
|
+
totalCount: clients.length,
|
|
2520
|
+
installCommand: "tb init"
|
|
2521
|
+
}
|
|
2522
|
+
});
|
|
2523
|
+
} catch (error: unknown) {
|
|
2524
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2525
|
+
logger.warn("provider.status.failed", "provider status failed", { errorMessage });
|
|
2526
|
+
res.status(400).json({
|
|
2527
|
+
error: {
|
|
2528
|
+
code: "provider_status_failed",
|
|
2529
|
+
message: errorMessage
|
|
2530
|
+
}
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
});
|
|
2534
|
+
|
|
1858
2535
|
controlApp.post("/providers/install/preview", (req, res) => {
|
|
1859
2536
|
try {
|
|
1860
2537
|
const changes = previewProviderInstall({
|
|
@@ -1932,6 +2609,172 @@ export class TokenbuddyDaemon {
|
|
|
1932
2609
|
}
|
|
1933
2610
|
});
|
|
1934
2611
|
|
|
2612
|
+
// ─────────────────────────────────────────────────────────────────
|
|
2613
|
+
// tb-ui v1: 控制平面写端点(PR-0)
|
|
2614
|
+
// 所有端点都热生效,不需重启 daemon。改完 store 即下次推理生效。
|
|
2615
|
+
// ─────────────────────────────────────────────────────────────────
|
|
2616
|
+
|
|
2617
|
+
// 1) GET /routing/strategy — 读当前路由策略 + 来源
|
|
2618
|
+
controlApp.get("/routing/strategy", (req, res) => {
|
|
2619
|
+
try {
|
|
2620
|
+
const stored = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)?.config;
|
|
2621
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2622
|
+
const source: "store" | "config" | "default" =
|
|
2623
|
+
stored !== undefined ? "store" : this.config.sellerRouting ? "config" : "default";
|
|
2624
|
+
logger.info("routing.strategy.read", "routing strategy read", {
|
|
2625
|
+
source,
|
|
2626
|
+
mode: current.mode,
|
|
2627
|
+
scorer: current.scorer
|
|
2628
|
+
});
|
|
2629
|
+
res.status(200).json({ strategy: current, source });
|
|
2630
|
+
} catch (error: unknown) {
|
|
2631
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2632
|
+
logger.warn("routing.strategy.read_failed", "routing strategy read failed", { errorMessage });
|
|
2633
|
+
res.status(500).json({ error: { code: "routing_strategy_read_failed", message: errorMessage } });
|
|
2634
|
+
}
|
|
2635
|
+
});
|
|
2636
|
+
|
|
2637
|
+
// 2) GET /routing/preview — 算「假如改完会怎样」,不改 state
|
|
2638
|
+
// query: modelId? protocol? paymentMethod? mode? scorer? sellerId? sellerIds?(逗号分隔)
|
|
2639
|
+
controlApp.get("/routing/preview", (req, res) => {
|
|
2640
|
+
try {
|
|
2641
|
+
const override = buildRoutingConfigFromQuery(req.query);
|
|
2642
|
+
const result = this.buildRoutingPreview({
|
|
2643
|
+
modelId: typeof req.query.modelId === "string" ? req.query.modelId : undefined,
|
|
2644
|
+
protocol: typeof req.query.protocol === "string" ? req.query.protocol : undefined,
|
|
2645
|
+
paymentMethod: typeof req.query.paymentMethod === "string" ? req.query.paymentMethod : undefined,
|
|
2646
|
+
routing: override ?? undefined
|
|
2647
|
+
});
|
|
2648
|
+
if ("error" in result.plan) {
|
|
2649
|
+
res.status(409).json({
|
|
2650
|
+
error: { code: result.plan.error, message: `cannot preview routing: ${result.plan.error}` },
|
|
2651
|
+
modelId: result.modelId,
|
|
2652
|
+
protocol: result.protocol,
|
|
2653
|
+
paymentMethod: result.paymentMethod
|
|
2654
|
+
});
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
res.status(200).json({
|
|
2658
|
+
modelId: result.modelId,
|
|
2659
|
+
protocol: result.protocol,
|
|
2660
|
+
paymentMethod: result.paymentMethod,
|
|
2661
|
+
plan: result.plan
|
|
2662
|
+
});
|
|
2663
|
+
} catch (error: unknown) {
|
|
2664
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2665
|
+
logger.warn("routing.preview.failed", "routing preview failed", { errorMessage });
|
|
2666
|
+
res.status(400).json({ error: { code: "routing_preview_failed", message: errorMessage } });
|
|
2667
|
+
}
|
|
2668
|
+
});
|
|
2669
|
+
|
|
2670
|
+
// 3) PUT /routing/strategy — 写策略 + 热更新 + 返回 preview
|
|
2671
|
+
controlApp.put("/routing/strategy", (req, res) => {
|
|
2672
|
+
try {
|
|
2673
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
2674
|
+
const normalized = normalizeSellerRoutingConfig(body);
|
|
2675
|
+
// 必填字段再次校验(normalize 会回退 default,但 PUT 必须显式)
|
|
2676
|
+
assertSellerRoutingConfig(normalized);
|
|
2677
|
+
this.tokenStore.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, normalized);
|
|
2678
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2679
|
+
logger.info("routing.strategy.applied", "routing strategy applied", {
|
|
2680
|
+
mode: current.mode,
|
|
2681
|
+
scorer: current.scorer,
|
|
2682
|
+
sellerId: current.sellerId,
|
|
2683
|
+
sellerIds: current.sellerIds
|
|
2684
|
+
});
|
|
2685
|
+
const preview = this.buildRoutingPreview({ routing: current });
|
|
2686
|
+
const previewPayload = "error" in preview.plan
|
|
2687
|
+
? { error: preview.plan.error }
|
|
2688
|
+
: {
|
|
2689
|
+
modelId: preview.modelId,
|
|
2690
|
+
protocol: preview.protocol,
|
|
2691
|
+
paymentMethod: preview.paymentMethod,
|
|
2692
|
+
...preview.plan
|
|
2693
|
+
};
|
|
2694
|
+
res.status(200).json({ applied: true, strategy: current, preview: previewPayload });
|
|
2695
|
+
} catch (error: unknown) {
|
|
2696
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2697
|
+
logger.warn("routing.strategy.apply_failed", "routing strategy apply failed", { errorMessage });
|
|
2698
|
+
res.status(400).json({ error: { code: "routing_strategy_apply_failed", message: errorMessage } });
|
|
2699
|
+
}
|
|
2700
|
+
});
|
|
2701
|
+
|
|
2702
|
+
// 4) PUT /prewarm/focus-set — 设置 explicit focus set(覆盖 config.warmupModels / env)
|
|
2703
|
+
// body: { models: ["claude-3-5-sonnet", "gpt-4o"], clear?: false }
|
|
2704
|
+
// clear=true 时 models 数组可省略;表示回退 env / historical
|
|
2705
|
+
controlApp.put("/prewarm/focus-set", (req, res) => {
|
|
2706
|
+
try {
|
|
2707
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
2708
|
+
const clear = body.clear === true;
|
|
2709
|
+
if (clear) {
|
|
2710
|
+
const result = this.applyFocusSet(null);
|
|
2711
|
+
res.status(200).json({ ok: true, ...result });
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
if (!Array.isArray(body.models)) {
|
|
2715
|
+
res.status(400).json({
|
|
2716
|
+
error: { code: "invalid_focus_set", message: "focus-set body must have a string[] `models` field, or `clear: true`" }
|
|
2717
|
+
});
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
const models = body.models
|
|
2721
|
+
.filter((m): m is string => typeof m === "string")
|
|
2722
|
+
.map((m) => m.trim())
|
|
2723
|
+
.filter(Boolean);
|
|
2724
|
+
const result = this.applyFocusSet(models);
|
|
2725
|
+
res.status(200).json({ ok: true, ...result });
|
|
2726
|
+
} catch (error: unknown) {
|
|
2727
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2728
|
+
logger.warn("focus_set.apply_failed", "focus set apply failed", { errorMessage });
|
|
2729
|
+
res.status(400).json({ error: { code: "focus_set_apply_failed", message: errorMessage } });
|
|
2730
|
+
}
|
|
2731
|
+
});
|
|
2732
|
+
|
|
2733
|
+
// 5) POST /daemon/restart — 优雅重启 tb-proxyd(调子进程 `tb daemon restart`)
|
|
2734
|
+
// 现有 CLI 子命令(cli.ts:1129)。detached 模式让子进程独立于 daemon 生命周期。
|
|
2735
|
+
controlApp.post("/daemon/restart", (req, res) => {
|
|
2736
|
+
try {
|
|
2737
|
+
const child = spawn("tb", ["daemon", "restart"], {
|
|
2738
|
+
detached: true,
|
|
2739
|
+
stdio: "ignore"
|
|
2740
|
+
});
|
|
2741
|
+
child.unref();
|
|
2742
|
+
logger.info("daemon.restart.scheduled", "daemon restart scheduled via tb CLI");
|
|
2743
|
+
res.status(202).json({ ok: true, message: "restart scheduled" });
|
|
2744
|
+
} catch (error: unknown) {
|
|
2745
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2746
|
+
logger.warn("daemon.restart.failed", "daemon restart failed", { errorMessage });
|
|
2747
|
+
res.status(500).json({ error: { code: "daemon_restart_failed", message: errorMessage } });
|
|
2748
|
+
}
|
|
2749
|
+
});
|
|
2750
|
+
|
|
2751
|
+
this.ensureClawtipStaticAssets();
|
|
2752
|
+
controlApp.use(CLAWTIP_STATIC_ROUTE, express.static(this.clawtipStaticDir(), { index: false, fallthrough: false }));
|
|
2753
|
+
|
|
2754
|
+
// ────────────────────────────────────────────────────────────
|
|
2755
|
+
// tb-ui v1: 静态托管 SPA(dist 由 `npm run build --workspace tb-ui` 生成)
|
|
2756
|
+
// 必须在所有 API 路由**之后**才挂载,这样:
|
|
2757
|
+
// - `/health` `/ledger/purchases` 等 API 路径仍由上面 17+ 个端点处理
|
|
2758
|
+
// - 真实静态文件(`/index.html` `/assets/index-abc.js` 等)由 express.static 服务
|
|
2759
|
+
// - 未匹配路径(`/overview` `/routing` 等 React Router 路径)走 SPA fallback 回 index.html
|
|
2760
|
+
// ────────────────────────────────────────────────────────────
|
|
2761
|
+
const uiDir = resolveUiDir();
|
|
2762
|
+
if (uiDir && fs.existsSync(uiDir) && fs.existsSync(path.join(uiDir, "index.html"))) {
|
|
2763
|
+
controlApp.use(express.static(uiDir, { index: "index.html", fallthrough: true }));
|
|
2764
|
+
// SPA fallback: React Router 用 BrowserRouter(history mode),客户端路径如
|
|
2765
|
+
// /overview /routing /ledger 不对应任何文件,必须回 index.html 让 React Router 接管。
|
|
2766
|
+
// Express 已经按注册顺序匹配了所有 17+ 个 API 路由,这里只接住"什么都没匹配"的 GET。
|
|
2767
|
+
controlApp.get(/^(?!\/(?:health|status|payments|sellers|models|ledger|routing|prewarm|v1\.2|daemon|providers|static)\/).*/, (_req, res) => {
|
|
2768
|
+
res.sendFile(path.join(uiDir, "index.html"));
|
|
2769
|
+
});
|
|
2770
|
+
logger.info("ui.static.attached", "tb-ui dist attached to control plane SPA fallback", { uiDir });
|
|
2771
|
+
} else {
|
|
2772
|
+
logger.warn("ui.static.missing", "tb-ui dist not found; only control plane API will be served", {
|
|
2773
|
+
uiDir,
|
|
2774
|
+
hint: "run `npm run build --workspace tb-ui` then restart"
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
|
|
1935
2778
|
this.controlServer = controlApp.listen(this.config.controlPort);
|
|
1936
2779
|
|
|
1937
2780
|
// 2. Proxy Plane Server (17821)
|
|
@@ -1999,9 +2842,13 @@ export class TokenbuddyDaemon {
|
|
|
1999
2842
|
/**
|
|
2000
2843
|
* v1.2 §18.4: build the focus set from the explicit config, the env
|
|
2001
2844
|
* override, and the historical usage in the buyer store. The order of
|
|
2002
|
-
* precedence: explicit
|
|
2845
|
+
* precedence: explicit `currentFocusSet` (set via `PUT /prewarm/focus-set`)
|
|
2846
|
+
* > explicit config > env > historical > empty.
|
|
2003
2847
|
*/
|
|
2004
2848
|
private resolveFocusSet(): string[] {
|
|
2849
|
+
if (this.currentFocusSet !== null) {
|
|
2850
|
+
return this.currentFocusSet;
|
|
2851
|
+
}
|
|
2005
2852
|
const explicit = this.config.warmupModels ?? [];
|
|
2006
2853
|
if (explicit.length > 0) {
|
|
2007
2854
|
return explicit;
|
|
@@ -2014,6 +2861,93 @@ export class TokenbuddyDaemon {
|
|
|
2014
2861
|
return this.tokenStore.recentModels(7, 5);
|
|
2015
2862
|
}
|
|
2016
2863
|
|
|
2864
|
+
/**
|
|
2865
|
+
* tb-ui v1 `PUT /prewarm/focus-set` 调用的统一入口。`models === null`
|
|
2866
|
+
* 表示「清除 explicit focus set,回退 env/historical」。
|
|
2867
|
+
* 写 store + 触发 `runRoutingPrewarmSweep`,**热生效**(不需重启 daemon)。
|
|
2868
|
+
*/
|
|
2869
|
+
public applyFocusSet(models: string[] | null): { focusSet: string[]; source: "explicit" | "env" | "historical" | "empty" } {
|
|
2870
|
+
if (models === null) {
|
|
2871
|
+
this.tokenStore.removeDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY);
|
|
2872
|
+
this.currentFocusSet = null;
|
|
2873
|
+
} else {
|
|
2874
|
+
const deduped = Array.from(new Set(models.map((m) => m.trim()).filter(Boolean)));
|
|
2875
|
+
this.tokenStore.saveDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY, { models: deduped });
|
|
2876
|
+
this.currentFocusSet = deduped;
|
|
2877
|
+
}
|
|
2878
|
+
// 触发路由重读 + 预热 sweep(如果当前 routing 已是焦点集合的依赖)。
|
|
2879
|
+
this.refreshSellerRoutingConfig();
|
|
2880
|
+
const focusSet = this.resolveFocusSet();
|
|
2881
|
+
const source: "explicit" | "env" | "historical" | "empty" =
|
|
2882
|
+
this.currentFocusSet !== null
|
|
2883
|
+
? "explicit"
|
|
2884
|
+
: (this.config.warmupModels?.length ?? 0) > 0
|
|
2885
|
+
? "explicit"
|
|
2886
|
+
: (process.env.TB_BUYER_WARMUP_MODELS?.trim() ?? "").length > 0
|
|
2887
|
+
? "env"
|
|
2888
|
+
: focusSet.length > 0
|
|
2889
|
+
? "historical"
|
|
2890
|
+
: "empty";
|
|
2891
|
+
logger.info("focus_set.applied", "explicit focus set applied", {
|
|
2892
|
+
source,
|
|
2893
|
+
focusSetSize: focusSet.length,
|
|
2894
|
+
focusSet: focusSet.slice(0, 20)
|
|
2895
|
+
});
|
|
2896
|
+
return { focusSet, source };
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
/**
|
|
2900
|
+
* tb-ui v1 `GET /routing/preview` 和 `PUT /routing/strategy` 复用的 preview 计算。
|
|
2901
|
+
* 接受任意 routing 覆盖(来自 request body)算「假如改成这个,路由会是啥」。
|
|
2902
|
+
* 不修改任何内部 state,**纯函数式**。
|
|
2903
|
+
*/
|
|
2904
|
+
public buildRoutingPreview(input: {
|
|
2905
|
+
modelId?: string;
|
|
2906
|
+
protocol?: string;
|
|
2907
|
+
paymentMethod?: string;
|
|
2908
|
+
routing?: Partial<BuyerSellerRoutingConfig>;
|
|
2909
|
+
}): { modelId: string; protocol: string; paymentMethod: string; plan: SellerRoutePlan | { error: string } } {
|
|
2910
|
+
const registry = this.lastRegistrySnapshot;
|
|
2911
|
+
const focusFirst = this.resolveFocusSet()[0];
|
|
2912
|
+
const registryFirst = registry?.sellers[0]?.models?.[0];
|
|
2913
|
+
const modelId = input.modelId?.trim() || focusFirst || registryFirst || "";
|
|
2914
|
+
const protocol = input.protocol?.trim() || "chat_completions";
|
|
2915
|
+
const paymentMethod = input.paymentMethod?.trim() || this.defaultPaymentMethod() || "clawtip";
|
|
2916
|
+
if (!modelId) {
|
|
2917
|
+
return { modelId, protocol, paymentMethod, plan: { error: "no_focus_model_available" } };
|
|
2918
|
+
}
|
|
2919
|
+
if (!registry) {
|
|
2920
|
+
return { modelId, protocol, paymentMethod, plan: { error: "registry_not_loaded" } };
|
|
2921
|
+
}
|
|
2922
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2923
|
+
const routing: BuyerSellerRoutingConfig = input.routing
|
|
2924
|
+
? mergeSellerRoutingConfig(current, input.routing)
|
|
2925
|
+
: current;
|
|
2926
|
+
const resolvedRouting = resolveSellerRoutingForModel(routing, modelId);
|
|
2927
|
+
const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
|
|
2928
|
+
this.sellerPool.ensureRegistrySellers(registrySellers);
|
|
2929
|
+
const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
|
|
2930
|
+
const plan = planSellerRouteSet({
|
|
2931
|
+
modelId,
|
|
2932
|
+
protocol,
|
|
2933
|
+
paymentMethod,
|
|
2934
|
+
registrySellers,
|
|
2935
|
+
routing: resolvedRouting,
|
|
2936
|
+
prewarmCandidates: this.prewarmCache.get(modelId, protocol, paymentMethod)?.candidates,
|
|
2937
|
+
sellerMetrics: Array.from(poolById.values()).map((entry) => ({
|
|
2938
|
+
sellerId: entry.sellerId,
|
|
2939
|
+
healthScore: entry.healthScore,
|
|
2940
|
+
avgLatencyMs: entry.avgLatencyMs,
|
|
2941
|
+
ttftMs: entry.ttftMs,
|
|
2942
|
+
avgInferenceMs: entry.avgInferenceMs,
|
|
2943
|
+
circuit: entry.circuit,
|
|
2944
|
+
capacityBlockedUntil: entry.capacityBlockedUntil
|
|
2945
|
+
})),
|
|
2946
|
+
now: Date.now()
|
|
2947
|
+
});
|
|
2948
|
+
return { modelId, protocol, paymentMethod, plan };
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2017
2951
|
private async runStartupPrewarmSweep(): Promise<void> {
|
|
2018
2952
|
const focusSet = this.resolveFocusSet();
|
|
2019
2953
|
if (focusSet.length === 0) {
|
|
@@ -2029,7 +2963,7 @@ export class TokenbuddyDaemon {
|
|
|
2029
2963
|
await this.prewarmScheduler.runStartupPrewarm(
|
|
2030
2964
|
focusSet.map((modelId) => ({
|
|
2031
2965
|
modelId,
|
|
2032
|
-
protocol: this.resolvePrewarmProtocol(modelId)
|
|
2966
|
+
protocol: this.resolvePrewarmProtocol(modelId, this.defaultPaymentMethod())
|
|
2033
2967
|
}))
|
|
2034
2968
|
);
|
|
2035
2969
|
} catch (err) {
|
|
@@ -2039,9 +2973,9 @@ export class TokenbuddyDaemon {
|
|
|
2039
2973
|
}
|
|
2040
2974
|
}
|
|
2041
2975
|
|
|
2042
|
-
private resolvePrewarmProtocol(modelId: string): string | undefined {
|
|
2976
|
+
private resolvePrewarmProtocol(modelId: string, paymentMethod = "clawtip"): string | undefined {
|
|
2043
2977
|
for (const protocol of ["chat_completions", "messages", "responses"]) {
|
|
2044
|
-
if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod
|
|
2978
|
+
if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod }).length > 0) {
|
|
2045
2979
|
return protocol;
|
|
2046
2980
|
}
|
|
2047
2981
|
}
|
|
@@ -2049,9 +2983,209 @@ export class TokenbuddyDaemon {
|
|
|
2049
2983
|
}
|
|
2050
2984
|
|
|
2051
2985
|
public stop() {
|
|
2986
|
+
if (this.clawtipActivationWaitCancelToken) {
|
|
2987
|
+
this.clawtipActivationWaitCancelToken.cancelled = true;
|
|
2988
|
+
}
|
|
2052
2989
|
if (this.controlServer) this.controlServer.close();
|
|
2053
2990
|
if (this.proxyServer) this.proxyServer.close();
|
|
2054
2991
|
void this.prewarmScheduler.stop();
|
|
2055
2992
|
this.tokenStore.close();
|
|
2056
2993
|
}
|
|
2994
|
+
|
|
2995
|
+
/**
|
|
2996
|
+
* @internal — test-only seam to inject a registry snapshot without
|
|
2997
|
+
* hitting the network. Used by `tests/control-plane-ui-endpoints.test.ts`
|
|
2998
|
+
* to drive `buildRoutingPreview` deterministically. Production code
|
|
2999
|
+
* must NOT call this; the real `fetchRegistry()` populates the snapshot.
|
|
3000
|
+
*/
|
|
3001
|
+
public setLastRegistrySnapshotForTest(snapshot: SellerRegistryDocument | null): void {
|
|
3002
|
+
this.lastRegistrySnapshot = snapshot;
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
function selectionModeForRouting(routing: BuyerSellerRoutingConfig): "auto" | "manual" {
|
|
3007
|
+
return routing.mode === "fullAuto" ? "auto" : "manual";
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
interface ClawtipQrResponse {
|
|
3011
|
+
ok: true;
|
|
3012
|
+
kind: "activate" | "recharge";
|
|
3013
|
+
method: "clawtip";
|
|
3014
|
+
orderNo?: string;
|
|
3015
|
+
amountFen?: number;
|
|
3016
|
+
qrImageUrl: string;
|
|
3017
|
+
sourceImagePath: string;
|
|
3018
|
+
staticImagePath: string;
|
|
3019
|
+
authUrl?: string;
|
|
3020
|
+
clawtipId?: string;
|
|
3021
|
+
orderFile?: string;
|
|
3022
|
+
resourceUrl?: string;
|
|
3023
|
+
walletConfigPath: string;
|
|
3024
|
+
walletConfigPresent: boolean;
|
|
3025
|
+
requiresWalletAuth: boolean;
|
|
3026
|
+
payCredentialWritten: boolean;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
function withLiveClawtipWalletState(payment: PaymentConfig, home?: string): PaymentConfig {
|
|
3030
|
+
if (payment.method !== "clawtip") {
|
|
3031
|
+
return payment;
|
|
3032
|
+
}
|
|
3033
|
+
const walletConfig = inspectOpenClawWalletConfig(home);
|
|
3034
|
+
return {
|
|
3035
|
+
...payment,
|
|
3036
|
+
enabled: payment.enabled && walletConfig.exists,
|
|
3037
|
+
config: {
|
|
3038
|
+
...(payment.config ?? {}),
|
|
3039
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
3040
|
+
walletConfigPresent: walletConfig.exists,
|
|
3041
|
+
nearbyWalletConfigPaths: walletConfig.alternatePaths
|
|
3042
|
+
}
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
function normalizeClawtipActivationPayment(bootstrap: ClawtipBootstrapResponse): ClawtipBootstrapPayment {
|
|
3047
|
+
if (!bootstrap.payment?.orderNo || !bootstrap.payment.indicator || !bootstrap.payment.resourceUrl) {
|
|
3048
|
+
throw new Error("ClawTip bootstrap response missing orderNo, indicator, or resourceUrl.");
|
|
3049
|
+
}
|
|
3050
|
+
return {
|
|
3051
|
+
orderNo: bootstrap.payment.orderNo,
|
|
3052
|
+
amountFen: bootstrap.payment.amountFen ?? bootstrap.activationFeeFen ?? 1,
|
|
3053
|
+
payTo: bootstrap.payment.payTo,
|
|
3054
|
+
encryptedData: bootstrap.payment.encryptedData,
|
|
3055
|
+
indicator: bootstrap.payment.indicator,
|
|
3056
|
+
slug: bootstrap.payment.slug,
|
|
3057
|
+
skillId: bootstrap.payment.skillId,
|
|
3058
|
+
description: bootstrap.payment.description,
|
|
3059
|
+
resourceUrl: bootstrap.payment.resourceUrl,
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
function safeQrExtension(filePath: string): ".png" | ".jpg" | ".jpeg" | ".webp" {
|
|
3064
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
3065
|
+
if (extension === ".jpg" || extension === ".jpeg" || extension === ".webp") {
|
|
3066
|
+
return extension;
|
|
3067
|
+
}
|
|
3068
|
+
return ".png";
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
function safeStaticFileSegment(value: string): string {
|
|
3072
|
+
return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "clawtip";
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
function readConfigString(config: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
3076
|
+
const value = config?.[key];
|
|
3077
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
function readConfigBoolean(config: Record<string, unknown> | undefined, key: string): boolean {
|
|
3081
|
+
return config?.[key] === true;
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
function selectedSellerIdForRouting(routing: BuyerSellerRoutingConfig): string | undefined {
|
|
3085
|
+
return routing.mode === "fixed" ? routing.sellerId : undefined;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
function routingKey(routing: BuyerSellerRoutingConfig): string {
|
|
3089
|
+
const fixedByModel = Object.entries(routing.fixedByModel ?? {})
|
|
3090
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
3091
|
+
.map(([modelId, sellerId]) => `${modelId}:${sellerId}`);
|
|
3092
|
+
return [
|
|
3093
|
+
routing.mode,
|
|
3094
|
+
routing.scorer,
|
|
3095
|
+
routing.sellerId ?? "",
|
|
3096
|
+
...(routing.sellerIds ?? []),
|
|
3097
|
+
...fixedByModel
|
|
3098
|
+
].join("\u0001");
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
/**
|
|
3102
|
+
* 从 query string 构造 `BuyerSellerRoutingConfig` override(用于 GET /routing/preview)。
|
|
3103
|
+
* 接受 `mode` / `scorer` / `sellerId` / `sellerIds`(逗号分隔)。
|
|
3104
|
+
* 任一字段缺失返回 `undefined`,调用方走「用当前 routing」分支。
|
|
3105
|
+
* mode / scorer 非法抛 400,由端点 handler 捕获。
|
|
3106
|
+
*/
|
|
3107
|
+
function buildRoutingConfigFromQuery(query: Record<string, unknown> | Request["query"]): Partial<BuyerSellerRoutingConfig> | undefined {
|
|
3108
|
+
const mode = typeof query.mode === "string" ? query.mode.trim() : "";
|
|
3109
|
+
const scorer = typeof query.scorer === "string" ? query.scorer.trim() : "";
|
|
3110
|
+
const sellerId = typeof query.sellerId === "string" ? query.sellerId.trim() : "";
|
|
3111
|
+
const sellerIdsRaw = typeof query.sellerIds === "string" ? query.sellerIds.trim() : "";
|
|
3112
|
+
const fixedByModelRaw = typeof query.fixedByModel === "string" ? query.fixedByModel.trim() : "";
|
|
3113
|
+
if (!mode && !scorer && !sellerId && !sellerIdsRaw && !fixedByModelRaw) {
|
|
3114
|
+
return undefined;
|
|
3115
|
+
}
|
|
3116
|
+
const override: Partial<BuyerSellerRoutingConfig> = {};
|
|
3117
|
+
if (mode) {
|
|
3118
|
+
if (mode !== "fixed" && mode !== "fixedSet" && mode !== "fullAuto") {
|
|
3119
|
+
throw new Error("mode must be fixed, fixedSet, or fullAuto");
|
|
3120
|
+
}
|
|
3121
|
+
override.mode = mode;
|
|
3122
|
+
}
|
|
3123
|
+
if (scorer) {
|
|
3124
|
+
if (scorer !== "speed" && scorer !== "discount" && scorer !== "balanced") {
|
|
3125
|
+
throw new Error("scorer must be speed, discount, or balanced");
|
|
3126
|
+
}
|
|
3127
|
+
override.scorer = scorer;
|
|
3128
|
+
}
|
|
3129
|
+
if (sellerId) {
|
|
3130
|
+
override.sellerId = sellerId;
|
|
3131
|
+
}
|
|
3132
|
+
if (sellerIdsRaw) {
|
|
3133
|
+
override.sellerIds = parseSellerIdList(sellerIdsRaw);
|
|
3134
|
+
}
|
|
3135
|
+
if (fixedByModelRaw) {
|
|
3136
|
+
override.fixedByModel = parseFixedByModel(fixedByModelRaw);
|
|
3137
|
+
}
|
|
3138
|
+
return override;
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
function sameSellerRouting(a: BuyerSellerRoutingConfig, b: BuyerSellerRoutingConfig): boolean {
|
|
3142
|
+
return a.mode === b.mode
|
|
3143
|
+
&& a.scorer === b.scorer
|
|
3144
|
+
&& optionalStringEqual(a.sellerId, b.sellerId)
|
|
3145
|
+
&& stringArraysEqual(a.sellerIds ?? [], b.sellerIds ?? [])
|
|
3146
|
+
&& fixedByModelEqual(a.fixedByModel ?? {}, b.fixedByModel ?? {});
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
function optionalStringEqual(a: string | undefined, b: string | undefined): boolean {
|
|
3150
|
+
return (a ?? "") === (b ?? "");
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
function stringArraysEqual(a: string[], b: string[]): boolean {
|
|
3154
|
+
if (a.length !== b.length) {
|
|
3155
|
+
return false;
|
|
3156
|
+
}
|
|
3157
|
+
return a.every((entry, index) => entry === b[index]);
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
function resolveSellerRoutingForModel(routing: BuyerSellerRoutingConfig, modelId: string): BuyerSellerRoutingConfig {
|
|
3161
|
+
if (routing.mode !== "fixed") {
|
|
3162
|
+
return routing;
|
|
3163
|
+
}
|
|
3164
|
+
const fixedSellerId = routing.fixedByModel?.[modelId]?.trim() || routing.sellerId;
|
|
3165
|
+
return {
|
|
3166
|
+
mode: "fixed",
|
|
3167
|
+
scorer: routing.scorer,
|
|
3168
|
+
sellerId: fixedSellerId,
|
|
3169
|
+
fixedByModel: routing.fixedByModel
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
function parseFixedByModel(value: string): Record<string, string> {
|
|
3174
|
+
const entries = value
|
|
3175
|
+
.split(",")
|
|
3176
|
+
.map((entry) => entry.split(":"))
|
|
3177
|
+
.filter((parts): parts is [string, string] => parts.length === 2)
|
|
3178
|
+
.map(([modelId, sellerId]) => [modelId.trim(), sellerId.trim()] as const)
|
|
3179
|
+
.filter(([modelId, sellerId]) => modelId.length > 0 && sellerId.length > 0);
|
|
3180
|
+
return Object.fromEntries(entries);
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
function fixedByModelEqual(a: Record<string, string>, b: Record<string, string>): boolean {
|
|
3184
|
+
const aEntries = Object.entries(a).sort(([left], [right]) => left.localeCompare(right));
|
|
3185
|
+
const bEntries = Object.entries(b).sort(([left], [right]) => left.localeCompare(right));
|
|
3186
|
+
return aEntries.length === bEntries.length
|
|
3187
|
+
&& aEntries.every(([modelId, sellerId], index) => {
|
|
3188
|
+
const [otherModelId, otherSellerId] = bEntries[index] ?? [];
|
|
3189
|
+
return modelId === otherModelId && sellerId === otherSellerId;
|
|
3190
|
+
});
|
|
2057
3191
|
}
|