@tokenbuddy/tokenbuddy 1.0.35 → 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/provider-install.ts
DELETED
|
@@ -1,1006 +0,0 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as os from "os";
|
|
3
|
-
import * as path from "path";
|
|
4
|
-
import {
|
|
5
|
-
BuyerStore,
|
|
6
|
-
ProviderInstallSnapshot,
|
|
7
|
-
} from "./buyer-store.js";
|
|
8
|
-
import {
|
|
9
|
-
ProtocolPreference,
|
|
10
|
-
} from "./seller-catalog.js";
|
|
11
|
-
|
|
12
|
-
export const PROXY_ACCESS_TOKEN_PLACEHOLDER = "TOKENBUDDY_PROXY";
|
|
13
|
-
const DESKTOP_PROFILE_ID = "00000000-0000-4000-8000-000000178210";
|
|
14
|
-
const CLAUDE_ONE_M_MARKER = "[1M]";
|
|
15
|
-
const CLAUDE_CLIENT_HAIKU_MODEL = "claude-haiku-4-5";
|
|
16
|
-
const CLAUDE_CLIENT_SONNET_MODEL = "claude-sonnet-4-6";
|
|
17
|
-
const CLAUDE_CLIENT_OPUS_MODEL = "claude-opus-4-7";
|
|
18
|
-
|
|
19
|
-
export const SUPPORTED_PROVIDER_IDS = [
|
|
20
|
-
"codex",
|
|
21
|
-
"claude-code",
|
|
22
|
-
"claude-desktop",
|
|
23
|
-
"openclaw",
|
|
24
|
-
"opencode",
|
|
25
|
-
"hermes",
|
|
26
|
-
] as const;
|
|
27
|
-
|
|
28
|
-
export type ProviderId = typeof SUPPORTED_PROVIDER_IDS[number];
|
|
29
|
-
export type ModelSelectionKind = "single-model" | "claude-role-mapping";
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* provider 探测选项。
|
|
33
|
-
*/
|
|
34
|
-
export interface ProviderDetectOptions {
|
|
35
|
-
/** 用户 home 目录,默认 `os.homedir()`;测试可注入临时目录 */
|
|
36
|
-
home?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* 单模型 provider 的 runtime config(适用于 codex / opencode / hermes / openclaw 等)。
|
|
41
|
-
* `defaultModel` 是 buyer 端默认转发到的上游模型 ID。
|
|
42
|
-
*/
|
|
43
|
-
export interface SingleModelProviderRuntimeConfig {
|
|
44
|
-
selectionKind: "single-model";
|
|
45
|
-
protocolPreference?: ProtocolPreference;
|
|
46
|
-
defaultModel: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Claude Code role 绑定:把 buyer 端的 `haiku/sonnet/opus` 角色映射到具体上游模型。
|
|
51
|
-
* `declareOneM` 用于在 UI 上声明 1M context(仅展示用途,不参与计费)。
|
|
52
|
-
*/
|
|
53
|
-
export interface ClaudeRoleBinding {
|
|
54
|
-
/** 实际转发到 seller 的上游模型 ID */
|
|
55
|
-
upstreamModel: string;
|
|
56
|
-
/** UI 展示名(可选) */
|
|
57
|
-
displayName?: string;
|
|
58
|
-
/** 是否在配置里标注 1M context(Claude Code 1M marker) */
|
|
59
|
-
declareOneM?: boolean;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Claude Code provider 的 model mapping 配置。
|
|
64
|
-
* 三个 role 都可选;缺失时回退到 `fallbackModel`。
|
|
65
|
-
*/
|
|
66
|
-
export interface ClaudeCodeModelMappingConfig {
|
|
67
|
-
selectionKind: "claude-role-mapping";
|
|
68
|
-
protocolPreference?: ProtocolPreference;
|
|
69
|
-
fallbackModel?: string;
|
|
70
|
-
roles: {
|
|
71
|
-
haiku?: ClaudeRoleBinding;
|
|
72
|
-
sonnet?: ClaudeRoleBinding;
|
|
73
|
-
opus?: ClaudeRoleBinding;
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* 任何 provider 的 runtime config 联合类型。
|
|
79
|
-
* 通过 `selectionKind` 字段做类型守卫。
|
|
80
|
-
*/
|
|
81
|
-
export type ProviderRuntimeConfig =
|
|
82
|
-
| SingleModelProviderRuntimeConfig
|
|
83
|
-
| ClaudeCodeModelMappingConfig;
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* `tb init` 期间为每个 provider 收集的最终选择(按 provider id 索引)。
|
|
87
|
-
*/
|
|
88
|
-
export type ProviderSelections = Partial<Record<ProviderId, ProviderRuntimeConfig>>;
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* `applyProviderInstall` / `previewProviderInstall` 的输入。
|
|
92
|
-
* 必填 `providers`(要安装的 provider ID 列表)和 `proxyUrl`(buyer 代理 URL)。
|
|
93
|
-
*/
|
|
94
|
-
export interface ProviderInstallOptions extends ProviderDetectOptions {
|
|
95
|
-
providers: string[];
|
|
96
|
-
proxyUrl: string;
|
|
97
|
-
model?: string;
|
|
98
|
-
providerSelections?: ProviderSelections;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* `rollbackProviderInstall` 的输入。
|
|
103
|
-
*/
|
|
104
|
-
export interface ProviderRollbackOptions extends ProviderDetectOptions {
|
|
105
|
-
providers: string[];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* provider 探测结果(供 `tb doctor` / install 流程使用)。
|
|
110
|
-
*/
|
|
111
|
-
export interface ProviderCandidate {
|
|
112
|
-
id: ProviderId;
|
|
113
|
-
name: string;
|
|
114
|
-
detected: boolean;
|
|
115
|
-
configured: boolean;
|
|
116
|
-
status: "configured" | "installed" | "missing";
|
|
117
|
-
configPath: string;
|
|
118
|
-
commandName?: string;
|
|
119
|
-
executablePath?: string;
|
|
120
|
-
observedPaths?: string[];
|
|
121
|
-
reason: string;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* provider 安装时产生的单文件变更(preview 阶段用)。
|
|
126
|
-
*/
|
|
127
|
-
export interface ProviderFileChange {
|
|
128
|
-
providerId: ProviderId;
|
|
129
|
-
path: string;
|
|
130
|
-
action: "create" | "update";
|
|
131
|
-
existed: boolean;
|
|
132
|
-
summary: string;
|
|
133
|
-
content: string;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* `applyProviderInstall` 的结果(每文件一行)。
|
|
138
|
-
*/
|
|
139
|
-
export interface ProviderApplyResult {
|
|
140
|
-
providerId: ProviderId;
|
|
141
|
-
path: string;
|
|
142
|
-
action: "created" | "updated";
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* `rollbackProviderInstall` 的结果(每文件一行)。
|
|
147
|
-
*/
|
|
148
|
-
export interface ProviderRollbackResult {
|
|
149
|
-
providerId: ProviderId;
|
|
150
|
-
path: string;
|
|
151
|
-
action: "restored" | "removed" | "missing_snapshot";
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
interface ProviderDefinition {
|
|
155
|
-
id: ProviderId;
|
|
156
|
-
name: string;
|
|
157
|
-
configPath(home: string): string;
|
|
158
|
-
commandName?: string;
|
|
159
|
-
observedPaths?(home: string): string[];
|
|
160
|
-
isConfigured?(filePath: string, home: string): boolean;
|
|
161
|
-
changes(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[];
|
|
162
|
-
modelSelectionKind: ModelSelectionKind;
|
|
163
|
-
protocolPreference?: ProtocolPreference;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
167
|
-
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function resolveHome(home?: string): string {
|
|
171
|
-
return home && home.trim() ? home : os.homedir();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function isProviderId(value: string): value is ProviderId {
|
|
175
|
-
return (SUPPORTED_PROVIDER_IDS as readonly string[]).includes(value);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function assertProviderIds(providers: string[]): ProviderId[] {
|
|
179
|
-
if (!Array.isArray(providers) || providers.length === 0) {
|
|
180
|
-
throw new Error("providers must include at least one provider id");
|
|
181
|
-
}
|
|
182
|
-
const out: ProviderId[] = [];
|
|
183
|
-
for (const provider of providers) {
|
|
184
|
-
if (!isProviderId(provider)) {
|
|
185
|
-
throw new Error(`unsupported provider: ${provider}`);
|
|
186
|
-
}
|
|
187
|
-
if (!out.includes(provider)) {
|
|
188
|
-
out.push(provider);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return out;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function readText(filePath: string): string | undefined {
|
|
195
|
-
if (!fs.existsSync(filePath)) {
|
|
196
|
-
return undefined;
|
|
197
|
-
}
|
|
198
|
-
return fs.readFileSync(filePath, "utf8");
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function readJsonObject(filePath: string): Record<string, unknown> {
|
|
202
|
-
const text = readText(filePath);
|
|
203
|
-
if (!text) {
|
|
204
|
-
return {};
|
|
205
|
-
}
|
|
206
|
-
try {
|
|
207
|
-
const parsed = JSON.parse(text) as unknown;
|
|
208
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
209
|
-
? (parsed as Record<string, unknown>)
|
|
210
|
-
: {};
|
|
211
|
-
} catch {
|
|
212
|
-
return {};
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function parseYamlScalar(value: string): unknown {
|
|
217
|
-
const trimmed = value.trim();
|
|
218
|
-
if (!trimmed) {
|
|
219
|
-
return "";
|
|
220
|
-
}
|
|
221
|
-
if (trimmed === "true") {
|
|
222
|
-
return true;
|
|
223
|
-
}
|
|
224
|
-
if (trimmed === "false") {
|
|
225
|
-
return false;
|
|
226
|
-
}
|
|
227
|
-
if (trimmed === "null") {
|
|
228
|
-
return null;
|
|
229
|
-
}
|
|
230
|
-
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
231
|
-
return trimmed.slice(1, -1);
|
|
232
|
-
}
|
|
233
|
-
const numeric = Number(trimmed);
|
|
234
|
-
if (Number.isFinite(numeric) && /^-?\d+(?:\.\d+)?$/.test(trimmed)) {
|
|
235
|
-
return numeric;
|
|
236
|
-
}
|
|
237
|
-
return trimmed;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function parseSimpleYamlObject(text: string): Record<string, unknown> {
|
|
241
|
-
const root: Record<string, unknown> = {};
|
|
242
|
-
const stack: Array<{ indent: number; value: Record<string, unknown> }> = [{ indent: -1, value: root }];
|
|
243
|
-
for (const rawLine of text.split(/\r?\n/)) {
|
|
244
|
-
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
const indent = rawLine.match(/^ */)?.[0].length ?? 0;
|
|
248
|
-
const trimmed = rawLine.trim();
|
|
249
|
-
const separatorIndex = trimmed.indexOf(":");
|
|
250
|
-
if (separatorIndex <= 0) {
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
const key = trimmed.slice(0, separatorIndex).trim();
|
|
254
|
-
const rest = trimmed.slice(separatorIndex + 1).trim();
|
|
255
|
-
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
256
|
-
stack.pop();
|
|
257
|
-
}
|
|
258
|
-
const parent = stack[stack.length - 1].value;
|
|
259
|
-
if (!rest) {
|
|
260
|
-
const child = isPlainRecord(parent[key]) ? parent[key] as Record<string, unknown> : {};
|
|
261
|
-
parent[key] = child;
|
|
262
|
-
stack.push({ indent, value: child });
|
|
263
|
-
} else {
|
|
264
|
-
parent[key] = parseYamlScalar(rest);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
return root;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function readYamlObject(filePath: string): Record<string, unknown> {
|
|
271
|
-
const text = readText(filePath);
|
|
272
|
-
if (!text) {
|
|
273
|
-
return {};
|
|
274
|
-
}
|
|
275
|
-
return parseSimpleYamlObject(text);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function yamlScalarContent(value: unknown): string {
|
|
279
|
-
if (typeof value === "number" || typeof value === "boolean") {
|
|
280
|
-
return String(value);
|
|
281
|
-
}
|
|
282
|
-
if (value === null) {
|
|
283
|
-
return "null";
|
|
284
|
-
}
|
|
285
|
-
const text = String(value ?? "");
|
|
286
|
-
if (!text || /[:#\n\r\t]|^\s|\s$|^(true|false|null)$/i.test(text) || /^-?\d+(?:\.\d+)?$/.test(text)) {
|
|
287
|
-
return JSON.stringify(text);
|
|
288
|
-
}
|
|
289
|
-
return text;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function yamlContent(value: Record<string, unknown>): string {
|
|
293
|
-
const lines: string[] = [];
|
|
294
|
-
const writeObject = (objectValue: Record<string, unknown>, indent: number): void => {
|
|
295
|
-
for (const [key, entry] of Object.entries(objectValue)) {
|
|
296
|
-
const prefix = " ".repeat(indent);
|
|
297
|
-
if (isPlainRecord(entry)) {
|
|
298
|
-
lines.push(`${prefix}${key}:`);
|
|
299
|
-
writeObject(entry, indent + 2);
|
|
300
|
-
} else {
|
|
301
|
-
lines.push(`${prefix}${key}: ${yamlScalarContent(entry)}`);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
};
|
|
305
|
-
writeObject(value, 0);
|
|
306
|
-
return `${lines.join("\n")}\n`;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function replaceTopLevelYamlSection(existing: string, sectionName: string, sectionBody: string): string {
|
|
310
|
-
const lines = existing.split(/\r?\n/);
|
|
311
|
-
const sectionStart = lines.findIndex((line) => {
|
|
312
|
-
return line === `${sectionName}:` || line.startsWith(`${sectionName}: `);
|
|
313
|
-
});
|
|
314
|
-
const bodyLines = [`${sectionName}:`, ...sectionBody.trimEnd().split(/\r?\n/).map((line) => ` ${line}`)];
|
|
315
|
-
if (sectionStart < 0) {
|
|
316
|
-
const prefix = existing.trimEnd();
|
|
317
|
-
return `${prefix}${prefix ? "\n" : ""}${bodyLines.join("\n")}\n`;
|
|
318
|
-
}
|
|
319
|
-
let sectionEnd = sectionStart + 1;
|
|
320
|
-
while (sectionEnd < lines.length) {
|
|
321
|
-
const line = lines[sectionEnd];
|
|
322
|
-
if (line.trim() && !line.startsWith(" ") && !line.startsWith("\t")) {
|
|
323
|
-
break;
|
|
324
|
-
}
|
|
325
|
-
sectionEnd += 1;
|
|
326
|
-
}
|
|
327
|
-
return `${[
|
|
328
|
-
...lines.slice(0, sectionStart),
|
|
329
|
-
...bodyLines,
|
|
330
|
-
...lines.slice(sectionEnd),
|
|
331
|
-
].join("\n").replace(/\n*$/, "")}\n`;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function readObjectField(value: unknown, key: string): Record<string, unknown> | undefined {
|
|
335
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
336
|
-
return undefined;
|
|
337
|
-
}
|
|
338
|
-
const field = (value as Record<string, unknown>)[key];
|
|
339
|
-
return field && typeof field === "object" && !Array.isArray(field)
|
|
340
|
-
? field as Record<string, unknown>
|
|
341
|
-
: undefined;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function jsonContent(value: unknown): string {
|
|
345
|
-
return `${JSON.stringify(value, null, 2)}\n`;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function displayPath(home: string, filePath: string): string {
|
|
349
|
-
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function resolveExecutable(commandName: string): string | undefined {
|
|
353
|
-
const pathValue = process.env.PATH || "";
|
|
354
|
-
const pathEntries = pathValue.split(path.delimiter).filter(Boolean);
|
|
355
|
-
const windowsExts = process.platform === "win32"
|
|
356
|
-
? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM")
|
|
357
|
-
.split(";")
|
|
358
|
-
.filter(Boolean)
|
|
359
|
-
: [""];
|
|
360
|
-
|
|
361
|
-
for (const entry of pathEntries) {
|
|
362
|
-
for (const ext of windowsExts) {
|
|
363
|
-
const candidate = path.join(entry, process.platform === "win32" ? `${commandName}${ext}` : commandName);
|
|
364
|
-
try {
|
|
365
|
-
fs.accessSync(candidate, fs.constants.X_OK);
|
|
366
|
-
return candidate;
|
|
367
|
-
} catch {
|
|
368
|
-
continue;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
return undefined;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function escapeTomlString(value: string): string {
|
|
376
|
-
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function replaceTomlSection(existing: string, sectionName: string, sectionBody: string): string {
|
|
380
|
-
const sectionPattern = new RegExp(`^\\[${sectionName}\\]\\n[\\s\\S]*?(?=^\\[|\\s*$)`, "m");
|
|
381
|
-
const normalized = existing.trimEnd();
|
|
382
|
-
const nextSection = `[${sectionName}]\n${sectionBody.trimEnd()}\n`;
|
|
383
|
-
if (sectionPattern.test(normalized)) {
|
|
384
|
-
return `${normalized.replace(sectionPattern, nextSection).trimEnd()}\n`;
|
|
385
|
-
}
|
|
386
|
-
return `${normalized}${normalized ? "\n\n" : ""}${nextSection}`;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function stripClaudeOneMMarker(model: string): string {
|
|
390
|
-
const trimmed = model.trimEnd();
|
|
391
|
-
if (!trimmed.toLowerCase().endsWith(CLAUDE_ONE_M_MARKER.toLowerCase())) {
|
|
392
|
-
return model;
|
|
393
|
-
}
|
|
394
|
-
return trimmed.slice(0, -CLAUDE_ONE_M_MARKER.length).trimEnd();
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function setClaudeOneMMarker(model: string, enabled: boolean): string {
|
|
398
|
-
const base = stripClaudeOneMMarker(model).trim();
|
|
399
|
-
if (!base) {
|
|
400
|
-
return "";
|
|
401
|
-
}
|
|
402
|
-
return enabled ? `${base}${CLAUDE_ONE_M_MARKER}` : base;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function makeChange(providerId: ProviderId, filePath: string, summary: string, content: string): ProviderFileChange {
|
|
406
|
-
const existed = fs.existsSync(filePath);
|
|
407
|
-
return {
|
|
408
|
-
providerId,
|
|
409
|
-
path: filePath,
|
|
410
|
-
action: existed ? "update" : "create",
|
|
411
|
-
existed,
|
|
412
|
-
summary,
|
|
413
|
-
content,
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function makeSingleModelRuntimeConfig(
|
|
418
|
-
provider: ProviderDefinition,
|
|
419
|
-
model: string,
|
|
420
|
-
): SingleModelProviderRuntimeConfig {
|
|
421
|
-
return {
|
|
422
|
-
selectionKind: "single-model",
|
|
423
|
-
protocolPreference: provider.protocolPreference,
|
|
424
|
-
defaultModel: model,
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function pickConfiguredModel(config: ProviderRuntimeConfig): string {
|
|
429
|
-
if (config.selectionKind === "single-model") {
|
|
430
|
-
return config.defaultModel;
|
|
431
|
-
}
|
|
432
|
-
const sonnetModel = config.roles.sonnet?.upstreamModel;
|
|
433
|
-
const opusModel = config.roles.opus?.upstreamModel;
|
|
434
|
-
const haikuModel = config.roles.haiku?.upstreamModel;
|
|
435
|
-
return sonnetModel || opusModel || haikuModel || config.fallbackModel || "";
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function resolveProviderRuntimeConfig(
|
|
439
|
-
provider: ProviderDefinition,
|
|
440
|
-
options: ProviderInstallOptions,
|
|
441
|
-
): ProviderRuntimeConfig {
|
|
442
|
-
const selection = options.providerSelections?.[provider.id];
|
|
443
|
-
if (selection) {
|
|
444
|
-
return selection;
|
|
445
|
-
}
|
|
446
|
-
const model = options.model?.trim();
|
|
447
|
-
if (!model) {
|
|
448
|
-
throw new Error(`model is required for provider ${provider.id}`);
|
|
449
|
-
}
|
|
450
|
-
return makeSingleModelRuntimeConfig(provider, model);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function codexConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
|
|
454
|
-
const model = pickConfiguredModel(config);
|
|
455
|
-
const configPath = path.join(home, ".codex", "config.toml");
|
|
456
|
-
const existing = readText(configPath) || "";
|
|
457
|
-
const content = replaceTomlSection(
|
|
458
|
-
existing,
|
|
459
|
-
"tokenbuddy",
|
|
460
|
-
[
|
|
461
|
-
`proxy_url = "${escapeTomlString(proxyUrl)}"`,
|
|
462
|
-
`api_key = "${PROXY_ACCESS_TOKEN_PLACEHOLDER}"`,
|
|
463
|
-
`model = "${escapeTomlString(model)}"`,
|
|
464
|
-
].join("\n"),
|
|
465
|
-
);
|
|
466
|
-
return [makeChange("codex", configPath, "configure TokenBuddy proxy for Codex", content)];
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function resolveClaudeFallbackAlias(config: ClaudeCodeModelMappingConfig): string {
|
|
470
|
-
if (config.roles.sonnet?.upstreamModel) {
|
|
471
|
-
return setClaudeOneMMarker(
|
|
472
|
-
CLAUDE_CLIENT_SONNET_MODEL,
|
|
473
|
-
Boolean(config.roles.sonnet.declareOneM),
|
|
474
|
-
);
|
|
475
|
-
}
|
|
476
|
-
if (config.roles.opus?.upstreamModel) {
|
|
477
|
-
return setClaudeOneMMarker(
|
|
478
|
-
CLAUDE_CLIENT_OPUS_MODEL,
|
|
479
|
-
Boolean(config.roles.opus.declareOneM),
|
|
480
|
-
);
|
|
481
|
-
}
|
|
482
|
-
if (config.roles.haiku?.upstreamModel) {
|
|
483
|
-
return CLAUDE_CLIENT_HAIKU_MODEL;
|
|
484
|
-
}
|
|
485
|
-
return CLAUDE_CLIENT_SONNET_MODEL;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
function buildClaudeRoleEnv(config: ClaudeCodeModelMappingConfig): Record<string, string> {
|
|
489
|
-
const env: Record<string, string> = {};
|
|
490
|
-
const roleConfigs: Array<{
|
|
491
|
-
binding?: ClaudeRoleBinding;
|
|
492
|
-
modelKey: string;
|
|
493
|
-
displayNameKey: string;
|
|
494
|
-
alias: string;
|
|
495
|
-
allowOneM: boolean;
|
|
496
|
-
}> = [
|
|
497
|
-
{
|
|
498
|
-
binding: config.roles.haiku,
|
|
499
|
-
modelKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
500
|
-
displayNameKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
|
|
501
|
-
alias: CLAUDE_CLIENT_HAIKU_MODEL,
|
|
502
|
-
allowOneM: false,
|
|
503
|
-
},
|
|
504
|
-
{
|
|
505
|
-
binding: config.roles.sonnet,
|
|
506
|
-
modelKey: "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
507
|
-
displayNameKey: "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
|
508
|
-
alias: CLAUDE_CLIENT_SONNET_MODEL,
|
|
509
|
-
allowOneM: true,
|
|
510
|
-
},
|
|
511
|
-
{
|
|
512
|
-
binding: config.roles.opus,
|
|
513
|
-
modelKey: "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
514
|
-
displayNameKey: "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
|
515
|
-
alias: CLAUDE_CLIENT_OPUS_MODEL,
|
|
516
|
-
allowOneM: true,
|
|
517
|
-
},
|
|
518
|
-
];
|
|
519
|
-
|
|
520
|
-
for (const role of roleConfigs) {
|
|
521
|
-
if (!role.binding?.upstreamModel?.trim()) {
|
|
522
|
-
continue;
|
|
523
|
-
}
|
|
524
|
-
env[role.modelKey] = role.allowOneM
|
|
525
|
-
? setClaudeOneMMarker(role.alias, Boolean(role.binding.declareOneM))
|
|
526
|
-
: role.alias;
|
|
527
|
-
const displayName = role.binding.displayName?.trim() || stripClaudeOneMMarker(role.binding.upstreamModel).trim();
|
|
528
|
-
if (displayName) {
|
|
529
|
-
env[role.displayNameKey] = displayName;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
env.ANTHROPIC_MODEL = resolveClaudeFallbackAlias(config);
|
|
534
|
-
return env;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function claudeCodeConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
|
|
538
|
-
const configPath = path.join(home, ".claude", "settings.json");
|
|
539
|
-
const current = readJsonObject(configPath);
|
|
540
|
-
const env = current.env && typeof current.env === "object" && !Array.isArray(current.env)
|
|
541
|
-
? (current.env as Record<string, unknown>)
|
|
542
|
-
: {};
|
|
543
|
-
|
|
544
|
-
const nextEnv: Record<string, unknown> = {
|
|
545
|
-
...env,
|
|
546
|
-
ANTHROPIC_BASE_URL: proxyUrl,
|
|
547
|
-
ANTHROPIC_AUTH_TOKEN: PROXY_ACCESS_TOKEN_PLACEHOLDER,
|
|
548
|
-
};
|
|
549
|
-
|
|
550
|
-
delete nextEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
551
|
-
delete nextEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME;
|
|
552
|
-
delete nextEnv.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
553
|
-
delete nextEnv.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME;
|
|
554
|
-
delete nextEnv.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
|
555
|
-
delete nextEnv.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME;
|
|
556
|
-
delete nextEnv.ANTHROPIC_MODEL;
|
|
557
|
-
|
|
558
|
-
if (config.selectionKind === "claude-role-mapping") {
|
|
559
|
-
Object.assign(nextEnv, buildClaudeRoleEnv(config));
|
|
560
|
-
} else {
|
|
561
|
-
nextEnv.ANTHROPIC_MODEL = config.defaultModel;
|
|
562
|
-
nextEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = config.defaultModel;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
current.env = nextEnv;
|
|
566
|
-
return [
|
|
567
|
-
makeChange(
|
|
568
|
-
"claude-code",
|
|
569
|
-
configPath,
|
|
570
|
-
"configure Anthropic proxy env for Claude Code",
|
|
571
|
-
jsonContent(current),
|
|
572
|
-
),
|
|
573
|
-
];
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
function claudeDesktopConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
|
|
577
|
-
const model = pickConfiguredModel(config);
|
|
578
|
-
const configDir = path.join(home, "Library", "Application Support", "Claude");
|
|
579
|
-
const configPath = path.join(configDir, "claude_desktop_config.json");
|
|
580
|
-
const threepDir = path.join(home, "Library", "Application Support", "Claude-3p");
|
|
581
|
-
const threepConfigPath = path.join(threepDir, "claude_desktop_config.json");
|
|
582
|
-
const libraryPath = path.join(threepDir, "configLibrary");
|
|
583
|
-
const profilePath = path.join(libraryPath, `${DESKTOP_PROFILE_ID}.json`);
|
|
584
|
-
const metaPath = path.join(libraryPath, "_meta.json");
|
|
585
|
-
|
|
586
|
-
const primary = readJsonObject(configPath);
|
|
587
|
-
primary.deploymentMode = "3p";
|
|
588
|
-
const threep = readJsonObject(threepConfigPath);
|
|
589
|
-
threep.deploymentMode = "3p";
|
|
590
|
-
|
|
591
|
-
const profile = {
|
|
592
|
-
disableDeploymentModeChooser: true,
|
|
593
|
-
inferenceGatewayApiKey: PROXY_ACCESS_TOKEN_PLACEHOLDER,
|
|
594
|
-
inferenceGatewayAuthScheme: "bearer",
|
|
595
|
-
inferenceGatewayBaseUrl: proxyUrl,
|
|
596
|
-
inferenceProvider: "gateway",
|
|
597
|
-
inferenceModels: [{ name: model }],
|
|
598
|
-
};
|
|
599
|
-
|
|
600
|
-
const meta = readJsonObject(metaPath);
|
|
601
|
-
const existingEntries = Array.isArray(meta.entries) ? meta.entries : [];
|
|
602
|
-
meta.appliedId = DESKTOP_PROFILE_ID;
|
|
603
|
-
meta.entries = [
|
|
604
|
-
...existingEntries.filter((entry) => !(entry && typeof entry === "object" && "id" in entry && entry.id === DESKTOP_PROFILE_ID)),
|
|
605
|
-
{ id: DESKTOP_PROFILE_ID, name: "TokenBuddy" },
|
|
606
|
-
];
|
|
607
|
-
|
|
608
|
-
return [
|
|
609
|
-
makeChange("claude-desktop", configPath, "enable Claude Desktop 3p deployment mode", jsonContent(primary)),
|
|
610
|
-
makeChange("claude-desktop", threepConfigPath, "enable Claude Desktop 3p config", jsonContent(threep)),
|
|
611
|
-
makeChange("claude-desktop", profilePath, "write TokenBuddy Claude Desktop profile", jsonContent(profile)),
|
|
612
|
-
makeChange("claude-desktop", metaPath, "select TokenBuddy Claude Desktop profile", jsonContent(meta)),
|
|
613
|
-
];
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
function openclawConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
|
|
617
|
-
const model = pickConfiguredModel(config);
|
|
618
|
-
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
|
619
|
-
const current = readJsonObject(configPath);
|
|
620
|
-
const models = isPlainRecord(current.models) ? current.models : {};
|
|
621
|
-
const providers = isPlainRecord(models.providers) ? models.providers : {};
|
|
622
|
-
const existingProvider = isPlainRecord(providers.tokenbuddy) ? providers.tokenbuddy : {};
|
|
623
|
-
const existingModels = Array.isArray(existingProvider.models) ? existingProvider.models : [];
|
|
624
|
-
const nextModels = [
|
|
625
|
-
...existingModels.filter((entry) => {
|
|
626
|
-
return !(isPlainRecord(entry) && entry.id === model);
|
|
627
|
-
}),
|
|
628
|
-
{
|
|
629
|
-
id: model,
|
|
630
|
-
name: model,
|
|
631
|
-
api: "openai-completions",
|
|
632
|
-
input: ["text", "image"],
|
|
633
|
-
},
|
|
634
|
-
];
|
|
635
|
-
providers.tokenbuddy = {
|
|
636
|
-
...existingProvider,
|
|
637
|
-
baseUrl: openAiBaseUrl(proxyUrl),
|
|
638
|
-
apiKey: PROXY_ACCESS_TOKEN_PLACEHOLDER,
|
|
639
|
-
auth: "api-key",
|
|
640
|
-
api: "openai-completions",
|
|
641
|
-
models: nextModels,
|
|
642
|
-
};
|
|
643
|
-
models.providers = providers;
|
|
644
|
-
current.models = models;
|
|
645
|
-
const agents = isPlainRecord(current.agents) ? current.agents : {};
|
|
646
|
-
const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {};
|
|
647
|
-
defaults.model = `tokenbuddy/${model}`;
|
|
648
|
-
agents.defaults = defaults;
|
|
649
|
-
current.agents = agents;
|
|
650
|
-
return [makeChange("openclaw", configPath, "configure OpenClaw proxy settings", jsonContent(current))];
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
function isOpenclawTokenBuddyConfigured(filePath: string): boolean {
|
|
654
|
-
const current = readJsonObject(filePath);
|
|
655
|
-
const tokenbuddy = readObjectField(readObjectField(readObjectField(current, "models"), "providers"), "tokenbuddy");
|
|
656
|
-
const defaults = readObjectField(readObjectField(current, "agents"), "defaults");
|
|
657
|
-
if (!tokenbuddy || !defaults) {
|
|
658
|
-
return false;
|
|
659
|
-
}
|
|
660
|
-
const defaultModel = defaults.model;
|
|
661
|
-
return tokenbuddy.apiKey === PROXY_ACCESS_TOKEN_PLACEHOLDER &&
|
|
662
|
-
typeof tokenbuddy.baseUrl === "string" &&
|
|
663
|
-
tokenbuddy.baseUrl.includes("127.0.0.1") &&
|
|
664
|
-
tokenbuddy.baseUrl.endsWith("/v1") &&
|
|
665
|
-
typeof defaultModel === "string" &&
|
|
666
|
-
defaultModel.startsWith("tokenbuddy/");
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
function openAiBaseUrl(proxyUrl: string): string {
|
|
670
|
-
const normalized = proxyUrl.replace(/\/+$/, "");
|
|
671
|
-
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function opencodeConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
|
|
675
|
-
const model = pickConfiguredModel(config);
|
|
676
|
-
const configPath = path.join(home, ".config", "opencode", "opencode.json");
|
|
677
|
-
const current = readJsonObject(configPath);
|
|
678
|
-
const providers = current.provider && typeof current.provider === "object" && !Array.isArray(current.provider)
|
|
679
|
-
? (current.provider as Record<string, unknown>)
|
|
680
|
-
: {};
|
|
681
|
-
providers.tokenbuddy = {
|
|
682
|
-
name: "TokenBuddy",
|
|
683
|
-
npm: "@ai-sdk/openai-compatible",
|
|
684
|
-
options: {
|
|
685
|
-
apiKey: PROXY_ACCESS_TOKEN_PLACEHOLDER,
|
|
686
|
-
baseURL: openAiBaseUrl(proxyUrl),
|
|
687
|
-
},
|
|
688
|
-
models: {
|
|
689
|
-
[model]: {
|
|
690
|
-
name: model,
|
|
691
|
-
attachment: true,
|
|
692
|
-
tool_call: true,
|
|
693
|
-
},
|
|
694
|
-
},
|
|
695
|
-
};
|
|
696
|
-
current.provider = providers;
|
|
697
|
-
// 写顶层 model / small_model,让 opencode 默认走 tokenbuddy 而不是残留的 openai/qwen-plus 死链
|
|
698
|
-
current.model = `tokenbuddy/${model}`;
|
|
699
|
-
current.small_model = `tokenbuddy/${model}`;
|
|
700
|
-
return [makeChange("opencode", configPath, "configure OpenCode provider for TokenBuddy proxy", jsonContent(current))];
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
function isOpencodeTokenBuddyConfigured(filePath: string): boolean {
|
|
704
|
-
const current = readJsonObject(filePath);
|
|
705
|
-
const tokenbuddy = readObjectField(readObjectField(current, "provider"), "tokenbuddy");
|
|
706
|
-
const options = readObjectField(tokenbuddy, "options");
|
|
707
|
-
if (!tokenbuddy || !options) {
|
|
708
|
-
return false;
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
return tokenbuddy.npm === "@ai-sdk/openai-compatible" &&
|
|
712
|
-
options.apiKey === PROXY_ACCESS_TOKEN_PLACEHOLDER &&
|
|
713
|
-
typeof options.baseURL === "string" &&
|
|
714
|
-
options.baseURL.includes("127.0.0.1") &&
|
|
715
|
-
options.baseURL.endsWith("/v1") &&
|
|
716
|
-
typeof current.model === "string" &&
|
|
717
|
-
current.model.startsWith("tokenbuddy/") &&
|
|
718
|
-
typeof current.small_model === "string" &&
|
|
719
|
-
current.small_model.startsWith("tokenbuddy/");
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
function hermesConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
|
|
723
|
-
const model = pickConfiguredModel(config);
|
|
724
|
-
const configPath = path.join(home, ".hermes", "config.yaml");
|
|
725
|
-
const existing = readText(configPath) || "";
|
|
726
|
-
const current = parseSimpleYamlObject(existing);
|
|
727
|
-
const modelConfig = isPlainRecord(current.model) ? current.model : {};
|
|
728
|
-
const nextModelConfig = {
|
|
729
|
-
...modelConfig,
|
|
730
|
-
default: model,
|
|
731
|
-
provider: "custom",
|
|
732
|
-
base_url: openAiBaseUrl(proxyUrl),
|
|
733
|
-
api_key: PROXY_ACCESS_TOKEN_PLACEHOLDER,
|
|
734
|
-
api_mode: "chat_completions",
|
|
735
|
-
};
|
|
736
|
-
const content = replaceTopLevelYamlSection(existing, "model", yamlContent(nextModelConfig));
|
|
737
|
-
return [makeChange("hermes", configPath, "configure Hermes OpenAI proxy settings", content)];
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
function isHermesTokenBuddyConfigured(filePath: string): boolean {
|
|
741
|
-
const current = readYamlObject(filePath);
|
|
742
|
-
const modelConfig = readObjectField(current, "model");
|
|
743
|
-
if (!modelConfig) {
|
|
744
|
-
return false;
|
|
745
|
-
}
|
|
746
|
-
return modelConfig.provider === "custom" &&
|
|
747
|
-
modelConfig.api_key === PROXY_ACCESS_TOKEN_PLACEHOLDER &&
|
|
748
|
-
modelConfig.api_mode === "chat_completions" &&
|
|
749
|
-
typeof modelConfig.base_url === "string" &&
|
|
750
|
-
modelConfig.base_url.includes("127.0.0.1") &&
|
|
751
|
-
modelConfig.base_url.endsWith("/v1") &&
|
|
752
|
-
typeof modelConfig.default === "string" &&
|
|
753
|
-
modelConfig.default.length > 0;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
const PROVIDERS: ProviderDefinition[] = [
|
|
757
|
-
{
|
|
758
|
-
id: "codex",
|
|
759
|
-
name: "Codex CLI",
|
|
760
|
-
commandName: "codex",
|
|
761
|
-
configPath: (home) => path.join(home, ".codex", "config.toml"),
|
|
762
|
-
changes: codexConfig,
|
|
763
|
-
modelSelectionKind: "single-model",
|
|
764
|
-
protocolPreference: "responses",
|
|
765
|
-
},
|
|
766
|
-
{
|
|
767
|
-
id: "claude-code",
|
|
768
|
-
name: "Claude Code CLI",
|
|
769
|
-
commandName: "claude",
|
|
770
|
-
configPath: (home) => path.join(home, ".claude", "settings.json"),
|
|
771
|
-
changes: claudeCodeConfig,
|
|
772
|
-
modelSelectionKind: "claude-role-mapping",
|
|
773
|
-
protocolPreference: "messages",
|
|
774
|
-
},
|
|
775
|
-
{
|
|
776
|
-
id: "claude-desktop",
|
|
777
|
-
name: "Claude Desktop App",
|
|
778
|
-
configPath: (home) => path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
|
|
779
|
-
changes: claudeDesktopConfig,
|
|
780
|
-
modelSelectionKind: "single-model",
|
|
781
|
-
protocolPreference: "messages",
|
|
782
|
-
},
|
|
783
|
-
{
|
|
784
|
-
id: "openclaw",
|
|
785
|
-
name: "OpenClaw Agent",
|
|
786
|
-
commandName: "openclaw",
|
|
787
|
-
configPath: (home) => path.join(home, ".openclaw", "openclaw.json"),
|
|
788
|
-
isConfigured: isOpenclawTokenBuddyConfigured,
|
|
789
|
-
observedPaths: (home) => [
|
|
790
|
-
path.join(home, ".openclaw", "configs"),
|
|
791
|
-
path.join(home, ".openclaw", "config.json"),
|
|
792
|
-
],
|
|
793
|
-
changes: openclawConfig,
|
|
794
|
-
modelSelectionKind: "single-model",
|
|
795
|
-
protocolPreference: "chat_completions",
|
|
796
|
-
},
|
|
797
|
-
{
|
|
798
|
-
id: "opencode",
|
|
799
|
-
name: "OpenCode",
|
|
800
|
-
commandName: "opencode",
|
|
801
|
-
configPath: (home) => path.join(home, ".config", "opencode", "opencode.json"),
|
|
802
|
-
isConfigured: isOpencodeTokenBuddyConfigured,
|
|
803
|
-
changes: opencodeConfig,
|
|
804
|
-
modelSelectionKind: "single-model",
|
|
805
|
-
protocolPreference: "chat_completions",
|
|
806
|
-
},
|
|
807
|
-
{
|
|
808
|
-
id: "hermes",
|
|
809
|
-
name: "Hermes Terminal",
|
|
810
|
-
commandName: "hermes",
|
|
811
|
-
configPath: (home) => path.join(home, ".hermes", "config.yaml"),
|
|
812
|
-
isConfigured: isHermesTokenBuddyConfigured,
|
|
813
|
-
observedPaths: (home) => [
|
|
814
|
-
path.join(home, ".hermes", "settings.json"),
|
|
815
|
-
path.join(home, ".hermes", "auth.json"),
|
|
816
|
-
],
|
|
817
|
-
changes: hermesConfig,
|
|
818
|
-
modelSelectionKind: "single-model",
|
|
819
|
-
protocolPreference: "chat_completions",
|
|
820
|
-
},
|
|
821
|
-
];
|
|
822
|
-
|
|
823
|
-
function getProviderDefinition(providerId: ProviderId): ProviderDefinition {
|
|
824
|
-
const provider = PROVIDERS.find((entry) => entry.id === providerId);
|
|
825
|
-
if (!provider) {
|
|
826
|
-
throw new Error(`unsupported provider: ${providerId}`);
|
|
827
|
-
}
|
|
828
|
-
return provider;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
/**
|
|
832
|
-
* 探测所有 SUPPORTED_PROVIDER_IDS:检查可执行文件、配置文件、原生 hints 目录。
|
|
833
|
-
* 状态机:`configured`(已存在配置且被识别) / `installed`(可执行/配置存在但未配置) / `missing`(都未找到)。
|
|
834
|
-
*
|
|
835
|
-
* @param options 探测选项
|
|
836
|
-
* @returns 探测结果列表
|
|
837
|
-
*/
|
|
838
|
-
export function detectProviders(options: ProviderDetectOptions = {}): ProviderCandidate[] {
|
|
839
|
-
const home = resolveHome(options.home);
|
|
840
|
-
return PROVIDERS.map((provider) => {
|
|
841
|
-
const configPath = provider.configPath(home);
|
|
842
|
-
const configExists = fs.existsSync(configPath);
|
|
843
|
-
const configured = configExists && (provider.isConfigured?.(configPath, home) ?? true);
|
|
844
|
-
const executablePath = provider.commandName ? resolveExecutable(provider.commandName) : undefined;
|
|
845
|
-
const observedPaths = provider.observedPaths?.(home).filter((entry) => fs.existsSync(entry)) || [];
|
|
846
|
-
const installed = Boolean(executablePath) || observedPaths.length > 0 || configExists;
|
|
847
|
-
const status: ProviderCandidate["status"] = configured
|
|
848
|
-
? "configured"
|
|
849
|
-
: installed
|
|
850
|
-
? "installed"
|
|
851
|
-
: "missing";
|
|
852
|
-
const reasonParts: string[] = [];
|
|
853
|
-
if (configured) {
|
|
854
|
-
reasonParts.push(`Configured at ${displayPath(home, configPath)}`);
|
|
855
|
-
} else if (installed) {
|
|
856
|
-
reasonParts.push(`Installed, TokenBuddy config missing at ${displayPath(home, configPath)}`);
|
|
857
|
-
} else {
|
|
858
|
-
reasonParts.push(`Missing ${displayPath(home, configPath)}`);
|
|
859
|
-
}
|
|
860
|
-
if (executablePath) {
|
|
861
|
-
reasonParts.push(`CLI ${displayPath(home, executablePath)}`);
|
|
862
|
-
}
|
|
863
|
-
if (observedPaths.length > 0) {
|
|
864
|
-
reasonParts.push(`Native files ${observedPaths.map((entry) => displayPath(home, entry)).join(", ")}`);
|
|
865
|
-
}
|
|
866
|
-
return {
|
|
867
|
-
id: provider.id,
|
|
868
|
-
name: provider.name,
|
|
869
|
-
detected: status !== "missing",
|
|
870
|
-
configured,
|
|
871
|
-
status,
|
|
872
|
-
configPath,
|
|
873
|
-
commandName: provider.commandName,
|
|
874
|
-
executablePath,
|
|
875
|
-
observedPaths,
|
|
876
|
-
reason: reasonParts.join(" · "),
|
|
877
|
-
};
|
|
878
|
-
});
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
/**
|
|
882
|
-
* 返回 provider 默认的协议偏好(未声明则返回 `undefined`,由运行时探测)。
|
|
883
|
-
*
|
|
884
|
-
* @param providerId provider ID
|
|
885
|
-
* @returns 协议偏好
|
|
886
|
-
*/
|
|
887
|
-
export function getProviderProtocolPreference(providerId: ProviderId): ProtocolPreference | undefined {
|
|
888
|
-
return getProviderDefinition(providerId).protocolPreference;
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
/**
|
|
892
|
-
* 返回 provider 的 model selection kind(决定 init 走 single-model 还是 claude-role-mapping 流程)。
|
|
893
|
-
*
|
|
894
|
-
* @param providerId provider ID
|
|
895
|
-
* @returns selection kind
|
|
896
|
-
*/
|
|
897
|
-
export function getProviderModelSelectionKind(providerId: ProviderId): ModelSelectionKind {
|
|
898
|
-
return getProviderDefinition(providerId).modelSelectionKind;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
/**
|
|
902
|
-
* 预览 provider 安装会产生的文件变更(不实际写盘)。
|
|
903
|
-
* 给 `tb init` 在用户确认前展示"将要改哪些文件 / 改什么内容"。
|
|
904
|
-
*
|
|
905
|
-
* @param options 安装选项
|
|
906
|
-
* @returns 计划中的文件变更列表
|
|
907
|
-
*/
|
|
908
|
-
export function previewProviderInstall(options: ProviderInstallOptions): ProviderFileChange[] {
|
|
909
|
-
const home = resolveHome(options.home);
|
|
910
|
-
const providerIds = assertProviderIds(options.providers);
|
|
911
|
-
if (!options.proxyUrl || !options.proxyUrl.trim()) {
|
|
912
|
-
throw new Error("proxyUrl is required");
|
|
913
|
-
}
|
|
914
|
-
return providerIds.flatMap((providerId) => {
|
|
915
|
-
const provider = getProviderDefinition(providerId);
|
|
916
|
-
const runtimeConfig = resolveProviderRuntimeConfig(provider, options);
|
|
917
|
-
return provider.changes(home, options.proxyUrl, runtimeConfig);
|
|
918
|
-
});
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
/**
|
|
922
|
-
* 实际写盘 provider 配置。
|
|
923
|
-
* 同时把每个 provider 的原始文件快照写入 `store`(用于 `tb rollback`)。
|
|
924
|
-
*
|
|
925
|
-
* @param options 安装选项
|
|
926
|
-
* @param store buyer store(用于快照 + runtime config 持久化)
|
|
927
|
-
* @returns 实际写盘结果列表
|
|
928
|
-
*/
|
|
929
|
-
export function applyProviderInstall(options: ProviderInstallOptions, store: BuyerStore): ProviderApplyResult[] {
|
|
930
|
-
const providerIds = assertProviderIds(options.providers);
|
|
931
|
-
const changes = previewProviderInstall(options);
|
|
932
|
-
const byProvider = new Map<ProviderId, ProviderFileChange[]>();
|
|
933
|
-
for (const change of changes) {
|
|
934
|
-
byProvider.set(change.providerId, [...(byProvider.get(change.providerId) || []), change]);
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
for (const [providerId, providerChanges] of byProvider) {
|
|
938
|
-
const snapshot: ProviderInstallSnapshot = {
|
|
939
|
-
providerId,
|
|
940
|
-
files: providerChanges.map((change) => ({
|
|
941
|
-
path: change.path,
|
|
942
|
-
existed: fs.existsSync(change.path),
|
|
943
|
-
content: readText(change.path),
|
|
944
|
-
})),
|
|
945
|
-
};
|
|
946
|
-
store.saveProviderInstallSnapshot(snapshot);
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
for (const providerId of providerIds) {
|
|
950
|
-
const provider = getProviderDefinition(providerId);
|
|
951
|
-
const runtimeConfig = resolveProviderRuntimeConfig(provider, options);
|
|
952
|
-
store.saveProviderRuntimeConfig(providerId, runtimeConfig);
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
const applied: ProviderApplyResult[] = [];
|
|
956
|
-
for (const change of changes) {
|
|
957
|
-
const dir = path.dirname(change.path);
|
|
958
|
-
if (!fs.existsSync(dir)) {
|
|
959
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
960
|
-
}
|
|
961
|
-
fs.writeFileSync(change.path, change.content, "utf8");
|
|
962
|
-
applied.push({
|
|
963
|
-
providerId: change.providerId,
|
|
964
|
-
path: change.path,
|
|
965
|
-
action: change.existed ? "updated" : "created",
|
|
966
|
-
});
|
|
967
|
-
}
|
|
968
|
-
return applied;
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
/**
|
|
972
|
-
* 回滚 provider 安装。
|
|
973
|
-
* 从 `store` 读取安装前的快照,恢复原文件(如果快照里有原始内容)。
|
|
974
|
-
* 没有快照的 provider 标记为 `missing_snapshot`。
|
|
975
|
-
*
|
|
976
|
-
* @param options 回滚选项
|
|
977
|
-
* @param store buyer store
|
|
978
|
-
* @returns 回滚结果列表
|
|
979
|
-
*/
|
|
980
|
-
export function rollbackProviderInstall(options: ProviderRollbackOptions, store: BuyerStore): ProviderRollbackResult[] {
|
|
981
|
-
const providerIds = assertProviderIds(options.providers);
|
|
982
|
-
const results: ProviderRollbackResult[] = [];
|
|
983
|
-
for (const providerId of providerIds) {
|
|
984
|
-
const snapshot = store.getProviderInstallSnapshot(providerId);
|
|
985
|
-
if (!snapshot) {
|
|
986
|
-
results.push({ providerId, path: "", action: "missing_snapshot" });
|
|
987
|
-
continue;
|
|
988
|
-
}
|
|
989
|
-
for (const file of snapshot.files) {
|
|
990
|
-
if (file.existed) {
|
|
991
|
-
const dir = path.dirname(file.path);
|
|
992
|
-
if (!fs.existsSync(dir)) {
|
|
993
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
994
|
-
}
|
|
995
|
-
fs.writeFileSync(file.path, file.content || "", "utf8");
|
|
996
|
-
results.push({ providerId, path: file.path, action: "restored" });
|
|
997
|
-
} else {
|
|
998
|
-
fs.rmSync(file.path, { force: true });
|
|
999
|
-
results.push({ providerId, path: file.path, action: "removed" });
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
store.removeProviderInstallSnapshot(providerId);
|
|
1003
|
-
store.removeProviderRuntimeConfig(providerId);
|
|
1004
|
-
}
|
|
1005
|
-
return results;
|
|
1006
|
-
}
|