@tokenbuddy/tokenbuddy 1.0.36 → 1.0.37
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 +6 -1
- package/dist/src/buyer-store.js +43 -4
- package/dist/src/cli.js +2 -2
- package/dist/src/daemon.d.ts +12 -0
- package/dist/src/daemon.js +791 -61
- package/dist/src/doctor-diagnostics.js +1 -6
- package/dist/src/provider-install.d.ts +2 -2
- package/dist/src/provider-install.js +248 -2
- package/dist/src/seller-catalog.d.ts +21 -0
- package/dist/src/seller-catalog.js +17 -0
- package/dist/src/seller-route-planner.d.ts +4 -1
- package/dist/src/seller-route-planner.js +3 -0
- package/dist/src/seller-routing-strategy.d.ts +3 -0
- package/dist/src/terminal-detect.d.ts +1 -1
- package/dist/src/terminal-detect.js +3 -2
- package/package.json +15 -2
- package/static/ui/assets/index-Djfl9tw5.js +271 -0
- package/static/ui/assets/index-DkfztCkn.css +1 -0
- package/static/ui/index.html +2 -2
- package/dist/src/buyer-store.d.ts.map +0 -1
- package/dist/src/buyer-store.js.map +0 -1
- package/dist/src/clawtip-bootstrap.d.ts.map +0 -1
- package/dist/src/clawtip-bootstrap.js.map +0 -1
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js.map +0 -1
- package/dist/src/credit-tracker.d.ts.map +0 -1
- package/dist/src/credit-tracker.js.map +0 -1
- package/dist/src/daemon.d.ts.map +0 -1
- package/dist/src/daemon.js.map +0 -1
- package/dist/src/doctor-clawtip-wallet.d.ts.map +0 -1
- package/dist/src/doctor-clawtip-wallet.js.map +0 -1
- package/dist/src/doctor-diagnostics.d.ts.map +0 -1
- package/dist/src/doctor-diagnostics.js.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js.map +0 -1
- package/dist/src/init-clawtip-activation.d.ts.map +0 -1
- package/dist/src/init-clawtip-activation.js.map +0 -1
- package/dist/src/init-payment-options.d.ts.map +0 -1
- package/dist/src/init-payment-options.js.map +0 -1
- package/dist/src/init-setup.d.ts.map +0 -1
- package/dist/src/init-setup.js.map +0 -1
- package/dist/src/model-index.d.ts.map +0 -1
- package/dist/src/model-index.js.map +0 -1
- package/dist/src/package-update.d.ts.map +0 -1
- package/dist/src/package-update.js.map +0 -1
- package/dist/src/prewarm-cache.d.ts.map +0 -1
- package/dist/src/prewarm-cache.js.map +0 -1
- package/dist/src/prewarm-scheduler.d.ts.map +0 -1
- package/dist/src/prewarm-scheduler.js.map +0 -1
- package/dist/src/provider-install.d.ts.map +0 -1
- package/dist/src/provider-install.js.map +0 -1
- package/dist/src/provider-routing-config.d.ts.map +0 -1
- package/dist/src/provider-routing-config.js.map +0 -1
- package/dist/src/registry-trust.d.ts.map +0 -1
- package/dist/src/registry-trust.js.map +0 -1
- package/dist/src/route-failover.d.ts.map +0 -1
- package/dist/src/route-failover.js.map +0 -1
- package/dist/src/seller-catalog.d.ts.map +0 -1
- package/dist/src/seller-catalog.js.map +0 -1
- package/dist/src/seller-concurrency-limiter.d.ts.map +0 -1
- package/dist/src/seller-concurrency-limiter.js.map +0 -1
- package/dist/src/seller-metadata-cache.d.ts.map +0 -1
- package/dist/src/seller-metadata-cache.js.map +0 -1
- package/dist/src/seller-pool.d.ts.map +0 -1
- package/dist/src/seller-pool.js.map +0 -1
- package/dist/src/seller-route-planner.d.ts.map +0 -1
- package/dist/src/seller-route-planner.js.map +0 -1
- package/dist/src/seller-routing-config.d.ts.map +0 -1
- package/dist/src/seller-routing-config.js.map +0 -1
- package/dist/src/seller-routing-strategy.d.ts.map +0 -1
- package/dist/src/seller-routing-strategy.js.map +0 -1
- package/dist/src/stream-failover.d.ts.map +0 -1
- package/dist/src/stream-failover.js.map +0 -1
- package/dist/src/tb-clawtip-proof.d.ts.map +0 -1
- package/dist/src/tb-clawtip-proof.js.map +0 -1
- package/dist/src/tb-proxyd.d.ts.map +0 -1
- package/dist/src/tb-proxyd.js.map +0 -1
- package/dist/src/terminal-detect.d.ts.map +0 -1
- package/dist/src/terminal-detect.js.map +0 -1
- package/dist/src/terminal-image.d.ts.map +0 -1
- package/dist/src/terminal-image.js.map +0 -1
- package/src/buyer-store.ts +0 -1090
- package/src/clawtip-bootstrap.ts +0 -65
- package/src/cli.ts +0 -2243
- package/src/credit-tracker.ts +0 -295
- package/src/daemon.ts +0 -5475
- package/src/doctor-clawtip-wallet.ts +0 -95
- package/src/doctor-diagnostics.ts +0 -1026
- package/src/index.ts +0 -16
- package/src/init-clawtip-activation.ts +0 -695
- package/src/init-payment-options.ts +0 -373
- package/src/init-setup.ts +0 -165
- package/src/model-index.ts +0 -278
- package/src/package-update.ts +0 -311
- package/src/prewarm-cache.ts +0 -485
- package/src/prewarm-scheduler.ts +0 -675
- package/src/provider-install.ts +0 -1006
- package/src/provider-routing-config.ts +0 -410
- package/src/registry-trust.ts +0 -51
- package/src/route-failover.ts +0 -304
- package/src/seller-catalog.ts +0 -505
- package/src/seller-concurrency-limiter.ts +0 -161
- package/src/seller-metadata-cache.ts +0 -91
- package/src/seller-pool.ts +0 -557
- package/src/seller-route-planner.ts +0 -513
- package/src/seller-routing-config.ts +0 -211
- package/src/seller-routing-strategy.ts +0 -362
- package/src/stream-failover.ts +0 -152
- package/src/tb-clawtip-proof.ts +0 -28
- package/src/tb-proxyd.ts +0 -101
- package/src/terminal-detect.ts +0 -333
- package/src/terminal-image.ts +0 -228
- package/static/ui/assets/index-0MVXD7bH.css +0 -1
- package/static/ui/assets/index-BVbeDEwq.js +0 -271
- package/static/ui/assets/index-BVbeDEwq.js.map +0 -1
- package/tests/cli-routing.test.ts +0 -363
- package/tests/control-plane-ui-endpoints.test.ts +0 -1630
- package/tests/credit-tracker.test.ts +0 -165
- package/tests/daemon-413-fallback.test.ts +0 -92
- package/tests/daemon-classify.test.ts +0 -452
- package/tests/daemon-roles.test.ts +0 -92
- package/tests/daemon-trusted-registry-cache.test.ts +0 -132
- package/tests/e2e.test.ts +0 -366
- package/tests/image-generation-e2e.test.ts +0 -230
- package/tests/model-index.test.ts +0 -198
- package/tests/package-update.test.ts +0 -147
- package/tests/prewarm-cache.test.ts +0 -296
- package/tests/prewarm-scheduler.test.ts +0 -367
- package/tests/provider-routing-config.test.ts +0 -150
- package/tests/registry-trust.test.ts +0 -28
- package/tests/route-failover.test.ts +0 -222
- package/tests/seller-catalog-413.test.ts +0 -120
- package/tests/seller-catalog-utilities.test.ts +0 -124
- package/tests/seller-concurrency-limiter.test.ts +0 -83
- package/tests/seller-metadata-cache.test.ts +0 -89
- package/tests/seller-pool.test.ts +0 -365
- package/tests/seller-route-planner.test.ts +0 -312
- package/tests/seller-routing-config.test.ts +0 -124
- package/tests/seller-routing-strategy.test.ts +0 -167
- package/tests/stream-failover.test.ts +0 -52
- package/tests/thousand-seller.test.ts +0 -151
- package/tests/tokenbuddy.test.ts +0 -4043
- package/tsconfig.json +0 -8
package/src/stream-failover.ts
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import { createModuleLogger } from "@tokenbuddy/logging";
|
|
2
|
-
|
|
3
|
-
const logger = createModuleLogger("tb-proxyd:stream-failover");
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* v1.2 §6 / §18.10: stream-failover policy. The buyer honors the
|
|
7
|
-
* "abort + client retry" contract: once the first SSE byte has been
|
|
8
|
-
* written to the client, an upstream stream failure is surfaced as an
|
|
9
|
-
* abrupt close plus a `X-TokenBuddy-Retry-Hint: 1` trailer. The client
|
|
10
|
-
* (OpenAI / Anthropic SDK or any consumer honoring the OpenAI retry
|
|
11
|
-
* contract) re-issues the request and the buyer serves it from a
|
|
12
|
-
* healthy seller.
|
|
13
|
-
*
|
|
14
|
-
* The decisions in this module are intentionally one-way: the buyer
|
|
15
|
-
* never tries to splice two streams together (option B in the design
|
|
16
|
-
* doc) because that would double-charge and would require non-trivial
|
|
17
|
-
* idempotency re-design. v1.2 = abort + retry; v2 may revisit.
|
|
18
|
-
*/
|
|
19
|
-
/**
|
|
20
|
-
* v1.2 §6 / §18.10:stream-failover 策略。
|
|
21
|
-
* buyer 端遵循"abort + client retry"契约:一旦首字节 SSE 写入客户端,上游流失败只能 abrupt close
|
|
22
|
-
* 并附带 `X-TokenBuddy-Retry-Hint: 1` trailer;由 OpenAI / Anthropic SDK 重新发起请求,
|
|
23
|
-
* buyer 切到健康的 seller 重发。
|
|
24
|
-
*
|
|
25
|
-
* 该模块的决策是单向的:buyer 不会把两条流拼接(design doc 里的 option B),
|
|
26
|
-
* 因为会双重计费且需要重做幂等。v1.2 = abort + retry;v2 再考虑。
|
|
27
|
-
*/
|
|
28
|
-
export interface StreamFailoverOptions {
|
|
29
|
-
/** abort 响应中携带的 retry hint 响应头名,默认 `X-TokenBuddy-Retry-Hint` */
|
|
30
|
-
retryHintHeader?: string;
|
|
31
|
-
/** 时间源,默认 `Date.now`;测试可注入 */
|
|
32
|
-
now?: () => number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* `StreamFailover.decideOnStreamAbort` 的返回结果。
|
|
37
|
-
*/
|
|
38
|
-
export interface StreamFailoverDecision {
|
|
39
|
-
/** 决策动作:abort_with_retry_hint(已写首字节,abrupt close) / let_stream_complete(路由层可换 seller) */
|
|
40
|
-
action: "abort_with_retry_hint" | "let_stream_complete";
|
|
41
|
-
/** 决策原因(用于日志和稳定事件断言) */
|
|
42
|
-
reason: string;
|
|
43
|
-
/** retry hint 头的取值(`"0"` 或 `"1"`) */
|
|
44
|
-
retryHintValue: string;
|
|
45
|
-
/** 决策时是否已写过首字节(决策的输入状态) */
|
|
46
|
-
firstChunkCommitted: boolean;
|
|
47
|
-
/** 决策时已写入客户端的字节数 */
|
|
48
|
-
bytesFlushed: number;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* buyer 端 SSE 流失败决策器。
|
|
53
|
-
* 跟踪"首字节是否已写客户端"和"累计写入字节数",在 upstream 中断时决定是 abort+retry 还是切 seller 重试。
|
|
54
|
-
*/
|
|
55
|
-
export class StreamFailover {
|
|
56
|
-
private readonly retryHintHeader: string;
|
|
57
|
-
private readonly now: () => number;
|
|
58
|
-
private firstChunkCommitted = false;
|
|
59
|
-
private bytesFlushed = 0;
|
|
60
|
-
|
|
61
|
-
constructor(options: StreamFailoverOptions = {}) {
|
|
62
|
-
this.retryHintHeader = options.retryHintHeader ?? "X-TokenBuddy-Retry-Hint";
|
|
63
|
-
this.now = options.now ?? Date.now;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Record that the buyer's response stream has written its first chunk
|
|
68
|
-
* to the client. From this point on, the route-failover controller
|
|
69
|
-
* cannot switch sellers without the client's knowledge; failures
|
|
70
|
-
* must abort the stream and rely on the client to retry.
|
|
71
|
-
*/
|
|
72
|
-
markFirstChunkCommitted(): void {
|
|
73
|
-
if (this.firstChunkCommitted) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
this.firstChunkCommitted = true;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Track total bytes written to the client. Used by `tb doctor` and
|
|
81
|
-
* the inference ledger to attribute partial-stream usage.
|
|
82
|
-
*/
|
|
83
|
-
recordBytesWritten(bytes: number): void {
|
|
84
|
-
this.bytesFlushed += bytes;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Decide what to do when the upstream stream breaks. If the first
|
|
89
|
-
* chunk has already been written, the only option is to abort and
|
|
90
|
-
* surface the retry hint. Otherwise the controller is free to fail
|
|
91
|
-
* over to the next seller.
|
|
92
|
-
*/
|
|
93
|
-
decideOnStreamAbort(reason: string): StreamFailoverDecision {
|
|
94
|
-
if (!this.firstChunkCommitted) {
|
|
95
|
-
return {
|
|
96
|
-
action: "let_stream_complete",
|
|
97
|
-
reason: "no_chunks_yet_committed",
|
|
98
|
-
retryHintValue: "0",
|
|
99
|
-
firstChunkCommitted: false,
|
|
100
|
-
bytesFlushed: 0
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
logger.warn("stream.failover.aborted", "upstream stream broke after first chunk; aborting client with retry hint", {
|
|
104
|
-
reason,
|
|
105
|
-
bytesFlushed: this.bytesFlushed
|
|
106
|
-
});
|
|
107
|
-
return {
|
|
108
|
-
action: "abort_with_retry_hint",
|
|
109
|
-
reason,
|
|
110
|
-
retryHintValue: "1",
|
|
111
|
-
firstChunkCommitted: true,
|
|
112
|
-
bytesFlushed: this.bytesFlushed
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Read-only snapshot of the current stream state. The route-failover
|
|
118
|
-
* controller calls this to decide whether the next chunk is the first
|
|
119
|
-
* one (failover still possible) or a follow-up (abort required).
|
|
120
|
-
*/
|
|
121
|
-
snapshot(): { firstChunkCommitted: boolean; bytesFlushed: number } {
|
|
122
|
-
return {
|
|
123
|
-
firstChunkCommitted: this.firstChunkCommitted,
|
|
124
|
-
bytesFlushed: this.bytesFlushed
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Reset the failover state when a brand-new request starts. The
|
|
130
|
-
* `forwardProxyRequest` controller calls this before each new
|
|
131
|
-
* inference request.
|
|
132
|
-
*/
|
|
133
|
-
reset(): void {
|
|
134
|
-
this.firstChunkCommitted = false;
|
|
135
|
-
this.bytesFlushed = 0;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* The HTTP header to set on the abort response so the client knows
|
|
140
|
-
* it should retry. Exposed so the controller and the test fixtures
|
|
141
|
-
* can refer to the same constant.
|
|
142
|
-
*/
|
|
143
|
-
get headerName(): string {
|
|
144
|
-
return this.retryHintHeader;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Constant for the "retry hint value" used on stream-abort responses.
|
|
150
|
-
* Exposed so callers can refer to the same value in tests.
|
|
151
|
-
*/
|
|
152
|
-
export const STREAM_FAILOVER_RETRY_HINT = "1";
|
package/src/tb-clawtip-proof.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { createClawtipPaymentProof } from "./init-clawtip-activation.js";
|
|
3
|
-
|
|
4
|
-
async function readStdin(): Promise<string> {
|
|
5
|
-
const chunks: Buffer[] = [];
|
|
6
|
-
for await (const chunk of process.stdin) {
|
|
7
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
8
|
-
}
|
|
9
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
async function main(): Promise<void> {
|
|
13
|
-
const input = (await readStdin()).trim();
|
|
14
|
-
if (!input) {
|
|
15
|
-
throw new Error("ClawTip proof provider requires a JSON payload on stdin");
|
|
16
|
-
}
|
|
17
|
-
const payload = JSON.parse(input) as unknown;
|
|
18
|
-
const proof = await createClawtipPaymentProof(
|
|
19
|
-
payload && typeof payload === "object" ? payload as { paymentInstructions?: unknown; quote?: unknown } : {},
|
|
20
|
-
);
|
|
21
|
-
process.stdout.write(`${proof}\n`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
main().catch((error: unknown) => {
|
|
25
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
-
process.stderr.write(`${message}\n`);
|
|
27
|
-
process.exitCode = 1;
|
|
28
|
-
});
|
package/src/tb-proxyd.ts
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { TokenbuddyDaemon } from "./daemon.js";
|
|
2
|
-
import { createModuleLogger } from "@tokenbuddy/logging";
|
|
3
|
-
import { resolveBuyerStorePath } from "./buyer-store.js";
|
|
4
|
-
import { normalizeSellerRoutingConfig, parseSellerRoutingEnv } from "./seller-routing-config.js";
|
|
5
|
-
import { DEFAULT_SELLER_REGISTRY_URL } from "./registry-trust.js";
|
|
6
|
-
|
|
7
|
-
const logger = createModuleLogger("tb-proxyd");
|
|
8
|
-
|
|
9
|
-
const dbPath = resolveBuyerStorePath();
|
|
10
|
-
const controlPort = parsePortEnv("TB_PROXYD_CONTROL_PORT", 17820);
|
|
11
|
-
const proxyPort = parsePortEnv("TB_PROXYD_PROXY_PORT", 17821);
|
|
12
|
-
const sellerRegistryUrl = process.env.TB_PROXYD_SELLER_REGISTRY_URL || DEFAULT_SELLER_REGISTRY_URL;
|
|
13
|
-
const sellerRoutingEnv = parseSellerRoutingEnv();
|
|
14
|
-
const sellerRouting = sellerRoutingEnv ? normalizeSellerRoutingConfig(sellerRoutingEnv) : undefined;
|
|
15
|
-
const sellerConcurrency = parseSellerConcurrencyEnv();
|
|
16
|
-
|
|
17
|
-
const daemon = new TokenbuddyDaemon({
|
|
18
|
-
controlPort,
|
|
19
|
-
proxyPort,
|
|
20
|
-
dbPath,
|
|
21
|
-
sellerRegistryUrl,
|
|
22
|
-
...(sellerRouting ? { sellerRouting } : {}),
|
|
23
|
-
...(sellerConcurrency ? { sellerConcurrency } : {})
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
logger.info("proxy.process.initializing", "tb-proxyd process initializing", {
|
|
27
|
-
dbPath,
|
|
28
|
-
controlPort,
|
|
29
|
-
proxyPort,
|
|
30
|
-
sellerRegistryUrl,
|
|
31
|
-
sellerRoutingEnvOverride: Boolean(sellerRouting),
|
|
32
|
-
sellerRoutingMode: sellerRouting?.mode,
|
|
33
|
-
sellerRoutingScorer: sellerRouting?.scorer,
|
|
34
|
-
sellerConcurrencyEnabled: sellerConcurrency?.enabled ?? false,
|
|
35
|
-
sellerMaxInFlight: sellerConcurrency?.maxInFlightPerSeller
|
|
36
|
-
});
|
|
37
|
-
daemon.start();
|
|
38
|
-
|
|
39
|
-
// Handle graceful stop
|
|
40
|
-
process.on("SIGTERM", () => {
|
|
41
|
-
logger.info("proxy.shutdown", "tb-proxyd received shutdown signal", { signal: "SIGTERM" });
|
|
42
|
-
daemon.stop();
|
|
43
|
-
process.exit(0);
|
|
44
|
-
});
|
|
45
|
-
process.on("SIGINT", () => {
|
|
46
|
-
logger.info("proxy.shutdown", "tb-proxyd received shutdown signal", { signal: "SIGINT" });
|
|
47
|
-
daemon.stop();
|
|
48
|
-
process.exit(0);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
function parsePortEnv(name: string, fallback: number): number {
|
|
52
|
-
const rawValue = process.env[name];
|
|
53
|
-
if (!rawValue) {
|
|
54
|
-
return fallback;
|
|
55
|
-
}
|
|
56
|
-
const port = Number(rawValue);
|
|
57
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
58
|
-
throw new Error(`${name} must be an integer port between 0 and 65535`);
|
|
59
|
-
}
|
|
60
|
-
return port;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function parseSellerConcurrencyEnv(): { enabled: boolean; maxInFlightPerSeller?: number; leaseTtlMs?: number } | undefined {
|
|
64
|
-
const enabled = parseBooleanEnv("TB_PROXYD_SELLER_CONCURRENCY_ENABLED");
|
|
65
|
-
const maxInFlightPerSeller = parsePositiveIntegerEnv("TB_PROXYD_SELLER_MAX_IN_FLIGHT");
|
|
66
|
-
const leaseTtlMs = parsePositiveIntegerEnv("TB_PROXYD_SELLER_LEASE_TTL_MS");
|
|
67
|
-
if (enabled === undefined && maxInFlightPerSeller === undefined && leaseTtlMs === undefined) {
|
|
68
|
-
return undefined;
|
|
69
|
-
}
|
|
70
|
-
return {
|
|
71
|
-
enabled: enabled ?? false,
|
|
72
|
-
...(maxInFlightPerSeller !== undefined ? { maxInFlightPerSeller } : {}),
|
|
73
|
-
...(leaseTtlMs !== undefined ? { leaseTtlMs } : {})
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function parseBooleanEnv(name: string): boolean | undefined {
|
|
78
|
-
const rawValue = process.env[name]?.trim().toLowerCase();
|
|
79
|
-
if (!rawValue) {
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
if (["1", "true", "yes", "on"].includes(rawValue)) {
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
if (["0", "false", "no", "off"].includes(rawValue)) {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
throw new Error(`${name} must be one of 1, true, yes, on, 0, false, no, off`);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function parsePositiveIntegerEnv(name: string): number | undefined {
|
|
92
|
-
const rawValue = process.env[name];
|
|
93
|
-
if (!rawValue) {
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
const parsed = Number(rawValue);
|
|
97
|
-
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
98
|
-
throw new Error(`${name} must be a positive integer`);
|
|
99
|
-
}
|
|
100
|
-
return parsed;
|
|
101
|
-
}
|
package/src/terminal-detect.ts
DELETED
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import * as os from "os";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* `detectTerminals()` 的输出单元:描述一个可被 rewrite 的 coding terminal。
|
|
7
|
-
* `detected=false` 时仍会返回,UI 用其渲染"未安装"标签而不是直接跳过。
|
|
8
|
-
*/
|
|
9
|
-
export interface TerminalCandidate {
|
|
10
|
-
/** terminal 内部 ID,例:`claude-code` / `claude-desktop` / `openclaw` / `hermes` */
|
|
11
|
-
id: string;
|
|
12
|
-
/** 人类可读名称,例:`Claude Code CLI` */
|
|
13
|
-
name: string;
|
|
14
|
-
/** 本机是否检测到该 terminal 的配置文件 */
|
|
15
|
-
detected: boolean;
|
|
16
|
-
/** 配置文件绝对路径(即使未检测到也会拼出预期路径) */
|
|
17
|
-
configPath: string;
|
|
18
|
-
/** 检测结果的说明,用于 UI 展示 */
|
|
19
|
-
reason: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const PLACEHOLDER_API_KEY = "TOKENBUDDY_PROXY";
|
|
23
|
-
const DESKTOP_PROFILE_ID = "00000000-0000-4000-8000-000000178210";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* 获取当前用户的 home 目录,作为拼装 terminal 配置路径的基准。
|
|
27
|
-
*
|
|
28
|
-
* @returns `os.homedir()` 返回的 home 路径
|
|
29
|
-
*/
|
|
30
|
-
export function getHomeDir(): string {
|
|
31
|
-
return os.homedir();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Detect which coding terminals are installed on the local system.
|
|
36
|
-
*
|
|
37
|
-
* 通过 `~/.claude/settings.json`、`Library/Application Support/Claude/...`、
|
|
38
|
-
* `~/.openclaw/openclaw.json`、`~/.hermes/config.yaml` 等约定路径判断。
|
|
39
|
-
* Claude Desktop 在非 macOS 平台上 `configPath` 可能为空字符串,
|
|
40
|
-
* 调用方需以 `detected` 字段为准。
|
|
41
|
-
*
|
|
42
|
-
* @returns terminal 候选列表(固定四个 ID 顺序)
|
|
43
|
-
*/
|
|
44
|
-
export function detectTerminals(): TerminalCandidate[] {
|
|
45
|
-
const home = getHomeDir();
|
|
46
|
-
const candidates: TerminalCandidate[] = [];
|
|
47
|
-
|
|
48
|
-
// 1. Claude Code
|
|
49
|
-
const claudePath = path.join(home, ".claude", "settings.json");
|
|
50
|
-
const claudeDetected = fs.existsSync(claudePath);
|
|
51
|
-
candidates.push({
|
|
52
|
-
id: "claude-code",
|
|
53
|
-
name: "Claude Code CLI",
|
|
54
|
-
detected: claudeDetected,
|
|
55
|
-
configPath: claudePath,
|
|
56
|
-
reason: claudeDetected ? "Found `~/.claude/settings.json`" : "Missing `~/.claude/settings.json`"
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// 2. Claude Desktop
|
|
60
|
-
let desktopPath = "";
|
|
61
|
-
if (process.platform === "darwin") {
|
|
62
|
-
desktopPath = path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
63
|
-
} else if (process.platform === "win32") {
|
|
64
|
-
const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
|
65
|
-
desktopPath = path.join(localAppData, "Claude", "claude_desktop_config.json");
|
|
66
|
-
}
|
|
67
|
-
const desktopDetected = desktopPath !== "" && fs.existsSync(desktopPath);
|
|
68
|
-
candidates.push({
|
|
69
|
-
id: "claude-desktop",
|
|
70
|
-
name: "Claude Desktop App",
|
|
71
|
-
detected: desktopDetected,
|
|
72
|
-
configPath: desktopPath,
|
|
73
|
-
reason: desktopDetected ? "Found Claude desktop config" : "Missing Claude desktop config"
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// 3. Openclaw
|
|
77
|
-
const openclawPath = path.join(home, ".openclaw", "openclaw.json");
|
|
78
|
-
const openclawDetected = fs.existsSync(openclawPath);
|
|
79
|
-
candidates.push({
|
|
80
|
-
id: "openclaw",
|
|
81
|
-
name: "Openclaw Agent",
|
|
82
|
-
detected: openclawDetected,
|
|
83
|
-
configPath: openclawPath,
|
|
84
|
-
reason: openclawDetected ? "Found `~/.openclaw/openclaw.json`" : "Missing `~/.openclaw/openclaw.json`"
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
// 4. Hermes
|
|
88
|
-
const hermesPath = path.join(home, ".hermes", "config.yaml");
|
|
89
|
-
const hermesDetected = fs.existsSync(hermesPath);
|
|
90
|
-
candidates.push({
|
|
91
|
-
id: "hermes",
|
|
92
|
-
name: "Hermes Terminal",
|
|
93
|
-
detected: hermesDetected,
|
|
94
|
-
configPath: hermesPath,
|
|
95
|
-
reason: hermesDetected ? "Found `~/.hermes/config.yaml`" : "Missing `~/.hermes/config.yaml`"
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
return candidates;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Safely rewrite Claude Code settings to route requests through our proxy.
|
|
103
|
-
*
|
|
104
|
-
* 写入 `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN`(占位 `TOKENBUDDY_PROXY`)/
|
|
105
|
-
* `ANTHROPIC_MODEL` / `ANTHROPIC_DEFAULT_SONNET_MODEL`。失败时仅打印日志,
|
|
106
|
-
* 不会抛出异常;调用方无需 try/catch。
|
|
107
|
-
*
|
|
108
|
-
* @param configPath `~/.claude/settings.json` 的绝对路径
|
|
109
|
-
* @param proxyUrl TokenBuddy proxy 的 base URL
|
|
110
|
-
* @param model Claude Code 默认模型 ID
|
|
111
|
-
*/
|
|
112
|
-
export function rewriteClaudeCode(configPath: string, proxyUrl: string, model: string): void {
|
|
113
|
-
try {
|
|
114
|
-
const parent = path.dirname(configPath);
|
|
115
|
-
if (!fs.existsSync(parent)) {
|
|
116
|
-
fs.mkdirSync(parent, { recursive: true });
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
let config: any = {};
|
|
120
|
-
if (fs.existsSync(configPath)) {
|
|
121
|
-
try {
|
|
122
|
-
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
123
|
-
} catch {}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (!config.env || typeof config.env !== "object") {
|
|
127
|
-
config.env = {};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
config.env.ANTHROPIC_BASE_URL = proxyUrl;
|
|
131
|
-
config.env.ANTHROPIC_AUTH_TOKEN = PLACEHOLDER_API_KEY;
|
|
132
|
-
config.env.ANTHROPIC_MODEL = model;
|
|
133
|
-
config.env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
|
|
134
|
-
|
|
135
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
136
|
-
console.log(`[terminal-detect] Claude Code successfully routed to ${proxyUrl}`);
|
|
137
|
-
} catch (err: any) {
|
|
138
|
-
console.error("[terminal-detect] Failed to rewrite Claude Code config:", err.message);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Safely rewrite Claude Desktop configuration.
|
|
144
|
-
*
|
|
145
|
-
* 写入 `deploymentMode = "3p"` 到 Claude 与 Claude-3p 两个目录,
|
|
146
|
-
* 并在 `configLibrary/<DESKTOP_PROFILE_ID>.json` 写入 TokenBuddy profile。
|
|
147
|
-
* 最后更新 `_meta.json` 把 `appliedId` 指向该 profile。
|
|
148
|
-
*
|
|
149
|
-
* @param configPath Claude Desktop 主配置(`claude_desktop_config.json`)绝对路径
|
|
150
|
-
* @param proxyUrl TokenBuddy proxy 的 base URL
|
|
151
|
-
* @param model Claude Desktop 默认模型 ID
|
|
152
|
-
*/
|
|
153
|
-
export function rewriteClaudeDesktop(configPath: string, proxyUrl: string, model: string): void {
|
|
154
|
-
try {
|
|
155
|
-
const dir = path.dirname(configPath);
|
|
156
|
-
const threepDir = dir.replace(/Claude$/, "Claude-3p");
|
|
157
|
-
|
|
158
|
-
const writeMode = (filePath: string) => {
|
|
159
|
-
const parent = path.dirname(filePath);
|
|
160
|
-
if (!fs.existsSync(parent)) {
|
|
161
|
-
fs.mkdirSync(parent, { recursive: true });
|
|
162
|
-
}
|
|
163
|
-
let config: any = {};
|
|
164
|
-
if (fs.existsSync(filePath)) {
|
|
165
|
-
try { config = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch {}
|
|
166
|
-
}
|
|
167
|
-
config.deploymentMode = "3p";
|
|
168
|
-
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), "utf8");
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
// 1. Write deployment modes
|
|
172
|
-
writeMode(configPath);
|
|
173
|
-
const threepConfigPath = configPath.replace(/Claude/, "Claude-3p");
|
|
174
|
-
writeMode(threepConfigPath);
|
|
175
|
-
|
|
176
|
-
// 2. Write Profile
|
|
177
|
-
const configLibraryPath = path.join(threepDir, "configLibrary");
|
|
178
|
-
if (!fs.existsSync(configLibraryPath)) {
|
|
179
|
-
fs.mkdirSync(configLibraryPath, { recursive: true });
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const profilePath = path.join(configLibraryPath, `${DESKTOP_PROFILE_ID}.json`);
|
|
183
|
-
const profile = {
|
|
184
|
-
disableDeploymentModeChooser: true,
|
|
185
|
-
inferenceGatewayApiKey: PLACEHOLDER_API_KEY,
|
|
186
|
-
inferenceGatewayAuthScheme: "bearer",
|
|
187
|
-
inferenceGatewayBaseUrl: proxyUrl,
|
|
188
|
-
inferenceProvider: "gateway",
|
|
189
|
-
inferenceModels: [{ name: model }]
|
|
190
|
-
};
|
|
191
|
-
fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2), "utf8");
|
|
192
|
-
|
|
193
|
-
// 3. Write _meta.json
|
|
194
|
-
const metaPath = path.join(configLibraryPath, "_meta.json");
|
|
195
|
-
let meta: any = {};
|
|
196
|
-
if (fs.existsSync(metaPath)) {
|
|
197
|
-
try { meta = JSON.parse(fs.readFileSync(metaPath, "utf8")); } catch {}
|
|
198
|
-
}
|
|
199
|
-
let entries = Array.isArray(meta.entries) ? meta.entries : [];
|
|
200
|
-
entries = entries.filter((e: any) => e.id !== DESKTOP_PROFILE_ID);
|
|
201
|
-
entries.push({ id: DESKTOP_PROFILE_ID, name: "TokenBuddy" });
|
|
202
|
-
|
|
203
|
-
meta.appliedId = DESKTOP_PROFILE_ID;
|
|
204
|
-
meta.entries = entries;
|
|
205
|
-
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf8");
|
|
206
|
-
|
|
207
|
-
console.log(`[terminal-detect] Claude Desktop successfully routed to ${proxyUrl}`);
|
|
208
|
-
} catch (err: any) {
|
|
209
|
-
console.error("[terminal-detect] Failed to rewrite Claude Desktop config:", err.message);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Rewrite Openclaw settings.
|
|
215
|
-
*
|
|
216
|
-
* 写入 `models.providers.tokenbuddy`,并把 `agents.defaults.model` 指向 TokenBuddy。
|
|
217
|
-
* 失败仅打印日志,不抛出。
|
|
218
|
-
*
|
|
219
|
-
* @param configPath `~/.openclaw/openclaw.json` 的绝对路径
|
|
220
|
-
* @param proxyUrl TokenBuddy proxy 的 base URL
|
|
221
|
-
* @param model Openclaw 默认模型 ID
|
|
222
|
-
*/
|
|
223
|
-
export function rewriteOpenclaw(configPath: string, proxyUrl: string, model: string): void {
|
|
224
|
-
try {
|
|
225
|
-
const parent = path.dirname(configPath);
|
|
226
|
-
if (!fs.existsSync(parent)) fs.mkdirSync(parent, { recursive: true });
|
|
227
|
-
|
|
228
|
-
let config: any = {};
|
|
229
|
-
if (fs.existsSync(configPath)) {
|
|
230
|
-
try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); } catch {}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const providers = config.models && typeof config.models === "object" && !Array.isArray(config.models)
|
|
234
|
-
? config.models.providers && typeof config.models.providers === "object" && !Array.isArray(config.models.providers)
|
|
235
|
-
? config.models.providers
|
|
236
|
-
: {}
|
|
237
|
-
: {};
|
|
238
|
-
const tokenbuddy = providers.tokenbuddy && typeof providers.tokenbuddy === "object" && !Array.isArray(providers.tokenbuddy)
|
|
239
|
-
? providers.tokenbuddy
|
|
240
|
-
: {};
|
|
241
|
-
const existingModels = Array.isArray(tokenbuddy.models) ? tokenbuddy.models : [];
|
|
242
|
-
providers.tokenbuddy = {
|
|
243
|
-
...tokenbuddy,
|
|
244
|
-
baseUrl: openAiBaseUrl(proxyUrl),
|
|
245
|
-
apiKey: PLACEHOLDER_API_KEY,
|
|
246
|
-
auth: "api-key",
|
|
247
|
-
api: "openai-completions",
|
|
248
|
-
models: [
|
|
249
|
-
...existingModels.filter((entry: any) => !(entry && typeof entry === "object" && entry.id === model)),
|
|
250
|
-
{
|
|
251
|
-
id: model,
|
|
252
|
-
name: model,
|
|
253
|
-
api: "openai-completions",
|
|
254
|
-
input: ["text", "image"],
|
|
255
|
-
},
|
|
256
|
-
],
|
|
257
|
-
};
|
|
258
|
-
config.models = {
|
|
259
|
-
...(config.models && typeof config.models === "object" && !Array.isArray(config.models) ? config.models : {}),
|
|
260
|
-
providers,
|
|
261
|
-
};
|
|
262
|
-
const agents = config.agents && typeof config.agents === "object" && !Array.isArray(config.agents) ? config.agents : {};
|
|
263
|
-
const defaults = agents.defaults && typeof agents.defaults === "object" && !Array.isArray(agents.defaults) ? agents.defaults : {};
|
|
264
|
-
defaults.model = `tokenbuddy/${model}`;
|
|
265
|
-
agents.defaults = defaults;
|
|
266
|
-
config.agents = agents;
|
|
267
|
-
|
|
268
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
269
|
-
console.log(`[terminal-detect] Openclaw successfully routed to ${proxyUrl}`);
|
|
270
|
-
} catch (err: any) {
|
|
271
|
-
console.error("[terminal-detect] Failed to rewrite Openclaw config:", err.message);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Rewrite Hermes settings.
|
|
277
|
-
*
|
|
278
|
-
* 在 `model` 块下写入 TokenBuddy 的默认模型、custom provider、base_url、api_key 和 api_mode。
|
|
279
|
-
* 失败仅打印日志,不抛出。
|
|
280
|
-
*
|
|
281
|
-
* @param configPath `~/.hermes/config.yaml` 的绝对路径
|
|
282
|
-
* @param proxyUrl TokenBuddy proxy 的 base URL
|
|
283
|
-
* @param model Hermes 默认模型 ID
|
|
284
|
-
*/
|
|
285
|
-
export function rewriteHermes(configPath: string, proxyUrl: string, model: string): void {
|
|
286
|
-
try {
|
|
287
|
-
const parent = path.dirname(configPath);
|
|
288
|
-
if (!fs.existsSync(parent)) fs.mkdirSync(parent, { recursive: true });
|
|
289
|
-
|
|
290
|
-
const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
|
|
291
|
-
const modelSection = [
|
|
292
|
-
"model:",
|
|
293
|
-
` default: ${model}`,
|
|
294
|
-
" provider: custom",
|
|
295
|
-
` base_url: ${JSON.stringify(openAiBaseUrl(proxyUrl))}`,
|
|
296
|
-
` api_key: ${PLACEHOLDER_API_KEY}`,
|
|
297
|
-
" api_mode: chat_completions",
|
|
298
|
-
].join("\n");
|
|
299
|
-
|
|
300
|
-
fs.writeFileSync(configPath, replaceTopLevelYamlSection(existing, "model", modelSection), "utf8");
|
|
301
|
-
console.log(`[terminal-detect] Hermes successfully routed to ${proxyUrl}`);
|
|
302
|
-
} catch (err: any) {
|
|
303
|
-
console.error("[terminal-detect] Failed to rewrite Hermes config:", err.message);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function openAiBaseUrl(proxyUrl: string): string {
|
|
308
|
-
const normalized = proxyUrl.replace(/\/+$/, "");
|
|
309
|
-
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function replaceTopLevelYamlSection(existing: string, sectionName: string, sectionBody: string): string {
|
|
313
|
-
const lines = existing.split(/\r?\n/);
|
|
314
|
-
const sectionStart = lines.findIndex((line) => line === `${sectionName}:` || line.startsWith(`${sectionName}: `));
|
|
315
|
-
const bodyLines = sectionBody.trimEnd().split(/\r?\n/);
|
|
316
|
-
if (sectionStart < 0) {
|
|
317
|
-
const prefix = existing.trimEnd();
|
|
318
|
-
return `${prefix}${prefix ? "\n" : ""}${bodyLines.join("\n")}\n`;
|
|
319
|
-
}
|
|
320
|
-
let sectionEnd = sectionStart + 1;
|
|
321
|
-
while (sectionEnd < lines.length) {
|
|
322
|
-
const line = lines[sectionEnd];
|
|
323
|
-
if (line.trim() && !line.startsWith(" ") && !line.startsWith("\t")) {
|
|
324
|
-
break;
|
|
325
|
-
}
|
|
326
|
-
sectionEnd += 1;
|
|
327
|
-
}
|
|
328
|
-
return `${[
|
|
329
|
-
...lines.slice(0, sectionStart),
|
|
330
|
-
...bodyLines,
|
|
331
|
-
...lines.slice(sectionEnd),
|
|
332
|
-
].join("\n").replace(/\n*$/, "")}\n`;
|
|
333
|
-
}
|