@tokenbuddy/tokenbuddy 1.0.14 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/buyer-store.d.ts +23 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +31 -6
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/clawtip-bootstrap.d.ts +23 -0
- package/dist/src/clawtip-bootstrap.d.ts.map +1 -0
- package/dist/src/clawtip-bootstrap.js +47 -0
- package/dist/src/clawtip-bootstrap.js.map +1 -0
- package/dist/src/cli.d.ts +24 -33
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +157 -58
- package/dist/src/cli.js.map +1 -1
- package/dist/src/daemon.d.ts +79 -1
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +984 -23
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/model-index.d.ts +1 -1
- package/dist/src/model-index.d.ts.map +1 -1
- package/dist/src/model-index.js +4 -0
- package/dist/src/model-index.js.map +1 -1
- package/dist/src/prewarm-cache.d.ts +4 -0
- package/dist/src/prewarm-cache.d.ts.map +1 -1
- package/dist/src/prewarm-cache.js +2 -1
- package/dist/src/prewarm-cache.js.map +1 -1
- package/dist/src/prewarm-scheduler.d.ts +2 -0
- package/dist/src/prewarm-scheduler.d.ts.map +1 -1
- package/dist/src/prewarm-scheduler.js +4 -2
- package/dist/src/prewarm-scheduler.js.map +1 -1
- package/dist/src/route-failover.d.ts.map +1 -1
- package/dist/src/route-failover.js +10 -0
- package/dist/src/route-failover.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +17 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +15 -1
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-pool.d.ts +12 -1
- package/dist/src/seller-pool.d.ts.map +1 -1
- package/dist/src/seller-pool.js +61 -7
- package/dist/src/seller-pool.js.map +1 -1
- package/dist/src/seller-route-planner.d.ts +11 -1
- package/dist/src/seller-route-planner.d.ts.map +1 -1
- package/dist/src/seller-route-planner.js +21 -9
- package/dist/src/seller-route-planner.js.map +1 -1
- package/dist/src/seller-routing-config.d.ts +2 -0
- package/dist/src/seller-routing-config.d.ts.map +1 -1
- package/dist/src/seller-routing-config.js +11 -1
- package/dist/src/seller-routing-config.js.map +1 -1
- package/package.json +1 -1
- package/src/buyer-store.ts +70 -7
- package/src/clawtip-bootstrap.ts +64 -0
- package/src/cli.ts +201 -76
- package/src/daemon.ts +1132 -25
- package/src/model-index.ts +4 -1
- package/src/prewarm-cache.ts +6 -1
- package/src/prewarm-scheduler.ts +6 -2
- package/src/route-failover.ts +11 -0
- package/src/seller-catalog.ts +24 -1
- package/src/seller-pool.ts +69 -7
- package/src/seller-route-planner.ts +33 -11
- package/src/seller-routing-config.ts +14 -1
- package/static/clawtip/recharge.png +0 -0
- package/tests/control-plane-ui-endpoints.test.ts +559 -0
- package/tests/daemon-classify.test.ts +9 -0
- package/tests/model-index.test.ts +14 -0
- package/tests/route-failover.test.ts +16 -0
- package/tests/seller-catalog-utilities.test.ts +54 -0
- package/tests/seller-pool.test.ts +56 -0
- package/tests/seller-route-planner.test.ts +40 -0
- package/tests/seller-routing-config.test.ts +13 -0
- package/tests/tokenbuddy.test.ts +200 -7
package/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,95 @@ 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
|
+
interface ClientToolStatus {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
status: ProviderCandidate["status"] | "manual";
|
|
71
|
+
detected: boolean;
|
|
72
|
+
configured: boolean;
|
|
73
|
+
configPath?: string;
|
|
74
|
+
commandName?: string;
|
|
75
|
+
reason: string;
|
|
76
|
+
manualConfig?: {
|
|
77
|
+
openaiBaseUrl: string;
|
|
78
|
+
anthropicBaseUrl: string;
|
|
79
|
+
apiKey: string;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clientToolStatusFromProvider(provider: ProviderCandidate): ClientToolStatus {
|
|
84
|
+
return {
|
|
85
|
+
id: provider.id,
|
|
86
|
+
name: provider.name,
|
|
87
|
+
status: provider.status,
|
|
88
|
+
detected: provider.detected,
|
|
89
|
+
configured: provider.configured,
|
|
90
|
+
configPath: provider.configPath,
|
|
91
|
+
commandName: provider.commandName,
|
|
92
|
+
reason: provider.reason,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildCustomClientToolStatus(proxyPort: number): ClientToolStatus {
|
|
97
|
+
const openaiBaseUrl = `http://127.0.0.1:${proxyPort}/v1`;
|
|
98
|
+
const anthropicBaseUrl = `http://127.0.0.1:${proxyPort}`;
|
|
99
|
+
return {
|
|
100
|
+
id: "custom",
|
|
101
|
+
name: "Custom client",
|
|
102
|
+
status: "manual",
|
|
103
|
+
detected: true,
|
|
104
|
+
configured: false,
|
|
105
|
+
reason: `OpenAI-compatible: ${openaiBaseUrl} · Anthropic-compatible: ${anthropicBaseUrl}`,
|
|
106
|
+
manualConfig: {
|
|
107
|
+
openaiBaseUrl,
|
|
108
|
+
anthropicBaseUrl,
|
|
109
|
+
apiKey: "TOKENBUDDY_PROXY",
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 解析 `tb-ui` 构建产物目录(daemon 静态托管 SPA 用)。
|
|
116
|
+
* 优先级:env TB_UI_DIR > require.resolve("tb-ui") > 相对路径猜
|
|
117
|
+
* 找不到时记录 warning 仍允许 daemon 启动(纯 API 模式);静态请求会 404。
|
|
118
|
+
*/
|
|
119
|
+
function resolveUiDir(): string | undefined {
|
|
120
|
+
if (process.env.TB_UI_DIR) return process.env.TB_UI_DIR;
|
|
121
|
+
try {
|
|
122
|
+
// require.resolve 在 npm workspaces 装好时能找到 tb-ui/package.json
|
|
123
|
+
const pkgPath = require.resolve("tb-ui/package.json");
|
|
124
|
+
return path.join(path.dirname(pkgPath), "dist");
|
|
125
|
+
} catch {
|
|
126
|
+
// fallback: monorepo 假设 daemon dist 跟 tb-ui/dist 在同 root
|
|
127
|
+
// 用 __filename (jest cjs 编译下可用,生产 ESM 也兼容) 推回 src 再到 root
|
|
128
|
+
const here = typeof __filename !== "undefined" ? path.dirname(__filename) : process.cwd();
|
|
129
|
+
return path.resolve(here, "../../tb-ui/dist");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
43
132
|
|
|
44
133
|
/**
|
|
45
134
|
* buyer 端守护进程(`tb-proxyd`)的配置。
|
|
@@ -56,6 +145,21 @@ export interface DaemonConfig {
|
|
|
56
145
|
sellerRegistryUrl: string;
|
|
57
146
|
/** 路由策略覆盖(与 `TB_SELLER_ROUTING_*` env 合并) */
|
|
58
147
|
sellerRouting?: BuyerSellerRoutingConfig;
|
|
148
|
+
/** test-only override for ClawTip bootstrap payload fetch. */
|
|
149
|
+
clawtipBootstrapFetcher?: (bootstrapUrl: string) => Promise<ClawtipBootstrapResponse>;
|
|
150
|
+
/** test-only override for the existing tb init ClawTip activation runner. */
|
|
151
|
+
clawtipWalletBootstrapStarter?: (
|
|
152
|
+
payment: ClawtipBootstrapPayment,
|
|
153
|
+
options?: { home?: string }
|
|
154
|
+
) => Promise<{ orderFile: string; parsedOutput: ParsedClawtipOutput; payCredential?: string }>;
|
|
155
|
+
/** test-only override for waiting on the existing tb init ClawTip registration loop. */
|
|
156
|
+
clawtipActivationWaiter?: (options?: WaitForClawtipActivationOptions) => Promise<boolean>;
|
|
157
|
+
/** test-only override for bundled ClawTip static assets; false disables bundled assets. */
|
|
158
|
+
clawtipBundledStaticDir?: string | false;
|
|
159
|
+
/** test-only home override for OpenClaw wallet and QR discovery. */
|
|
160
|
+
clawtipHomeDir?: string;
|
|
161
|
+
/** test-only home override for provider/client configuration detection. */
|
|
162
|
+
providerHomeDir?: string;
|
|
59
163
|
/**
|
|
60
164
|
* v1.2 §18.4 预热 focus-set 覆盖。
|
|
61
165
|
* 缺省时由 BuyerStore 历史模型使用情况 + `TB_BUYER_WARMUP_MODELS` env 推导。
|
|
@@ -94,6 +198,24 @@ interface ProxyBodySummary {
|
|
|
94
198
|
temperaturePresent?: boolean;
|
|
95
199
|
}
|
|
96
200
|
|
|
201
|
+
interface SellerHealthBody {
|
|
202
|
+
upstream?: {
|
|
203
|
+
status?: unknown;
|
|
204
|
+
lastErrorClass?: unknown;
|
|
205
|
+
last_error_class?: unknown;
|
|
206
|
+
};
|
|
207
|
+
capacity?: {
|
|
208
|
+
activeConnections?: unknown;
|
|
209
|
+
active_connections?: unknown;
|
|
210
|
+
maxConnections?: unknown;
|
|
211
|
+
max_connections?: unknown;
|
|
212
|
+
queueDepth?: unknown;
|
|
213
|
+
queue_depth?: unknown;
|
|
214
|
+
maxQueueDepth?: unknown;
|
|
215
|
+
max_queue_depth?: unknown;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
97
219
|
interface SellerSettlementSummary {
|
|
98
220
|
requestId: string;
|
|
99
221
|
settledMicros: number;
|
|
@@ -227,6 +349,60 @@ function summarizeProxyBody(body: unknown): ProxyBodySummary {
|
|
|
227
349
|
};
|
|
228
350
|
}
|
|
229
351
|
|
|
352
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
353
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
354
|
+
return value;
|
|
355
|
+
}
|
|
356
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
357
|
+
const parsed = Number(value);
|
|
358
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function readErrorCode(bodyText: string): string | undefined {
|
|
364
|
+
try {
|
|
365
|
+
const parsed = JSON.parse(bodyText) as { error?: { code?: unknown }; code?: unknown };
|
|
366
|
+
const code = parsed.error?.code ?? parsed.code;
|
|
367
|
+
return typeof code === "string" ? code : undefined;
|
|
368
|
+
} catch {
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isBusyCapacityErrorBody(bodyText: string | undefined): boolean {
|
|
374
|
+
if (!bodyText) {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
return readErrorCode(bodyText) === ErrorCode.BusyCapacity;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function capacityBlockedUntilFromHealth(body: SellerHealthBody, now: number): number | undefined {
|
|
381
|
+
const capacity = body.capacity;
|
|
382
|
+
if (!capacity) {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
const activeConnections = finiteNumber(capacity.activeConnections ?? capacity.active_connections);
|
|
386
|
+
const maxConnections = finiteNumber(capacity.maxConnections ?? capacity.max_connections);
|
|
387
|
+
const queueDepth = finiteNumber(capacity.queueDepth ?? capacity.queue_depth);
|
|
388
|
+
const maxQueueDepth = finiteNumber(capacity.maxQueueDepth ?? capacity.max_queue_depth);
|
|
389
|
+
if (
|
|
390
|
+
activeConnections === undefined ||
|
|
391
|
+
maxConnections === undefined ||
|
|
392
|
+
queueDepth === undefined ||
|
|
393
|
+
maxQueueDepth === undefined
|
|
394
|
+
) {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
if (maxConnections <= 0) {
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
const connectionsFull = activeConnections >= maxConnections;
|
|
401
|
+
const queueUnavailable = maxQueueDepth <= 0;
|
|
402
|
+
const queueFull = queueUnavailable || queueDepth >= maxQueueDepth;
|
|
403
|
+
return connectionsFull && queueFull ? now + SELLER_CAPACITY_BLOCK_MS : undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
230
406
|
function reorderDefaultSellerFirst(sellers: RegistrySeller[], defaultSellerId: string | undefined): RegistrySeller[] {
|
|
231
407
|
if (!defaultSellerId) {
|
|
232
408
|
return sellers;
|
|
@@ -275,6 +451,15 @@ export class TokenbuddyDaemon {
|
|
|
275
451
|
private selectionMode: "auto" | "manual";
|
|
276
452
|
private selectedSellerId?: string;
|
|
277
453
|
private sellerRouting: BuyerSellerRoutingConfig;
|
|
454
|
+
private lastRoutingPrewarmKey?: string;
|
|
455
|
+
private readonly lazyPrewarmKeys = new Set<string>();
|
|
456
|
+
private clawtipActivationWait?: Promise<void>;
|
|
457
|
+
private clawtipActivationWaitCancelToken?: { cancelled: boolean };
|
|
458
|
+
/**
|
|
459
|
+
* tb-ui v1 控制平面 `PUT /prewarm/focus-set` 写入的 explicit focus set。
|
|
460
|
+
* 优先级最高;`null` 表示回退到 env / historical(与 `resolveFocusSet()` 原行为一致)。
|
|
461
|
+
*/
|
|
462
|
+
private currentFocusSet: string[] | null = null;
|
|
278
463
|
|
|
279
464
|
private activePurchases = new Map<string, Promise<string>>();
|
|
280
465
|
|
|
@@ -302,13 +487,20 @@ export class TokenbuddyDaemon {
|
|
|
302
487
|
this.tokenStore = new BuyerStore({ dbPath: config.dbPath });
|
|
303
488
|
const storedRouting = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)
|
|
304
489
|
?.config;
|
|
490
|
+
const storedFocusSet = this.tokenStore.getDaemonRuntimeConfig<{ models?: string[] }>(FOCUS_SET_CONFIG_KEY)
|
|
491
|
+
?.config;
|
|
305
492
|
this.config = config;
|
|
306
493
|
this.sellerRouting = mergeSellerRoutingConfig(
|
|
307
494
|
storedRouting,
|
|
308
495
|
config.sellerRouting
|
|
309
496
|
);
|
|
310
|
-
this.selectionMode = this.sellerRouting
|
|
311
|
-
this.selectedSellerId = this.sellerRouting
|
|
497
|
+
this.selectionMode = selectionModeForRouting(this.sellerRouting);
|
|
498
|
+
this.selectedSellerId = selectedSellerIdForRouting(this.sellerRouting);
|
|
499
|
+
// tb-ui v1: explicit focus set 优先于 env / historical
|
|
500
|
+
if (storedFocusSet && Array.isArray(storedFocusSet.models)) {
|
|
501
|
+
const deduped = Array.from(new Set(storedFocusSet.models.map((m) => m.trim()).filter(Boolean)));
|
|
502
|
+
this.currentFocusSet = deduped.length > 0 ? deduped : null;
|
|
503
|
+
}
|
|
312
504
|
// v1.2 §18.5: scheduler is created here (not in the field initializer)
|
|
313
505
|
// because it needs the config-derived prober + idle interval.
|
|
314
506
|
Object.assign(this, {
|
|
@@ -339,7 +531,22 @@ export class TokenbuddyDaemon {
|
|
|
339
531
|
if (!res.ok) {
|
|
340
532
|
return { ok: false, latencyMs: Date.now() - startedAt, httpStatus: res.status, errorMessage: `health returned ${res.status}` };
|
|
341
533
|
}
|
|
342
|
-
|
|
534
|
+
const now = Date.now();
|
|
535
|
+
const body = await res.json() as SellerHealthBody;
|
|
536
|
+
const upstream = body.upstream;
|
|
537
|
+
const upstreamErrorClass = upstream?.lastErrorClass ?? upstream?.last_error_class;
|
|
538
|
+
return {
|
|
539
|
+
ok: true,
|
|
540
|
+
latencyMs: now - startedAt,
|
|
541
|
+
httpStatus: res.status,
|
|
542
|
+
upstreamStatus: typeof upstream?.status === "string"
|
|
543
|
+
? upstream.status as "healthy" | "degraded" | "unhealthy" | "unknown"
|
|
544
|
+
: undefined,
|
|
545
|
+
upstreamErrorClass: typeof upstreamErrorClass === "string"
|
|
546
|
+
? upstreamErrorClass
|
|
547
|
+
: undefined,
|
|
548
|
+
capacityBlockedUntil: capacityBlockedUntilFromHealth(body, now)
|
|
549
|
+
};
|
|
343
550
|
} catch (err) {
|
|
344
551
|
const message = err instanceof Error ? err.message : String(err);
|
|
345
552
|
return { ok: false, latencyMs: 0, errorMessage: message };
|
|
@@ -357,6 +564,201 @@ export class TokenbuddyDaemon {
|
|
|
357
564
|
return typeof address === "object" && address ? address.port : this.config.proxyPort;
|
|
358
565
|
}
|
|
359
566
|
|
|
567
|
+
private clawtipStaticDir(): string {
|
|
568
|
+
return path.join(path.dirname(this.config.dbPath), "static", "clawtip");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private bundledClawtipStaticDir(): string | undefined {
|
|
572
|
+
if (this.config.clawtipBundledStaticDir === false) {
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
if (typeof this.config.clawtipBundledStaticDir === "string") {
|
|
576
|
+
return fs.existsSync(this.config.clawtipBundledStaticDir) ? this.config.clawtipBundledStaticDir : undefined;
|
|
577
|
+
}
|
|
578
|
+
const here = typeof __filename !== "undefined" ? path.dirname(__filename) : process.cwd();
|
|
579
|
+
const candidates = [
|
|
580
|
+
path.resolve(here, "../static/clawtip"),
|
|
581
|
+
path.resolve(here, "../../static/clawtip"),
|
|
582
|
+
path.resolve(process.cwd(), "packages/tokenbuddy-cli/static/clawtip")
|
|
583
|
+
];
|
|
584
|
+
return candidates.find((candidate) => fs.existsSync(candidate));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private clawtipPublicUrl(fileName: string): string {
|
|
588
|
+
return `${CLAWTIP_STATIC_ROUTE}/${encodeURIComponent(fileName)}`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private ensureClawtipStaticAssets(): void {
|
|
592
|
+
const outputDir = this.clawtipStaticDir();
|
|
593
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
594
|
+
const rechargeOutputPath = path.join(outputDir, CLAWTIP_RECHARGE_QR_FILE);
|
|
595
|
+
if (fs.existsSync(rechargeOutputPath)) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const bundledDir = this.bundledClawtipStaticDir();
|
|
599
|
+
const rechargeSourcePath = bundledDir ? path.join(bundledDir, CLAWTIP_RECHARGE_QR_FILE) : undefined;
|
|
600
|
+
if (rechargeSourcePath && fs.existsSync(rechargeSourcePath)) {
|
|
601
|
+
fs.copyFileSync(rechargeSourcePath, rechargeOutputPath);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private copyClawtipQrToStatic(mediaPath: string, orderNo: string): { fileName: string; url: string; path: string } {
|
|
606
|
+
if (!fs.existsSync(mediaPath)) {
|
|
607
|
+
throw new Error(`ClawTip QR image does not exist: ${mediaPath}`);
|
|
608
|
+
}
|
|
609
|
+
const extension = safeQrExtension(mediaPath);
|
|
610
|
+
const fileName = `${safeStaticFileSegment(orderNo)}-${Date.now()}${extension}`;
|
|
611
|
+
const outputDir = this.clawtipStaticDir();
|
|
612
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
613
|
+
const outputPath = path.join(outputDir, fileName);
|
|
614
|
+
fs.copyFileSync(mediaPath, outputPath);
|
|
615
|
+
return {
|
|
616
|
+
fileName,
|
|
617
|
+
path: outputPath,
|
|
618
|
+
url: this.clawtipPublicUrl(fileName)
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private async startClawtipActivationQr(): Promise<ClawtipQrResponse> {
|
|
623
|
+
const bootstrapUrl = process.env.TOKENBUDDY_BOOTSTRAP_URL || "https://tb-wallet-bootstrap.fly.dev";
|
|
624
|
+
const fetchBootstrap = this.config.clawtipBootstrapFetcher || fetchClawtipBootstrap;
|
|
625
|
+
const bootstrap = await fetchBootstrap(bootstrapUrl);
|
|
626
|
+
const payment = normalizeClawtipActivationPayment(bootstrap);
|
|
627
|
+
const startBootstrap = this.config.clawtipWalletBootstrapStarter || startClawtipWalletBootstrap;
|
|
628
|
+
const activation = await startBootstrap(payment, { home: this.config.clawtipHomeDir });
|
|
629
|
+
if (!activation.parsedOutput.mediaPath) {
|
|
630
|
+
throw new Error("ClawTip activation did not return a QR image.");
|
|
631
|
+
}
|
|
632
|
+
const staticQr = this.copyClawtipQrToStatic(activation.parsedOutput.mediaPath, payment.orderNo);
|
|
633
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
634
|
+
const existingPayment = this.tokenStore.getPayment("clawtip");
|
|
635
|
+
this.tokenStore.savePayment({
|
|
636
|
+
method: "clawtip",
|
|
637
|
+
enabled: walletConfig.exists,
|
|
638
|
+
isDefault: existingPayment?.isDefault ?? true,
|
|
639
|
+
config: {
|
|
640
|
+
...(existingPayment?.config ?? {}),
|
|
641
|
+
bootstrapUrl,
|
|
642
|
+
orderNo: payment.orderNo,
|
|
643
|
+
amountFen: payment.amountFen,
|
|
644
|
+
indicator: payment.indicator,
|
|
645
|
+
slug: payment.slug,
|
|
646
|
+
skillId: payment.skillId,
|
|
647
|
+
description: payment.description,
|
|
648
|
+
resourceUrl: payment.resourceUrl,
|
|
649
|
+
activationOrderFile: activation.orderFile,
|
|
650
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
651
|
+
walletConfigPresent: walletConfig.exists,
|
|
652
|
+
activationQrImagePath: activation.parsedOutput.mediaPath,
|
|
653
|
+
activationQrImageUrl: staticQr.url,
|
|
654
|
+
authUrl: activation.parsedOutput.authUrl,
|
|
655
|
+
clawtipId: activation.parsedOutput.clawtipId,
|
|
656
|
+
payCredentialWritten: Boolean(activation.payCredential),
|
|
657
|
+
activationCompletedBy: activation.payCredential
|
|
658
|
+
? (walletConfig.exists ? "payCredential+wallet-config" : "payCredential")
|
|
659
|
+
: walletConfig.exists ? "wallet-config" : "pending-wallet-scan"
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
this.scheduleClawtipActivationWait(activation.parsedOutput.clawtipId);
|
|
663
|
+
return {
|
|
664
|
+
ok: true,
|
|
665
|
+
kind: "activate",
|
|
666
|
+
method: "clawtip",
|
|
667
|
+
orderNo: payment.orderNo,
|
|
668
|
+
amountFen: payment.amountFen,
|
|
669
|
+
qrImageUrl: staticQr.url,
|
|
670
|
+
sourceImagePath: activation.parsedOutput.mediaPath,
|
|
671
|
+
staticImagePath: staticQr.path,
|
|
672
|
+
authUrl: activation.parsedOutput.authUrl,
|
|
673
|
+
clawtipId: activation.parsedOutput.clawtipId,
|
|
674
|
+
orderFile: activation.orderFile,
|
|
675
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
676
|
+
walletConfigPresent: walletConfig.exists,
|
|
677
|
+
requiresWalletAuth: activation.parsedOutput.requiresWalletAuth,
|
|
678
|
+
payCredentialWritten: Boolean(activation.payCredential)
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private scheduleClawtipActivationWait(clawtipId?: string): void {
|
|
683
|
+
if (this.clawtipActivationWait) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const cancelToken = { cancelled: false };
|
|
687
|
+
this.clawtipActivationWaitCancelToken = cancelToken;
|
|
688
|
+
const waitForActivation = this.config.clawtipActivationWaiter || waitForClawtipActivationConfirmation;
|
|
689
|
+
this.clawtipActivationWait = waitForActivation({
|
|
690
|
+
clawtipId,
|
|
691
|
+
inspectWalletConfig: () => inspectOpenClawWalletConfig(this.config.clawtipHomeDir),
|
|
692
|
+
isCancelled: () => cancelToken.cancelled,
|
|
693
|
+
cancel: () => undefined
|
|
694
|
+
})
|
|
695
|
+
.then((walletRegistered) => {
|
|
696
|
+
if (cancelToken.cancelled) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (!walletRegistered) {
|
|
700
|
+
logger.info("control.payment.clawtip.activation_wait.pending", "ClawTip activation wait ended before wallet registration");
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
704
|
+
const payment = this.tokenStore.getPayment("clawtip");
|
|
705
|
+
if (!payment || payment.method !== "clawtip") {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
this.tokenStore.savePayment({
|
|
709
|
+
...payment,
|
|
710
|
+
enabled: walletConfig.exists,
|
|
711
|
+
config: {
|
|
712
|
+
...(payment.config ?? {}),
|
|
713
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
714
|
+
walletConfigPresent: walletConfig.exists,
|
|
715
|
+
activationCompletedBy: walletConfig.exists
|
|
716
|
+
? "wallet-config"
|
|
717
|
+
: readConfigString(payment.config, "activationCompletedBy") ?? "pending-wallet-scan"
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
logger.info("control.payment.clawtip.activation_wait.completed", "ClawTip activation wait completed", {
|
|
721
|
+
walletRegistered,
|
|
722
|
+
walletConfigPresent: walletConfig.exists
|
|
723
|
+
});
|
|
724
|
+
})
|
|
725
|
+
.catch((error: unknown) => {
|
|
726
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
727
|
+
logger.warn("control.payment.clawtip.activation_wait.failed", "ClawTip activation wait failed", { errorMessage });
|
|
728
|
+
})
|
|
729
|
+
.finally(() => {
|
|
730
|
+
if (this.clawtipActivationWaitCancelToken === cancelToken) {
|
|
731
|
+
this.clawtipActivationWaitCancelToken = undefined;
|
|
732
|
+
this.clawtipActivationWait = undefined;
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private clawtipRechargeQr(): ClawtipQrResponse {
|
|
738
|
+
const payment = this.tokenStore.getPayment("clawtip");
|
|
739
|
+
const resourceUrl = readConfigString(payment?.config, "resourceUrl");
|
|
740
|
+
const orderNo = readConfigString(payment?.config, "orderNo") || "clawtip-recharge";
|
|
741
|
+
const mediaPath = path.join(this.clawtipStaticDir(), CLAWTIP_RECHARGE_QR_FILE);
|
|
742
|
+
if (!fs.existsSync(mediaPath)) {
|
|
743
|
+
throw new Error(`ClawTip fixed recharge QR image is missing: ${mediaPath}`);
|
|
744
|
+
}
|
|
745
|
+
const walletConfig = inspectOpenClawWalletConfig(this.config.clawtipHomeDir);
|
|
746
|
+
return {
|
|
747
|
+
ok: true,
|
|
748
|
+
kind: "recharge",
|
|
749
|
+
method: "clawtip",
|
|
750
|
+
orderNo,
|
|
751
|
+
qrImageUrl: this.clawtipPublicUrl(CLAWTIP_RECHARGE_QR_FILE),
|
|
752
|
+
sourceImagePath: mediaPath,
|
|
753
|
+
staticImagePath: mediaPath,
|
|
754
|
+
resourceUrl,
|
|
755
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
756
|
+
walletConfigPresent: walletConfig.exists,
|
|
757
|
+
requiresWalletAuth: false,
|
|
758
|
+
payCredentialWritten: readConfigBoolean(payment?.config, "payCredentialWritten")
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
360
762
|
// v1.2 §18.9: stale-cache fallback. The buyer remembers the last
|
|
361
763
|
// successfully fetched registry document and reuses it when the
|
|
362
764
|
// bootstrap returns 413 (`X-TokenBuddy-Registry-Too-Large: 1`). This
|
|
@@ -397,6 +799,7 @@ export class TokenbuddyDaemon {
|
|
|
397
799
|
}
|
|
398
800
|
|
|
399
801
|
private runtimeSummary() {
|
|
802
|
+
this.refreshSellerRoutingConfig();
|
|
400
803
|
return {
|
|
401
804
|
status: "running",
|
|
402
805
|
pid: process.pid,
|
|
@@ -502,7 +905,7 @@ export class TokenbuddyDaemon {
|
|
|
502
905
|
return payments.find((payment) => payment.isDefault)?.method || payments.find((payment) => payment.method === "mock")?.method;
|
|
503
906
|
}
|
|
504
907
|
|
|
505
|
-
private async selectSellerRoutes(endpoint: string, modelId: string): Promise<SellerRoute[]> {
|
|
908
|
+
private async selectSellerRoutes(endpoint: string, modelId: string): Promise<{ routes: SellerRoute[]; plan: SellerRoutePlan; paymentMethod: string }> {
|
|
506
909
|
const protocol = this.endpointProtocol(endpoint);
|
|
507
910
|
if (!protocol) {
|
|
508
911
|
throw new Error(`unsupported proxy endpoint: ${endpoint}`);
|
|
@@ -515,12 +918,14 @@ export class TokenbuddyDaemon {
|
|
|
515
918
|
// v1.2: registry is the source of truth for routing. We rebuild the
|
|
516
919
|
// model-index once per request (cheap; index lookup is in-memory) so
|
|
517
920
|
// the response always reflects the latest seller list. The previous
|
|
518
|
-
// "fetchSellerManifest per
|
|
921
|
+
// "fetchSellerManifest per request" path is removed in favor of
|
|
519
922
|
// pulling `models` directly off the registry entries.
|
|
520
923
|
const registry = await this.fetchRegistry();
|
|
521
924
|
|
|
522
|
-
const routing = this.
|
|
925
|
+
const routing = resolveSellerRoutingForModel(this.refreshSellerRoutingConfig(), modelId);
|
|
523
926
|
const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
|
|
927
|
+
this.sellerPool.ensureRegistrySellers(registrySellers);
|
|
928
|
+
this.scheduleLazyPrewarmIfNeeded(modelId, protocol, paymentMethod);
|
|
524
929
|
const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
|
|
525
930
|
const planned = planSellerRouteSet({
|
|
526
931
|
modelId,
|
|
@@ -533,8 +938,12 @@ export class TokenbuddyDaemon {
|
|
|
533
938
|
sellerId: entry.sellerId,
|
|
534
939
|
healthScore: entry.healthScore,
|
|
535
940
|
avgLatencyMs: entry.avgLatencyMs,
|
|
536
|
-
|
|
537
|
-
|
|
941
|
+
ttftMs: entry.ttftMs,
|
|
942
|
+
avgInferenceMs: entry.avgInferenceMs,
|
|
943
|
+
circuit: entry.circuit,
|
|
944
|
+
capacityBlockedUntil: entry.capacityBlockedUntil
|
|
945
|
+
})),
|
|
946
|
+
now: Date.now()
|
|
538
947
|
});
|
|
539
948
|
|
|
540
949
|
if (planned.routes.length === 0) {
|
|
@@ -564,7 +973,122 @@ export class TokenbuddyDaemon {
|
|
|
564
973
|
sellerCount: routes.length,
|
|
565
974
|
sellers: routes.map((route) => route.seller.id)
|
|
566
975
|
});
|
|
567
|
-
return routes;
|
|
976
|
+
return { routes, plan: planned, paymentMethod };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private refreshSellerRoutingConfig(): BuyerSellerRoutingConfig {
|
|
980
|
+
const storedRouting = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)
|
|
981
|
+
?.config;
|
|
982
|
+
const nextRouting = mergeSellerRoutingConfig(
|
|
983
|
+
storedRouting,
|
|
984
|
+
this.config.sellerRouting
|
|
985
|
+
);
|
|
986
|
+
|
|
987
|
+
if (!sameSellerRouting(this.sellerRouting, nextRouting)) {
|
|
988
|
+
const previous = this.sellerRouting;
|
|
989
|
+
this.sellerRouting = nextRouting;
|
|
990
|
+
this.selectionMode = selectionModeForRouting(nextRouting);
|
|
991
|
+
this.selectedSellerId = selectedSellerIdForRouting(nextRouting);
|
|
992
|
+
logger.info("routing.config.reloaded", "seller routing config reloaded", {
|
|
993
|
+
previousMode: previous.mode,
|
|
994
|
+
previousScorer: previous.scorer,
|
|
995
|
+
sellerRoutingMode: nextRouting.mode,
|
|
996
|
+
sellerRoutingScorer: nextRouting.scorer,
|
|
997
|
+
selectedSellerId: this.selectedSellerId
|
|
998
|
+
});
|
|
999
|
+
void this.runRoutingPrewarmSweep(nextRouting);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return this.sellerRouting;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
private async runRoutingPrewarmSweep(routing: BuyerSellerRoutingConfig): Promise<void> {
|
|
1006
|
+
const focusSet = this.resolveFocusSet();
|
|
1007
|
+
const routingPrewarmKey = `${routingKey(routing)}\u0001${focusSet.join("\u0001")}`;
|
|
1008
|
+
if (focusSet.length === 0) {
|
|
1009
|
+
logger.info("prewarm.routing.skipped", "no focus set configured after routing reload; relying on lazy prewarms", {
|
|
1010
|
+
sellerRoutingMode: routing.mode,
|
|
1011
|
+
sellerRoutingScorer: routing.scorer
|
|
1012
|
+
});
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (this.lastRoutingPrewarmKey === routingPrewarmKey) {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
this.lastRoutingPrewarmKey = routingPrewarmKey;
|
|
1019
|
+
logger.info("prewarm.routing.scheduled", "routing reload prewarm sweep scheduled", {
|
|
1020
|
+
sellerRoutingMode: routing.mode,
|
|
1021
|
+
sellerRoutingScorer: routing.scorer,
|
|
1022
|
+
focusSetSize: focusSet.length,
|
|
1023
|
+
focusSet: focusSet.slice(0, 20)
|
|
1024
|
+
});
|
|
1025
|
+
try {
|
|
1026
|
+
await this.fetchRegistry();
|
|
1027
|
+
const paymentMethod = this.defaultPaymentMethod();
|
|
1028
|
+
for (const modelId of focusSet) {
|
|
1029
|
+
this.schedulePrewarmForModel({
|
|
1030
|
+
modelId,
|
|
1031
|
+
reason: "explicit",
|
|
1032
|
+
protocol: this.resolvePrewarmProtocol(modelId, paymentMethod),
|
|
1033
|
+
paymentMethod
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
logger.warn("prewarm.routing.failed", "routing reload prewarm sweep failed", {
|
|
1038
|
+
sellerRoutingMode: routing.mode,
|
|
1039
|
+
sellerRoutingScorer: routing.scorer,
|
|
1040
|
+
errorMessage: err instanceof Error ? err.message : String(err)
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
private scheduleLazyPrewarmIfNeeded(modelId: string, protocol: string, paymentMethod: string): void {
|
|
1046
|
+
const freshness = this.prewarmCache.freshness(modelId, protocol, paymentMethod);
|
|
1047
|
+
if (freshness.present && !freshness.expired) {
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const key = prewarmKey(modelId, protocol, paymentMethod);
|
|
1051
|
+
if (this.lazyPrewarmKeys.has(key)) {
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
this.lazyPrewarmKeys.add(key);
|
|
1055
|
+
logger.info("prewarm.lazy.scheduled", "lazy prewarm scheduled for requested model", {
|
|
1056
|
+
modelId,
|
|
1057
|
+
protocol,
|
|
1058
|
+
paymentMethod,
|
|
1059
|
+
freshnessState: freshness.state
|
|
1060
|
+
});
|
|
1061
|
+
this.schedulePrewarmForModel({
|
|
1062
|
+
modelId,
|
|
1063
|
+
reason: "lazy",
|
|
1064
|
+
protocol,
|
|
1065
|
+
paymentMethod
|
|
1066
|
+
}).finally(() => {
|
|
1067
|
+
this.lazyPrewarmKeys.delete(key);
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private schedulePrewarmForModel(input: {
|
|
1072
|
+
modelId: string;
|
|
1073
|
+
reason: PrewarmReason;
|
|
1074
|
+
protocol?: string;
|
|
1075
|
+
paymentMethod?: string;
|
|
1076
|
+
}): Promise<unknown> {
|
|
1077
|
+
if (!input.protocol || !input.paymentMethod) {
|
|
1078
|
+
logger.warn("prewarm.schedule.skipped", "prewarm schedule skipped because protocol or payment method is missing", {
|
|
1079
|
+
modelId: input.modelId,
|
|
1080
|
+
reason: input.reason,
|
|
1081
|
+
protocol: input.protocol,
|
|
1082
|
+
paymentMethod: input.paymentMethod
|
|
1083
|
+
});
|
|
1084
|
+
return Promise.resolve();
|
|
1085
|
+
}
|
|
1086
|
+
return this.prewarmScheduler.schedulePrewarm({
|
|
1087
|
+
modelId: input.modelId,
|
|
1088
|
+
reason: input.reason,
|
|
1089
|
+
protocol: input.protocol,
|
|
1090
|
+
paymentMethod: input.paymentMethod
|
|
1091
|
+
});
|
|
568
1092
|
}
|
|
569
1093
|
|
|
570
1094
|
private failoverErrorMessage(error: unknown): string {
|
|
@@ -580,13 +1104,16 @@ export class TokenbuddyDaemon {
|
|
|
580
1104
|
* caller side because it short-circuits the failure path with a
|
|
581
1105
|
* re-purchase.
|
|
582
1106
|
*/
|
|
583
|
-
private classifyFailureStatus(status: number): FailureKind {
|
|
1107
|
+
private classifyFailureStatus(status: number, bodyText?: string): FailureKind {
|
|
584
1108
|
if (status === 401 || status === 403) {
|
|
585
1109
|
return "auth_invalid";
|
|
586
1110
|
}
|
|
587
1111
|
if (status === 402) {
|
|
588
1112
|
return "insufficient_funds";
|
|
589
1113
|
}
|
|
1114
|
+
if (status === 429 && isBusyCapacityErrorBody(bodyText)) {
|
|
1115
|
+
return "busy_capacity";
|
|
1116
|
+
}
|
|
590
1117
|
if (status === 400 || status === 404 || status === 422) {
|
|
591
1118
|
return "hard_4xx";
|
|
592
1119
|
}
|
|
@@ -770,7 +1297,16 @@ export class TokenbuddyDaemon {
|
|
|
770
1297
|
usage: UsageSummary,
|
|
771
1298
|
settlement: SellerSettlementSummary | undefined,
|
|
772
1299
|
prompt: string | undefined,
|
|
773
|
-
response?: string
|
|
1300
|
+
response?: string,
|
|
1301
|
+
extras?: {
|
|
1302
|
+
ttftMs?: number;
|
|
1303
|
+
fallbackCount?: number;
|
|
1304
|
+
routeReason?: string;
|
|
1305
|
+
falloverChain?: string[];
|
|
1306
|
+
upstreamStatus?: string;
|
|
1307
|
+
durationMs?: number;
|
|
1308
|
+
paymentMethod?: string;
|
|
1309
|
+
}
|
|
774
1310
|
): void {
|
|
775
1311
|
if (settlement) {
|
|
776
1312
|
this.tokenStore.reconcileTokenBalance({
|
|
@@ -800,7 +1336,14 @@ export class TokenbuddyDaemon {
|
|
|
800
1336
|
balanceSnapshotMicros: settlement?.remainingCreditMicros,
|
|
801
1337
|
balanceSource: settlement ? "seller_authoritative" : "estimated",
|
|
802
1338
|
prompt,
|
|
803
|
-
response
|
|
1339
|
+
response,
|
|
1340
|
+
ttftMs: extras?.ttftMs,
|
|
1341
|
+
fallbackCount: extras?.fallbackCount,
|
|
1342
|
+
routeReason: extras?.routeReason,
|
|
1343
|
+
falloverChain: extras?.falloverChain,
|
|
1344
|
+
upstreamStatus: extras?.upstreamStatus,
|
|
1345
|
+
durationMs: extras?.durationMs,
|
|
1346
|
+
paymentMethod: extras?.paymentMethod
|
|
804
1347
|
});
|
|
805
1348
|
logger.info("inference.ledger.recorded", "safe inference ledger recorded", {
|
|
806
1349
|
requestId: settlement?.requestId || requestId,
|
|
@@ -815,7 +1358,14 @@ export class TokenbuddyDaemon {
|
|
|
815
1358
|
promptTokens: usage.promptTokens,
|
|
816
1359
|
completionTokens: usage.completionTokens,
|
|
817
1360
|
balanceSnapshotMicros: settlement?.remainingCreditMicros,
|
|
818
|
-
balanceSource: settlement ? "seller_authoritative" : "estimated"
|
|
1361
|
+
balanceSource: settlement ? "seller_authoritative" : "estimated",
|
|
1362
|
+
ttftMs: extras?.ttftMs,
|
|
1363
|
+
fallbackCount: extras?.fallbackCount,
|
|
1364
|
+
routeReason: extras?.routeReason,
|
|
1365
|
+
falloverChain: extras?.falloverChain,
|
|
1366
|
+
upstreamStatus: extras?.upstreamStatus,
|
|
1367
|
+
durationMs: extras?.durationMs,
|
|
1368
|
+
paymentMethod: extras?.paymentMethod
|
|
819
1369
|
});
|
|
820
1370
|
}
|
|
821
1371
|
|
|
@@ -1316,6 +1866,10 @@ export class TokenbuddyDaemon {
|
|
|
1316
1866
|
|
|
1317
1867
|
private async forwardProxyRequest(endpoint: string, req: Request, res: Response): Promise<void> {
|
|
1318
1868
|
const startedAt = Date.now();
|
|
1869
|
+
let firstByteAt: number | null = null;
|
|
1870
|
+
const markFirstByte = (): void => {
|
|
1871
|
+
if (firstByteAt === null) firstByteAt = Date.now();
|
|
1872
|
+
};
|
|
1319
1873
|
const body = req.body || {};
|
|
1320
1874
|
const { requestedModelId, resolvedModelId } = this.resolveRouteModelId(endpoint, body);
|
|
1321
1875
|
const modelId = resolvedModelId;
|
|
@@ -1328,7 +1882,12 @@ export class TokenbuddyDaemon {
|
|
|
1328
1882
|
}
|
|
1329
1883
|
|
|
1330
1884
|
try {
|
|
1331
|
-
const routes = await this.selectSellerRoutes(endpoint, modelId);
|
|
1885
|
+
const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId);
|
|
1886
|
+
const upstreamStatusFromHeaders = (h: Headers): string | undefined => {
|
|
1887
|
+
const raw = h.get("x-tokenbuddy-upstream-status") || h.get("x-upstream-status");
|
|
1888
|
+
if (!raw) return undefined;
|
|
1889
|
+
return raw === "healthy" || raw === "degraded" || raw === "unhealthy" || raw === "unknown" ? raw : "unknown";
|
|
1890
|
+
};
|
|
1332
1891
|
let lastError: unknown;
|
|
1333
1892
|
|
|
1334
1893
|
for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
|
|
@@ -1336,6 +1895,7 @@ export class TokenbuddyDaemon {
|
|
|
1336
1895
|
const sellerKey = route.seller.id;
|
|
1337
1896
|
logger.info("route.selected", "seller route selected", {
|
|
1338
1897
|
sellerKey,
|
|
1898
|
+
sellerId: sellerKey,
|
|
1339
1899
|
model: modelId,
|
|
1340
1900
|
endpoint,
|
|
1341
1901
|
protocol: route.protocol,
|
|
@@ -1493,7 +2053,7 @@ export class TokenbuddyDaemon {
|
|
|
1493
2053
|
status: upstreamResponse.status,
|
|
1494
2054
|
durationMs: Date.now() - startedAt
|
|
1495
2055
|
});
|
|
1496
|
-
const kind: FailureKind = this.classifyFailureStatus(upstreamResponse.status);
|
|
2056
|
+
const kind: FailureKind = this.classifyFailureStatus(upstreamResponse.status, errorBody);
|
|
1497
2057
|
const decision = this.routeFailover.decide(
|
|
1498
2058
|
{
|
|
1499
2059
|
sellerId: sellerKey,
|
|
@@ -1568,6 +2128,7 @@ export class TokenbuddyDaemon {
|
|
|
1568
2128
|
// 缺 event: 行)由卖方修,buyer 不兜底。
|
|
1569
2129
|
const sellerChunk = settlementExtractor.push(chunk);
|
|
1570
2130
|
if (sellerChunk.length > 0) {
|
|
2131
|
+
markFirstByte();
|
|
1571
2132
|
res.write(sellerChunk);
|
|
1572
2133
|
}
|
|
1573
2134
|
}
|
|
@@ -1579,11 +2140,13 @@ export class TokenbuddyDaemon {
|
|
|
1579
2140
|
if (decoderTail.length > 0) {
|
|
1580
2141
|
const sellerTail = settlementExtractor.push(decoderTail);
|
|
1581
2142
|
if (sellerTail.length > 0) {
|
|
2143
|
+
markFirstByte();
|
|
1582
2144
|
res.write(sellerTail);
|
|
1583
2145
|
}
|
|
1584
2146
|
}
|
|
1585
2147
|
const settlementTrailing = settlementExtractor.finish();
|
|
1586
2148
|
if (settlementTrailing.downstream.length > 0) {
|
|
2149
|
+
markFirstByte();
|
|
1587
2150
|
res.write(settlementTrailing.downstream);
|
|
1588
2151
|
}
|
|
1589
2152
|
res.end();
|
|
@@ -1593,12 +2156,23 @@ export class TokenbuddyDaemon {
|
|
|
1593
2156
|
requestId,
|
|
1594
2157
|
{ promptTokens: 0, completionTokens: 0, billedMicros: Math.max(1, bytes) },
|
|
1595
2158
|
this.parseSellerSettlementSummary(upstreamResponse.headers) ?? settlementTrailing.settlement ?? settlementExtractor.current(),
|
|
1596
|
-
this.inferPromptForHash(body)
|
|
2159
|
+
this.inferPromptForHash(body),
|
|
2160
|
+
undefined,
|
|
2161
|
+
{
|
|
2162
|
+
ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
|
|
2163
|
+
fallbackCount: routeIndex,
|
|
2164
|
+
routeReason: plan.reason,
|
|
2165
|
+
falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
|
|
2166
|
+
upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
|
|
2167
|
+
durationMs: Date.now() - startedAt,
|
|
2168
|
+
paymentMethod
|
|
2169
|
+
}
|
|
1597
2170
|
);
|
|
1598
2171
|
return;
|
|
1599
2172
|
}
|
|
1600
2173
|
|
|
1601
2174
|
const responseBody = await upstreamResponse.text();
|
|
2175
|
+
markFirstByte();
|
|
1602
2176
|
res.send(responseBody);
|
|
1603
2177
|
const usage = this.readUsage(responseBody);
|
|
1604
2178
|
this.recordReconciledInference(
|
|
@@ -1608,7 +2182,16 @@ export class TokenbuddyDaemon {
|
|
|
1608
2182
|
usage,
|
|
1609
2183
|
this.parseSellerSettlementSummary(upstreamResponse.headers),
|
|
1610
2184
|
this.inferPromptForHash(body),
|
|
1611
|
-
responseBody
|
|
2185
|
+
responseBody,
|
|
2186
|
+
{
|
|
2187
|
+
ttftMs: firstByteAt ? firstByteAt - startedAt : undefined,
|
|
2188
|
+
fallbackCount: routeIndex,
|
|
2189
|
+
routeReason: plan.reason,
|
|
2190
|
+
falloverChain: routes.slice(0, routeIndex + 1).map((r) => r.seller.id),
|
|
2191
|
+
upstreamStatus: upstreamStatusFromHeaders(upstreamResponse.headers),
|
|
2192
|
+
durationMs: Date.now() - startedAt,
|
|
2193
|
+
paymentMethod
|
|
2194
|
+
}
|
|
1612
2195
|
);
|
|
1613
2196
|
return;
|
|
1614
2197
|
} catch (routeError: unknown) {
|
|
@@ -1710,10 +2293,42 @@ export class TokenbuddyDaemon {
|
|
|
1710
2293
|
controlApp.get("/payments", (req, res) => {
|
|
1711
2294
|
logger.info("control.payments.requested", "control payments requested", {});
|
|
1712
2295
|
res.status(200).json({
|
|
1713
|
-
payments: this.tokenStore.listPayments()
|
|
2296
|
+
payments: this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir))
|
|
1714
2297
|
});
|
|
1715
2298
|
});
|
|
1716
2299
|
|
|
2300
|
+
controlApp.post("/payments/clawtip/activate", async (req, res) => {
|
|
2301
|
+
try {
|
|
2302
|
+
const qr = await this.startClawtipActivationQr();
|
|
2303
|
+
logger.info("control.payment.clawtip.activate_qr.created", "ClawTip activation QR copied for tb-ui", {
|
|
2304
|
+
orderNo: qr.orderNo,
|
|
2305
|
+
qrImageUrl: qr.qrImageUrl,
|
|
2306
|
+
walletConfigPresent: qr.walletConfigPresent
|
|
2307
|
+
});
|
|
2308
|
+
res.status(200).json(qr);
|
|
2309
|
+
} catch (error: unknown) {
|
|
2310
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2311
|
+
logger.warn("control.payment.clawtip.activate_qr.failed", "ClawTip activation QR failed", { errorMessage });
|
|
2312
|
+
res.status(500).json({ error: { code: "clawtip_activate_qr_failed", message: errorMessage } });
|
|
2313
|
+
}
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
controlApp.post("/payments/clawtip/recharge", (req, res) => {
|
|
2317
|
+
try {
|
|
2318
|
+
const qr = this.clawtipRechargeQr();
|
|
2319
|
+
logger.info("control.payment.clawtip.recharge_qr.created", "ClawTip fixed recharge QR served for tb-ui", {
|
|
2320
|
+
orderNo: qr.orderNo,
|
|
2321
|
+
qrImageUrl: qr.qrImageUrl,
|
|
2322
|
+
walletConfigPresent: qr.walletConfigPresent
|
|
2323
|
+
});
|
|
2324
|
+
res.status(200).json(qr);
|
|
2325
|
+
} catch (error: unknown) {
|
|
2326
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2327
|
+
logger.warn("control.payment.clawtip.recharge_qr.failed", "ClawTip recharge QR failed", { errorMessage });
|
|
2328
|
+
res.status(500).json({ error: { code: "clawtip_recharge_qr_failed", message: errorMessage } });
|
|
2329
|
+
}
|
|
2330
|
+
});
|
|
2331
|
+
|
|
1717
2332
|
controlApp.get("/ledger/purchases", (req, res) => {
|
|
1718
2333
|
logger.info("control.ledger.requested", "control purchase ledger requested", {
|
|
1719
2334
|
ledger: "purchases"
|
|
@@ -1855,6 +2470,41 @@ export class TokenbuddyDaemon {
|
|
|
1855
2470
|
}
|
|
1856
2471
|
});
|
|
1857
2472
|
|
|
2473
|
+
controlApp.get("/providers/status", (_req, res) => {
|
|
2474
|
+
try {
|
|
2475
|
+
const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
|
|
2476
|
+
const clients = [
|
|
2477
|
+
...providerStatuses,
|
|
2478
|
+
buildCustomClientToolStatus(this.activeProxyPort()),
|
|
2479
|
+
];
|
|
2480
|
+
const configuredCount = clients.filter((client) => client.configured).length;
|
|
2481
|
+
const detectedCount = clients.filter((client) => client.detected && client.status !== "manual").length;
|
|
2482
|
+
logger.info("provider.status.requested", "provider status requested", {
|
|
2483
|
+
clientCount: clients.length,
|
|
2484
|
+
configuredCount,
|
|
2485
|
+
detectedCount
|
|
2486
|
+
});
|
|
2487
|
+
res.status(200).json({
|
|
2488
|
+
clients,
|
|
2489
|
+
summary: {
|
|
2490
|
+
configuredCount,
|
|
2491
|
+
detectedCount,
|
|
2492
|
+
totalCount: clients.length,
|
|
2493
|
+
installCommand: "tb init"
|
|
2494
|
+
}
|
|
2495
|
+
});
|
|
2496
|
+
} catch (error: unknown) {
|
|
2497
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2498
|
+
logger.warn("provider.status.failed", "provider status failed", { errorMessage });
|
|
2499
|
+
res.status(400).json({
|
|
2500
|
+
error: {
|
|
2501
|
+
code: "provider_status_failed",
|
|
2502
|
+
message: errorMessage
|
|
2503
|
+
}
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
|
|
1858
2508
|
controlApp.post("/providers/install/preview", (req, res) => {
|
|
1859
2509
|
try {
|
|
1860
2510
|
const changes = previewProviderInstall({
|
|
@@ -1932,6 +2582,172 @@ export class TokenbuddyDaemon {
|
|
|
1932
2582
|
}
|
|
1933
2583
|
});
|
|
1934
2584
|
|
|
2585
|
+
// ─────────────────────────────────────────────────────────────────
|
|
2586
|
+
// tb-ui v1: 控制平面写端点(PR-0)
|
|
2587
|
+
// 所有端点都热生效,不需重启 daemon。改完 store 即下次推理生效。
|
|
2588
|
+
// ─────────────────────────────────────────────────────────────────
|
|
2589
|
+
|
|
2590
|
+
// 1) GET /routing/strategy — 读当前路由策略 + 来源
|
|
2591
|
+
controlApp.get("/routing/strategy", (req, res) => {
|
|
2592
|
+
try {
|
|
2593
|
+
const stored = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)?.config;
|
|
2594
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2595
|
+
const source: "store" | "config" | "default" =
|
|
2596
|
+
stored !== undefined ? "store" : this.config.sellerRouting ? "config" : "default";
|
|
2597
|
+
logger.info("routing.strategy.read", "routing strategy read", {
|
|
2598
|
+
source,
|
|
2599
|
+
mode: current.mode,
|
|
2600
|
+
scorer: current.scorer
|
|
2601
|
+
});
|
|
2602
|
+
res.status(200).json({ strategy: current, source });
|
|
2603
|
+
} catch (error: unknown) {
|
|
2604
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2605
|
+
logger.warn("routing.strategy.read_failed", "routing strategy read failed", { errorMessage });
|
|
2606
|
+
res.status(500).json({ error: { code: "routing_strategy_read_failed", message: errorMessage } });
|
|
2607
|
+
}
|
|
2608
|
+
});
|
|
2609
|
+
|
|
2610
|
+
// 2) GET /routing/preview — 算「假如改完会怎样」,不改 state
|
|
2611
|
+
// query: modelId? protocol? paymentMethod? mode? scorer? sellerId? sellerIds?(逗号分隔)
|
|
2612
|
+
controlApp.get("/routing/preview", (req, res) => {
|
|
2613
|
+
try {
|
|
2614
|
+
const override = buildRoutingConfigFromQuery(req.query);
|
|
2615
|
+
const result = this.buildRoutingPreview({
|
|
2616
|
+
modelId: typeof req.query.modelId === "string" ? req.query.modelId : undefined,
|
|
2617
|
+
protocol: typeof req.query.protocol === "string" ? req.query.protocol : undefined,
|
|
2618
|
+
paymentMethod: typeof req.query.paymentMethod === "string" ? req.query.paymentMethod : undefined,
|
|
2619
|
+
routing: override ?? undefined
|
|
2620
|
+
});
|
|
2621
|
+
if ("error" in result.plan) {
|
|
2622
|
+
res.status(409).json({
|
|
2623
|
+
error: { code: result.plan.error, message: `cannot preview routing: ${result.plan.error}` },
|
|
2624
|
+
modelId: result.modelId,
|
|
2625
|
+
protocol: result.protocol,
|
|
2626
|
+
paymentMethod: result.paymentMethod
|
|
2627
|
+
});
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
res.status(200).json({
|
|
2631
|
+
modelId: result.modelId,
|
|
2632
|
+
protocol: result.protocol,
|
|
2633
|
+
paymentMethod: result.paymentMethod,
|
|
2634
|
+
plan: result.plan
|
|
2635
|
+
});
|
|
2636
|
+
} catch (error: unknown) {
|
|
2637
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2638
|
+
logger.warn("routing.preview.failed", "routing preview failed", { errorMessage });
|
|
2639
|
+
res.status(400).json({ error: { code: "routing_preview_failed", message: errorMessage } });
|
|
2640
|
+
}
|
|
2641
|
+
});
|
|
2642
|
+
|
|
2643
|
+
// 3) PUT /routing/strategy — 写策略 + 热更新 + 返回 preview
|
|
2644
|
+
controlApp.put("/routing/strategy", (req, res) => {
|
|
2645
|
+
try {
|
|
2646
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
2647
|
+
const normalized = normalizeSellerRoutingConfig(body);
|
|
2648
|
+
// 必填字段再次校验(normalize 会回退 default,但 PUT 必须显式)
|
|
2649
|
+
assertSellerRoutingConfig(normalized);
|
|
2650
|
+
this.tokenStore.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, normalized);
|
|
2651
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2652
|
+
logger.info("routing.strategy.applied", "routing strategy applied", {
|
|
2653
|
+
mode: current.mode,
|
|
2654
|
+
scorer: current.scorer,
|
|
2655
|
+
sellerId: current.sellerId,
|
|
2656
|
+
sellerIds: current.sellerIds
|
|
2657
|
+
});
|
|
2658
|
+
const preview = this.buildRoutingPreview({ routing: current });
|
|
2659
|
+
const previewPayload = "error" in preview.plan
|
|
2660
|
+
? { error: preview.plan.error }
|
|
2661
|
+
: {
|
|
2662
|
+
modelId: preview.modelId,
|
|
2663
|
+
protocol: preview.protocol,
|
|
2664
|
+
paymentMethod: preview.paymentMethod,
|
|
2665
|
+
...preview.plan
|
|
2666
|
+
};
|
|
2667
|
+
res.status(200).json({ applied: true, strategy: current, preview: previewPayload });
|
|
2668
|
+
} catch (error: unknown) {
|
|
2669
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2670
|
+
logger.warn("routing.strategy.apply_failed", "routing strategy apply failed", { errorMessage });
|
|
2671
|
+
res.status(400).json({ error: { code: "routing_strategy_apply_failed", message: errorMessage } });
|
|
2672
|
+
}
|
|
2673
|
+
});
|
|
2674
|
+
|
|
2675
|
+
// 4) PUT /prewarm/focus-set — 设置 explicit focus set(覆盖 config.warmupModels / env)
|
|
2676
|
+
// body: { models: ["claude-3-5-sonnet", "gpt-4o"], clear?: false }
|
|
2677
|
+
// clear=true 时 models 数组可省略;表示回退 env / historical
|
|
2678
|
+
controlApp.put("/prewarm/focus-set", (req, res) => {
|
|
2679
|
+
try {
|
|
2680
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
2681
|
+
const clear = body.clear === true;
|
|
2682
|
+
if (clear) {
|
|
2683
|
+
const result = this.applyFocusSet(null);
|
|
2684
|
+
res.status(200).json({ ok: true, ...result });
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
if (!Array.isArray(body.models)) {
|
|
2688
|
+
res.status(400).json({
|
|
2689
|
+
error: { code: "invalid_focus_set", message: "focus-set body must have a string[] `models` field, or `clear: true`" }
|
|
2690
|
+
});
|
|
2691
|
+
return;
|
|
2692
|
+
}
|
|
2693
|
+
const models = body.models
|
|
2694
|
+
.filter((m): m is string => typeof m === "string")
|
|
2695
|
+
.map((m) => m.trim())
|
|
2696
|
+
.filter(Boolean);
|
|
2697
|
+
const result = this.applyFocusSet(models);
|
|
2698
|
+
res.status(200).json({ ok: true, ...result });
|
|
2699
|
+
} catch (error: unknown) {
|
|
2700
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2701
|
+
logger.warn("focus_set.apply_failed", "focus set apply failed", { errorMessage });
|
|
2702
|
+
res.status(400).json({ error: { code: "focus_set_apply_failed", message: errorMessage } });
|
|
2703
|
+
}
|
|
2704
|
+
});
|
|
2705
|
+
|
|
2706
|
+
// 5) POST /daemon/restart — 优雅重启 tb-proxyd(调子进程 `tb daemon restart`)
|
|
2707
|
+
// 现有 CLI 子命令(cli.ts:1129)。detached 模式让子进程独立于 daemon 生命周期。
|
|
2708
|
+
controlApp.post("/daemon/restart", (req, res) => {
|
|
2709
|
+
try {
|
|
2710
|
+
const child = spawn("tb", ["daemon", "restart"], {
|
|
2711
|
+
detached: true,
|
|
2712
|
+
stdio: "ignore"
|
|
2713
|
+
});
|
|
2714
|
+
child.unref();
|
|
2715
|
+
logger.info("daemon.restart.scheduled", "daemon restart scheduled via tb CLI");
|
|
2716
|
+
res.status(202).json({ ok: true, message: "restart scheduled" });
|
|
2717
|
+
} catch (error: unknown) {
|
|
2718
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2719
|
+
logger.warn("daemon.restart.failed", "daemon restart failed", { errorMessage });
|
|
2720
|
+
res.status(500).json({ error: { code: "daemon_restart_failed", message: errorMessage } });
|
|
2721
|
+
}
|
|
2722
|
+
});
|
|
2723
|
+
|
|
2724
|
+
this.ensureClawtipStaticAssets();
|
|
2725
|
+
controlApp.use(CLAWTIP_STATIC_ROUTE, express.static(this.clawtipStaticDir(), { index: false, fallthrough: false }));
|
|
2726
|
+
|
|
2727
|
+
// ────────────────────────────────────────────────────────────
|
|
2728
|
+
// tb-ui v1: 静态托管 SPA(dist 由 `npm run build --workspace tb-ui` 生成)
|
|
2729
|
+
// 必须在所有 API 路由**之后**才挂载,这样:
|
|
2730
|
+
// - `/health` `/ledger/purchases` 等 API 路径仍由上面 17+ 个端点处理
|
|
2731
|
+
// - 真实静态文件(`/index.html` `/assets/index-abc.js` 等)由 express.static 服务
|
|
2732
|
+
// - 未匹配路径(`/overview` `/routing` 等 React Router 路径)走 SPA fallback 回 index.html
|
|
2733
|
+
// ────────────────────────────────────────────────────────────
|
|
2734
|
+
const uiDir = resolveUiDir();
|
|
2735
|
+
if (uiDir && fs.existsSync(uiDir) && fs.existsSync(path.join(uiDir, "index.html"))) {
|
|
2736
|
+
controlApp.use(express.static(uiDir, { index: "index.html", fallthrough: true }));
|
|
2737
|
+
// SPA fallback: React Router 用 BrowserRouter(history mode),客户端路径如
|
|
2738
|
+
// /overview /routing /ledger 不对应任何文件,必须回 index.html 让 React Router 接管。
|
|
2739
|
+
// Express 已经按注册顺序匹配了所有 17+ 个 API 路由,这里只接住"什么都没匹配"的 GET。
|
|
2740
|
+
controlApp.get(/^(?!\/(?:health|status|payments|sellers|models|ledger|routing|prewarm|v1\.2|daemon|providers|static)\/).*/, (_req, res) => {
|
|
2741
|
+
res.sendFile(path.join(uiDir, "index.html"));
|
|
2742
|
+
});
|
|
2743
|
+
logger.info("ui.static.attached", "tb-ui dist attached to control plane SPA fallback", { uiDir });
|
|
2744
|
+
} else {
|
|
2745
|
+
logger.warn("ui.static.missing", "tb-ui dist not found; only control plane API will be served", {
|
|
2746
|
+
uiDir,
|
|
2747
|
+
hint: "run `npm run build --workspace tb-ui` then restart"
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
|
|
1935
2751
|
this.controlServer = controlApp.listen(this.config.controlPort);
|
|
1936
2752
|
|
|
1937
2753
|
// 2. Proxy Plane Server (17821)
|
|
@@ -1999,9 +2815,13 @@ export class TokenbuddyDaemon {
|
|
|
1999
2815
|
/**
|
|
2000
2816
|
* v1.2 §18.4: build the focus set from the explicit config, the env
|
|
2001
2817
|
* override, and the historical usage in the buyer store. The order of
|
|
2002
|
-
* precedence: explicit
|
|
2818
|
+
* precedence: explicit `currentFocusSet` (set via `PUT /prewarm/focus-set`)
|
|
2819
|
+
* > explicit config > env > historical > empty.
|
|
2003
2820
|
*/
|
|
2004
2821
|
private resolveFocusSet(): string[] {
|
|
2822
|
+
if (this.currentFocusSet !== null) {
|
|
2823
|
+
return this.currentFocusSet;
|
|
2824
|
+
}
|
|
2005
2825
|
const explicit = this.config.warmupModels ?? [];
|
|
2006
2826
|
if (explicit.length > 0) {
|
|
2007
2827
|
return explicit;
|
|
@@ -2014,6 +2834,93 @@ export class TokenbuddyDaemon {
|
|
|
2014
2834
|
return this.tokenStore.recentModels(7, 5);
|
|
2015
2835
|
}
|
|
2016
2836
|
|
|
2837
|
+
/**
|
|
2838
|
+
* tb-ui v1 `PUT /prewarm/focus-set` 调用的统一入口。`models === null`
|
|
2839
|
+
* 表示「清除 explicit focus set,回退 env/historical」。
|
|
2840
|
+
* 写 store + 触发 `runRoutingPrewarmSweep`,**热生效**(不需重启 daemon)。
|
|
2841
|
+
*/
|
|
2842
|
+
public applyFocusSet(models: string[] | null): { focusSet: string[]; source: "explicit" | "env" | "historical" | "empty" } {
|
|
2843
|
+
if (models === null) {
|
|
2844
|
+
this.tokenStore.removeDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY);
|
|
2845
|
+
this.currentFocusSet = null;
|
|
2846
|
+
} else {
|
|
2847
|
+
const deduped = Array.from(new Set(models.map((m) => m.trim()).filter(Boolean)));
|
|
2848
|
+
this.tokenStore.saveDaemonRuntimeConfig(FOCUS_SET_CONFIG_KEY, { models: deduped });
|
|
2849
|
+
this.currentFocusSet = deduped;
|
|
2850
|
+
}
|
|
2851
|
+
// 触发路由重读 + 预热 sweep(如果当前 routing 已是焦点集合的依赖)。
|
|
2852
|
+
this.refreshSellerRoutingConfig();
|
|
2853
|
+
const focusSet = this.resolveFocusSet();
|
|
2854
|
+
const source: "explicit" | "env" | "historical" | "empty" =
|
|
2855
|
+
this.currentFocusSet !== null
|
|
2856
|
+
? "explicit"
|
|
2857
|
+
: (this.config.warmupModels?.length ?? 0) > 0
|
|
2858
|
+
? "explicit"
|
|
2859
|
+
: (process.env.TB_BUYER_WARMUP_MODELS?.trim() ?? "").length > 0
|
|
2860
|
+
? "env"
|
|
2861
|
+
: focusSet.length > 0
|
|
2862
|
+
? "historical"
|
|
2863
|
+
: "empty";
|
|
2864
|
+
logger.info("focus_set.applied", "explicit focus set applied", {
|
|
2865
|
+
source,
|
|
2866
|
+
focusSetSize: focusSet.length,
|
|
2867
|
+
focusSet: focusSet.slice(0, 20)
|
|
2868
|
+
});
|
|
2869
|
+
return { focusSet, source };
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
/**
|
|
2873
|
+
* tb-ui v1 `GET /routing/preview` 和 `PUT /routing/strategy` 复用的 preview 计算。
|
|
2874
|
+
* 接受任意 routing 覆盖(来自 request body)算「假如改成这个,路由会是啥」。
|
|
2875
|
+
* 不修改任何内部 state,**纯函数式**。
|
|
2876
|
+
*/
|
|
2877
|
+
public buildRoutingPreview(input: {
|
|
2878
|
+
modelId?: string;
|
|
2879
|
+
protocol?: string;
|
|
2880
|
+
paymentMethod?: string;
|
|
2881
|
+
routing?: Partial<BuyerSellerRoutingConfig>;
|
|
2882
|
+
}): { modelId: string; protocol: string; paymentMethod: string; plan: SellerRoutePlan | { error: string } } {
|
|
2883
|
+
const registry = this.lastRegistrySnapshot;
|
|
2884
|
+
const focusFirst = this.resolveFocusSet()[0];
|
|
2885
|
+
const registryFirst = registry?.sellers[0]?.models?.[0];
|
|
2886
|
+
const modelId = input.modelId?.trim() || focusFirst || registryFirst || "";
|
|
2887
|
+
const protocol = input.protocol?.trim() || "chat_completions";
|
|
2888
|
+
const paymentMethod = input.paymentMethod?.trim() || this.defaultPaymentMethod() || "clawtip";
|
|
2889
|
+
if (!modelId) {
|
|
2890
|
+
return { modelId, protocol, paymentMethod, plan: { error: "no_focus_model_available" } };
|
|
2891
|
+
}
|
|
2892
|
+
if (!registry) {
|
|
2893
|
+
return { modelId, protocol, paymentMethod, plan: { error: "registry_not_loaded" } };
|
|
2894
|
+
}
|
|
2895
|
+
const current = this.refreshSellerRoutingConfig();
|
|
2896
|
+
const routing: BuyerSellerRoutingConfig = input.routing
|
|
2897
|
+
? mergeSellerRoutingConfig(current, input.routing)
|
|
2898
|
+
: current;
|
|
2899
|
+
const resolvedRouting = resolveSellerRoutingForModel(routing, modelId);
|
|
2900
|
+
const registrySellers = reorderDefaultSellerFirst(registry.sellers, registry.defaultSeller);
|
|
2901
|
+
this.sellerPool.ensureRegistrySellers(registrySellers);
|
|
2902
|
+
const poolById = new Map(this.sellerPool.snapshot().map((entry) => [entry.sellerId, entry]));
|
|
2903
|
+
const plan = planSellerRouteSet({
|
|
2904
|
+
modelId,
|
|
2905
|
+
protocol,
|
|
2906
|
+
paymentMethod,
|
|
2907
|
+
registrySellers,
|
|
2908
|
+
routing: resolvedRouting,
|
|
2909
|
+
prewarmCandidates: this.prewarmCache.get(modelId, protocol, paymentMethod)?.candidates,
|
|
2910
|
+
sellerMetrics: Array.from(poolById.values()).map((entry) => ({
|
|
2911
|
+
sellerId: entry.sellerId,
|
|
2912
|
+
healthScore: entry.healthScore,
|
|
2913
|
+
avgLatencyMs: entry.avgLatencyMs,
|
|
2914
|
+
ttftMs: entry.ttftMs,
|
|
2915
|
+
avgInferenceMs: entry.avgInferenceMs,
|
|
2916
|
+
circuit: entry.circuit,
|
|
2917
|
+
capacityBlockedUntil: entry.capacityBlockedUntil
|
|
2918
|
+
})),
|
|
2919
|
+
now: Date.now()
|
|
2920
|
+
});
|
|
2921
|
+
return { modelId, protocol, paymentMethod, plan };
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2017
2924
|
private async runStartupPrewarmSweep(): Promise<void> {
|
|
2018
2925
|
const focusSet = this.resolveFocusSet();
|
|
2019
2926
|
if (focusSet.length === 0) {
|
|
@@ -2029,7 +2936,7 @@ export class TokenbuddyDaemon {
|
|
|
2029
2936
|
await this.prewarmScheduler.runStartupPrewarm(
|
|
2030
2937
|
focusSet.map((modelId) => ({
|
|
2031
2938
|
modelId,
|
|
2032
|
-
protocol: this.resolvePrewarmProtocol(modelId)
|
|
2939
|
+
protocol: this.resolvePrewarmProtocol(modelId, this.defaultPaymentMethod())
|
|
2033
2940
|
}))
|
|
2034
2941
|
);
|
|
2035
2942
|
} catch (err) {
|
|
@@ -2039,9 +2946,9 @@ export class TokenbuddyDaemon {
|
|
|
2039
2946
|
}
|
|
2040
2947
|
}
|
|
2041
2948
|
|
|
2042
|
-
private resolvePrewarmProtocol(modelId: string): string | undefined {
|
|
2949
|
+
private resolvePrewarmProtocol(modelId: string, paymentMethod = "clawtip"): string | undefined {
|
|
2043
2950
|
for (const protocol of ["chat_completions", "messages", "responses"]) {
|
|
2044
|
-
if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod
|
|
2951
|
+
if (this.modelIndex.sellersFor(modelId, { protocol, paymentMethod }).length > 0) {
|
|
2045
2952
|
return protocol;
|
|
2046
2953
|
}
|
|
2047
2954
|
}
|
|
@@ -2049,9 +2956,209 @@ export class TokenbuddyDaemon {
|
|
|
2049
2956
|
}
|
|
2050
2957
|
|
|
2051
2958
|
public stop() {
|
|
2959
|
+
if (this.clawtipActivationWaitCancelToken) {
|
|
2960
|
+
this.clawtipActivationWaitCancelToken.cancelled = true;
|
|
2961
|
+
}
|
|
2052
2962
|
if (this.controlServer) this.controlServer.close();
|
|
2053
2963
|
if (this.proxyServer) this.proxyServer.close();
|
|
2054
2964
|
void this.prewarmScheduler.stop();
|
|
2055
2965
|
this.tokenStore.close();
|
|
2056
2966
|
}
|
|
2967
|
+
|
|
2968
|
+
/**
|
|
2969
|
+
* @internal — test-only seam to inject a registry snapshot without
|
|
2970
|
+
* hitting the network. Used by `tests/control-plane-ui-endpoints.test.ts`
|
|
2971
|
+
* to drive `buildRoutingPreview` deterministically. Production code
|
|
2972
|
+
* must NOT call this; the real `fetchRegistry()` populates the snapshot.
|
|
2973
|
+
*/
|
|
2974
|
+
public setLastRegistrySnapshotForTest(snapshot: SellerRegistryDocument | null): void {
|
|
2975
|
+
this.lastRegistrySnapshot = snapshot;
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
function selectionModeForRouting(routing: BuyerSellerRoutingConfig): "auto" | "manual" {
|
|
2980
|
+
return routing.mode === "fullAuto" ? "auto" : "manual";
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
interface ClawtipQrResponse {
|
|
2984
|
+
ok: true;
|
|
2985
|
+
kind: "activate" | "recharge";
|
|
2986
|
+
method: "clawtip";
|
|
2987
|
+
orderNo?: string;
|
|
2988
|
+
amountFen?: number;
|
|
2989
|
+
qrImageUrl: string;
|
|
2990
|
+
sourceImagePath: string;
|
|
2991
|
+
staticImagePath: string;
|
|
2992
|
+
authUrl?: string;
|
|
2993
|
+
clawtipId?: string;
|
|
2994
|
+
orderFile?: string;
|
|
2995
|
+
resourceUrl?: string;
|
|
2996
|
+
walletConfigPath: string;
|
|
2997
|
+
walletConfigPresent: boolean;
|
|
2998
|
+
requiresWalletAuth: boolean;
|
|
2999
|
+
payCredentialWritten: boolean;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
function withLiveClawtipWalletState(payment: PaymentConfig, home?: string): PaymentConfig {
|
|
3003
|
+
if (payment.method !== "clawtip") {
|
|
3004
|
+
return payment;
|
|
3005
|
+
}
|
|
3006
|
+
const walletConfig = inspectOpenClawWalletConfig(home);
|
|
3007
|
+
return {
|
|
3008
|
+
...payment,
|
|
3009
|
+
enabled: payment.enabled && walletConfig.exists,
|
|
3010
|
+
config: {
|
|
3011
|
+
...(payment.config ?? {}),
|
|
3012
|
+
walletConfigPath: walletConfig.expectedPath,
|
|
3013
|
+
walletConfigPresent: walletConfig.exists,
|
|
3014
|
+
nearbyWalletConfigPaths: walletConfig.alternatePaths
|
|
3015
|
+
}
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
function normalizeClawtipActivationPayment(bootstrap: ClawtipBootstrapResponse): ClawtipBootstrapPayment {
|
|
3020
|
+
if (!bootstrap.payment?.orderNo || !bootstrap.payment.indicator || !bootstrap.payment.resourceUrl) {
|
|
3021
|
+
throw new Error("ClawTip bootstrap response missing orderNo, indicator, or resourceUrl.");
|
|
3022
|
+
}
|
|
3023
|
+
return {
|
|
3024
|
+
orderNo: bootstrap.payment.orderNo,
|
|
3025
|
+
amountFen: bootstrap.payment.amountFen ?? bootstrap.activationFeeFen ?? 1,
|
|
3026
|
+
payTo: bootstrap.payment.payTo,
|
|
3027
|
+
encryptedData: bootstrap.payment.encryptedData,
|
|
3028
|
+
indicator: bootstrap.payment.indicator,
|
|
3029
|
+
slug: bootstrap.payment.slug,
|
|
3030
|
+
skillId: bootstrap.payment.skillId,
|
|
3031
|
+
description: bootstrap.payment.description,
|
|
3032
|
+
resourceUrl: bootstrap.payment.resourceUrl,
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
function safeQrExtension(filePath: string): ".png" | ".jpg" | ".jpeg" | ".webp" {
|
|
3037
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
3038
|
+
if (extension === ".jpg" || extension === ".jpeg" || extension === ".webp") {
|
|
3039
|
+
return extension;
|
|
3040
|
+
}
|
|
3041
|
+
return ".png";
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
function safeStaticFileSegment(value: string): string {
|
|
3045
|
+
return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "clawtip";
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
function readConfigString(config: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
3049
|
+
const value = config?.[key];
|
|
3050
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
function readConfigBoolean(config: Record<string, unknown> | undefined, key: string): boolean {
|
|
3054
|
+
return config?.[key] === true;
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
function selectedSellerIdForRouting(routing: BuyerSellerRoutingConfig): string | undefined {
|
|
3058
|
+
return routing.mode === "fixed" ? routing.sellerId : undefined;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
function routingKey(routing: BuyerSellerRoutingConfig): string {
|
|
3062
|
+
const fixedByModel = Object.entries(routing.fixedByModel ?? {})
|
|
3063
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
3064
|
+
.map(([modelId, sellerId]) => `${modelId}:${sellerId}`);
|
|
3065
|
+
return [
|
|
3066
|
+
routing.mode,
|
|
3067
|
+
routing.scorer,
|
|
3068
|
+
routing.sellerId ?? "",
|
|
3069
|
+
...(routing.sellerIds ?? []),
|
|
3070
|
+
...fixedByModel
|
|
3071
|
+
].join("\u0001");
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
/**
|
|
3075
|
+
* 从 query string 构造 `BuyerSellerRoutingConfig` override(用于 GET /routing/preview)。
|
|
3076
|
+
* 接受 `mode` / `scorer` / `sellerId` / `sellerIds`(逗号分隔)。
|
|
3077
|
+
* 任一字段缺失返回 `undefined`,调用方走「用当前 routing」分支。
|
|
3078
|
+
* mode / scorer 非法抛 400,由端点 handler 捕获。
|
|
3079
|
+
*/
|
|
3080
|
+
function buildRoutingConfigFromQuery(query: Record<string, unknown> | Request["query"]): Partial<BuyerSellerRoutingConfig> | undefined {
|
|
3081
|
+
const mode = typeof query.mode === "string" ? query.mode.trim() : "";
|
|
3082
|
+
const scorer = typeof query.scorer === "string" ? query.scorer.trim() : "";
|
|
3083
|
+
const sellerId = typeof query.sellerId === "string" ? query.sellerId.trim() : "";
|
|
3084
|
+
const sellerIdsRaw = typeof query.sellerIds === "string" ? query.sellerIds.trim() : "";
|
|
3085
|
+
const fixedByModelRaw = typeof query.fixedByModel === "string" ? query.fixedByModel.trim() : "";
|
|
3086
|
+
if (!mode && !scorer && !sellerId && !sellerIdsRaw && !fixedByModelRaw) {
|
|
3087
|
+
return undefined;
|
|
3088
|
+
}
|
|
3089
|
+
const override: Partial<BuyerSellerRoutingConfig> = {};
|
|
3090
|
+
if (mode) {
|
|
3091
|
+
if (mode !== "fixed" && mode !== "fixedSet" && mode !== "fullAuto") {
|
|
3092
|
+
throw new Error("mode must be fixed, fixedSet, or fullAuto");
|
|
3093
|
+
}
|
|
3094
|
+
override.mode = mode;
|
|
3095
|
+
}
|
|
3096
|
+
if (scorer) {
|
|
3097
|
+
if (scorer !== "speed" && scorer !== "discount" && scorer !== "balanced") {
|
|
3098
|
+
throw new Error("scorer must be speed, discount, or balanced");
|
|
3099
|
+
}
|
|
3100
|
+
override.scorer = scorer;
|
|
3101
|
+
}
|
|
3102
|
+
if (sellerId) {
|
|
3103
|
+
override.sellerId = sellerId;
|
|
3104
|
+
}
|
|
3105
|
+
if (sellerIdsRaw) {
|
|
3106
|
+
override.sellerIds = parseSellerIdList(sellerIdsRaw);
|
|
3107
|
+
}
|
|
3108
|
+
if (fixedByModelRaw) {
|
|
3109
|
+
override.fixedByModel = parseFixedByModel(fixedByModelRaw);
|
|
3110
|
+
}
|
|
3111
|
+
return override;
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
function sameSellerRouting(a: BuyerSellerRoutingConfig, b: BuyerSellerRoutingConfig): boolean {
|
|
3115
|
+
return a.mode === b.mode
|
|
3116
|
+
&& a.scorer === b.scorer
|
|
3117
|
+
&& optionalStringEqual(a.sellerId, b.sellerId)
|
|
3118
|
+
&& stringArraysEqual(a.sellerIds ?? [], b.sellerIds ?? [])
|
|
3119
|
+
&& fixedByModelEqual(a.fixedByModel ?? {}, b.fixedByModel ?? {});
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
function optionalStringEqual(a: string | undefined, b: string | undefined): boolean {
|
|
3123
|
+
return (a ?? "") === (b ?? "");
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
function stringArraysEqual(a: string[], b: string[]): boolean {
|
|
3127
|
+
if (a.length !== b.length) {
|
|
3128
|
+
return false;
|
|
3129
|
+
}
|
|
3130
|
+
return a.every((entry, index) => entry === b[index]);
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
function resolveSellerRoutingForModel(routing: BuyerSellerRoutingConfig, modelId: string): BuyerSellerRoutingConfig {
|
|
3134
|
+
if (routing.mode !== "fixed") {
|
|
3135
|
+
return routing;
|
|
3136
|
+
}
|
|
3137
|
+
const fixedSellerId = routing.fixedByModel?.[modelId]?.trim() || routing.sellerId;
|
|
3138
|
+
return {
|
|
3139
|
+
mode: "fixed",
|
|
3140
|
+
scorer: routing.scorer,
|
|
3141
|
+
sellerId: fixedSellerId,
|
|
3142
|
+
fixedByModel: routing.fixedByModel
|
|
3143
|
+
};
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
function parseFixedByModel(value: string): Record<string, string> {
|
|
3147
|
+
const entries = value
|
|
3148
|
+
.split(",")
|
|
3149
|
+
.map((entry) => entry.split(":"))
|
|
3150
|
+
.filter((parts): parts is [string, string] => parts.length === 2)
|
|
3151
|
+
.map(([modelId, sellerId]) => [modelId.trim(), sellerId.trim()] as const)
|
|
3152
|
+
.filter(([modelId, sellerId]) => modelId.length > 0 && sellerId.length > 0);
|
|
3153
|
+
return Object.fromEntries(entries);
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
function fixedByModelEqual(a: Record<string, string>, b: Record<string, string>): boolean {
|
|
3157
|
+
const aEntries = Object.entries(a).sort(([left], [right]) => left.localeCompare(right));
|
|
3158
|
+
const bEntries = Object.entries(b).sort(([left], [right]) => left.localeCompare(right));
|
|
3159
|
+
return aEntries.length === bEntries.length
|
|
3160
|
+
&& aEntries.every(([modelId, sellerId], index) => {
|
|
3161
|
+
const [otherModelId, otherSellerId] = bEntries[index] ?? [];
|
|
3162
|
+
return modelId === otherModelId && sellerId === otherSellerId;
|
|
3163
|
+
});
|
|
2057
3164
|
}
|