@tokenbuddy/tokenbuddy 1.0.4 → 1.0.6

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.
Files changed (42) hide show
  1. package/dist/src/buyer-store.d.ts +20 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +73 -1
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts.map +1 -1
  6. package/dist/src/cli.js +390 -62
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/daemon.d.ts +6 -5
  9. package/dist/src/daemon.d.ts.map +1 -1
  10. package/dist/src/daemon.js +298 -92
  11. package/dist/src/daemon.js.map +1 -1
  12. package/dist/src/doctor-diagnostics.d.ts +97 -0
  13. package/dist/src/doctor-diagnostics.d.ts.map +1 -0
  14. package/dist/src/doctor-diagnostics.js +547 -0
  15. package/dist/src/doctor-diagnostics.js.map +1 -0
  16. package/dist/src/init-payment-options.d.ts +34 -0
  17. package/dist/src/init-payment-options.d.ts.map +1 -0
  18. package/dist/src/init-payment-options.js +90 -0
  19. package/dist/src/init-payment-options.js.map +1 -0
  20. package/dist/src/provider-install.d.ts +37 -2
  21. package/dist/src/provider-install.d.ts.map +1 -1
  22. package/dist/src/provider-install.js +317 -67
  23. package/dist/src/provider-install.js.map +1 -1
  24. package/dist/src/seller-catalog.d.ts +79 -0
  25. package/dist/src/seller-catalog.d.ts.map +1 -0
  26. package/dist/src/seller-catalog.js +126 -0
  27. package/dist/src/seller-catalog.js.map +1 -0
  28. package/dist/src/tb-proxyd.js +13 -2
  29. package/dist/src/tb-proxyd.js.map +1 -1
  30. package/package.json +4 -4
  31. package/src/buyer-store.ts +113 -1
  32. package/src/cli.ts +490 -67
  33. package/src/daemon.ts +346 -117
  34. package/src/doctor-diagnostics.ts +850 -0
  35. package/src/init-payment-options.ts +131 -0
  36. package/src/provider-install.ts +426 -76
  37. package/src/seller-catalog.ts +222 -0
  38. package/src/tb-proxyd.ts +14 -2
  39. package/tests/e2e.test.ts +9 -0
  40. package/tests/tokenbuddy.test.ts +628 -19
  41. package/bin/tb-proxyd.js +0 -2
  42. package/bin/tb.js +0 -3
@@ -1,29 +1,75 @@
1
1
  import * as fs from "fs";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
- import { BuyerStore, ProviderInstallSnapshot } from "./buyer-store.js";
5
-
6
- const PLACEHOLDER_API_KEY = "TOKENBUDDY_PROXY";
4
+ import {
5
+ BuyerStore,
6
+ ProviderInstallSnapshot,
7
+ } from "./buyer-store.js";
8
+ import {
9
+ ProtocolPreference,
10
+ SellerRoutingPreference,
11
+ } from "./seller-catalog.js";
12
+
13
+ export const PROXY_ACCESS_TOKEN_PLACEHOLDER = "TOKENBUDDY_PROXY";
7
14
  const DESKTOP_PROFILE_ID = "00000000-0000-4000-8000-000000178210";
15
+ const CLAUDE_ONE_M_MARKER = "[1M]";
16
+ const CLAUDE_CLIENT_HAIKU_MODEL = "claude-haiku-4-5";
17
+ const CLAUDE_CLIENT_SONNET_MODEL = "claude-sonnet-4-6";
18
+ const CLAUDE_CLIENT_OPUS_MODEL = "claude-opus-4-7";
19
+ const ROUTING_CONFIG_KEY = "routing";
8
20
 
9
21
  export const SUPPORTED_PROVIDER_IDS = [
10
22
  "codex",
11
23
  "claude-code",
12
24
  "claude-desktop",
13
25
  "openclaw",
14
- "hermes"
26
+ "opencode",
27
+ "hermes",
15
28
  ] as const;
16
29
 
17
30
  export type ProviderId = typeof SUPPORTED_PROVIDER_IDS[number];
31
+ export type ModelSelectionKind = "single-model" | "claude-role-mapping";
18
32
 
19
33
  export interface ProviderDetectOptions {
20
34
  home?: string;
21
35
  }
22
36
 
37
+ export interface SingleModelProviderRuntimeConfig {
38
+ selectionKind: "single-model";
39
+ protocolPreference?: ProtocolPreference;
40
+ defaultModel: string;
41
+ sellerId?: string;
42
+ }
43
+
44
+ export interface ClaudeRoleBinding {
45
+ upstreamModel: string;
46
+ displayName?: string;
47
+ declareOneM?: boolean;
48
+ }
49
+
50
+ export interface ClaudeCodeModelMappingConfig {
51
+ selectionKind: "claude-role-mapping";
52
+ protocolPreference?: ProtocolPreference;
53
+ fallbackModel?: string;
54
+ roles: {
55
+ haiku?: ClaudeRoleBinding;
56
+ sonnet?: ClaudeRoleBinding;
57
+ opus?: ClaudeRoleBinding;
58
+ };
59
+ }
60
+
61
+ export type ProviderRuntimeConfig =
62
+ | SingleModelProviderRuntimeConfig
63
+ | ClaudeCodeModelMappingConfig;
64
+
65
+ export type ProviderSelections = Partial<Record<ProviderId, ProviderRuntimeConfig>>;
66
+
23
67
  export interface ProviderInstallOptions extends ProviderDetectOptions {
24
68
  providers: string[];
25
69
  proxyUrl: string;
26
- model: string;
70
+ model?: string;
71
+ providerSelections?: ProviderSelections;
72
+ sellerRouting?: SellerRoutingPreference;
27
73
  }
28
74
 
29
75
  export interface ProviderRollbackOptions extends ProviderDetectOptions {
@@ -34,7 +80,12 @@ export interface ProviderCandidate {
34
80
  id: ProviderId;
35
81
  name: string;
36
82
  detected: boolean;
83
+ configured: boolean;
84
+ status: "configured" | "installed" | "missing";
37
85
  configPath: string;
86
+ commandName?: string;
87
+ executablePath?: string;
88
+ observedPaths?: string[];
38
89
  reason: string;
39
90
  }
40
91
 
@@ -63,7 +114,11 @@ interface ProviderDefinition {
63
114
  id: ProviderId;
64
115
  name: string;
65
116
  configPath(home: string): string;
66
- changes(home: string, proxyUrl: string, model: string): ProviderFileChange[];
117
+ commandName?: string;
118
+ observedPaths?(home: string): string[];
119
+ changes(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[];
120
+ modelSelectionKind: ModelSelectionKind;
121
+ protocolPreference?: ProtocolPreference;
67
122
  }
68
123
 
69
124
  function resolveHome(home?: string): string {
@@ -105,7 +160,7 @@ function readJsonObject(filePath: string): Record<string, unknown> {
105
160
  try {
106
161
  const parsed = JSON.parse(text) as unknown;
107
162
  return parsed && typeof parsed === "object" && !Array.isArray(parsed)
108
- ? parsed as Record<string, unknown>
163
+ ? (parsed as Record<string, unknown>)
109
164
  : {};
110
165
  } catch {
111
166
  return {};
@@ -116,6 +171,33 @@ function jsonContent(value: unknown): string {
116
171
  return `${JSON.stringify(value, null, 2)}\n`;
117
172
  }
118
173
 
174
+ function displayPath(home: string, filePath: string): string {
175
+ return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
176
+ }
177
+
178
+ function resolveExecutable(commandName: string): string | undefined {
179
+ const pathValue = process.env.PATH || "";
180
+ const pathEntries = pathValue.split(path.delimiter).filter(Boolean);
181
+ const windowsExts = process.platform === "win32"
182
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM")
183
+ .split(";")
184
+ .filter(Boolean)
185
+ : [""];
186
+
187
+ for (const entry of pathEntries) {
188
+ for (const ext of windowsExts) {
189
+ const candidate = path.join(entry, process.platform === "win32" ? `${commandName}${ext}` : commandName);
190
+ try {
191
+ fs.accessSync(candidate, fs.constants.X_OK);
192
+ return candidate;
193
+ } catch {
194
+ continue;
195
+ }
196
+ }
197
+ }
198
+ return undefined;
199
+ }
200
+
119
201
  function escapeTomlString(value: string): string {
120
202
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
121
203
  }
@@ -130,34 +212,201 @@ function replaceTomlSection(existing: string, sectionName: string, sectionBody:
130
212
  return `${normalized}${normalized ? "\n\n" : ""}${nextSection}`;
131
213
  }
132
214
 
133
- function codexConfig(home: string, proxyUrl: string, model: string): ProviderFileChange[] {
215
+ function stripClaudeOneMMarker(model: string): string {
216
+ const trimmed = model.trimEnd();
217
+ if (!trimmed.toLowerCase().endsWith(CLAUDE_ONE_M_MARKER.toLowerCase())) {
218
+ return model;
219
+ }
220
+ return trimmed.slice(0, -CLAUDE_ONE_M_MARKER.length).trimEnd();
221
+ }
222
+
223
+ function setClaudeOneMMarker(model: string, enabled: boolean): string {
224
+ const base = stripClaudeOneMMarker(model).trim();
225
+ if (!base) {
226
+ return "";
227
+ }
228
+ return enabled ? `${base}${CLAUDE_ONE_M_MARKER}` : base;
229
+ }
230
+
231
+ function makeChange(providerId: ProviderId, filePath: string, summary: string, content: string): ProviderFileChange {
232
+ const existed = fs.existsSync(filePath);
233
+ return {
234
+ providerId,
235
+ path: filePath,
236
+ action: existed ? "update" : "create",
237
+ existed,
238
+ summary,
239
+ content,
240
+ };
241
+ }
242
+
243
+ function makeSingleModelRuntimeConfig(
244
+ provider: ProviderDefinition,
245
+ model: string,
246
+ sellerId?: string,
247
+ ): SingleModelProviderRuntimeConfig {
248
+ return {
249
+ selectionKind: "single-model",
250
+ protocolPreference: provider.protocolPreference,
251
+ defaultModel: model,
252
+ sellerId,
253
+ };
254
+ }
255
+
256
+ function pickConfiguredModel(config: ProviderRuntimeConfig): string {
257
+ if (config.selectionKind === "single-model") {
258
+ return config.defaultModel;
259
+ }
260
+ const sonnetModel = config.roles.sonnet?.upstreamModel;
261
+ const opusModel = config.roles.opus?.upstreamModel;
262
+ const haikuModel = config.roles.haiku?.upstreamModel;
263
+ return sonnetModel || opusModel || haikuModel || config.fallbackModel || "";
264
+ }
265
+
266
+ function resolveProviderRuntimeConfig(
267
+ provider: ProviderDefinition,
268
+ options: ProviderInstallOptions,
269
+ ): ProviderRuntimeConfig {
270
+ const selection = options.providerSelections?.[provider.id];
271
+ if (selection) {
272
+ return selection;
273
+ }
274
+ const model = options.model?.trim();
275
+ if (!model) {
276
+ throw new Error(`model is required for provider ${provider.id}`);
277
+ }
278
+ return makeSingleModelRuntimeConfig(
279
+ provider,
280
+ model,
281
+ options.sellerRouting?.mode === "fixed" ? options.sellerRouting.sellerId : undefined,
282
+ );
283
+ }
284
+
285
+ function codexConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
286
+ const model = pickConfiguredModel(config);
134
287
  const configPath = path.join(home, ".codex", "config.toml");
135
288
  const existing = readText(configPath) || "";
136
- const content = replaceTomlSection(existing, "tokenbuddy", [
137
- `proxy_url = "${escapeTomlString(proxyUrl)}"`,
138
- `api_key = "${PLACEHOLDER_API_KEY}"`,
139
- `model = "${escapeTomlString(model)}"`
140
- ].join("\n"));
289
+ const content = replaceTomlSection(
290
+ existing,
291
+ "tokenbuddy",
292
+ [
293
+ `proxy_url = "${escapeTomlString(proxyUrl)}"`,
294
+ `api_key = "${PROXY_ACCESS_TOKEN_PLACEHOLDER}"`,
295
+ `model = "${escapeTomlString(model)}"`,
296
+ ].join("\n"),
297
+ );
141
298
  return [makeChange("codex", configPath, "configure TokenBuddy proxy for Codex", content)];
142
299
  }
143
300
 
144
- function claudeCodeConfig(home: string, proxyUrl: string, model: string): ProviderFileChange[] {
301
+ function resolveClaudeFallbackAlias(config: ClaudeCodeModelMappingConfig): string {
302
+ if (config.roles.sonnet?.upstreamModel) {
303
+ return setClaudeOneMMarker(
304
+ CLAUDE_CLIENT_SONNET_MODEL,
305
+ Boolean(config.roles.sonnet.declareOneM),
306
+ );
307
+ }
308
+ if (config.roles.opus?.upstreamModel) {
309
+ return setClaudeOneMMarker(
310
+ CLAUDE_CLIENT_OPUS_MODEL,
311
+ Boolean(config.roles.opus.declareOneM),
312
+ );
313
+ }
314
+ if (config.roles.haiku?.upstreamModel) {
315
+ return CLAUDE_CLIENT_HAIKU_MODEL;
316
+ }
317
+ return CLAUDE_CLIENT_SONNET_MODEL;
318
+ }
319
+
320
+ function buildClaudeRoleEnv(config: ClaudeCodeModelMappingConfig): Record<string, string> {
321
+ const env: Record<string, string> = {};
322
+ const roleConfigs: Array<{
323
+ binding?: ClaudeRoleBinding;
324
+ modelKey: string;
325
+ displayNameKey: string;
326
+ alias: string;
327
+ allowOneM: boolean;
328
+ }> = [
329
+ {
330
+ binding: config.roles.haiku,
331
+ modelKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
332
+ displayNameKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
333
+ alias: CLAUDE_CLIENT_HAIKU_MODEL,
334
+ allowOneM: false,
335
+ },
336
+ {
337
+ binding: config.roles.sonnet,
338
+ modelKey: "ANTHROPIC_DEFAULT_SONNET_MODEL",
339
+ displayNameKey: "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
340
+ alias: CLAUDE_CLIENT_SONNET_MODEL,
341
+ allowOneM: true,
342
+ },
343
+ {
344
+ binding: config.roles.opus,
345
+ modelKey: "ANTHROPIC_DEFAULT_OPUS_MODEL",
346
+ displayNameKey: "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
347
+ alias: CLAUDE_CLIENT_OPUS_MODEL,
348
+ allowOneM: true,
349
+ },
350
+ ];
351
+
352
+ for (const role of roleConfigs) {
353
+ if (!role.binding?.upstreamModel?.trim()) {
354
+ continue;
355
+ }
356
+ env[role.modelKey] = role.allowOneM
357
+ ? setClaudeOneMMarker(role.alias, Boolean(role.binding.declareOneM))
358
+ : role.alias;
359
+ const displayName = role.binding.displayName?.trim() || stripClaudeOneMMarker(role.binding.upstreamModel).trim();
360
+ if (displayName) {
361
+ env[role.displayNameKey] = displayName;
362
+ }
363
+ }
364
+
365
+ env.ANTHROPIC_MODEL = resolveClaudeFallbackAlias(config);
366
+ return env;
367
+ }
368
+
369
+ function claudeCodeConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
145
370
  const configPath = path.join(home, ".claude", "settings.json");
146
- const config = readJsonObject(configPath);
147
- const env = config.env && typeof config.env === "object" && !Array.isArray(config.env)
148
- ? config.env as Record<string, unknown>
371
+ const current = readJsonObject(configPath);
372
+ const env = current.env && typeof current.env === "object" && !Array.isArray(current.env)
373
+ ? (current.env as Record<string, unknown>)
149
374
  : {};
150
- config.env = {
375
+
376
+ const nextEnv: Record<string, unknown> = {
151
377
  ...env,
152
378
  ANTHROPIC_BASE_URL: proxyUrl,
153
- ANTHROPIC_AUTH_TOKEN: PLACEHOLDER_API_KEY,
154
- ANTHROPIC_MODEL: model,
155
- ANTHROPIC_DEFAULT_SONNET_MODEL: model
379
+ ANTHROPIC_AUTH_TOKEN: PROXY_ACCESS_TOKEN_PLACEHOLDER,
156
380
  };
157
- return [makeChange("claude-code", configPath, "configure Anthropic proxy env for Claude Code", jsonContent(config))];
381
+
382
+ delete nextEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL;
383
+ delete nextEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME;
384
+ delete nextEnv.ANTHROPIC_DEFAULT_SONNET_MODEL;
385
+ delete nextEnv.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME;
386
+ delete nextEnv.ANTHROPIC_DEFAULT_OPUS_MODEL;
387
+ delete nextEnv.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME;
388
+ delete nextEnv.ANTHROPIC_MODEL;
389
+
390
+ if (config.selectionKind === "claude-role-mapping") {
391
+ Object.assign(nextEnv, buildClaudeRoleEnv(config));
392
+ } else {
393
+ nextEnv.ANTHROPIC_MODEL = config.defaultModel;
394
+ nextEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = config.defaultModel;
395
+ }
396
+
397
+ current.env = nextEnv;
398
+ return [
399
+ makeChange(
400
+ "claude-code",
401
+ configPath,
402
+ "configure Anthropic proxy env for Claude Code",
403
+ jsonContent(current),
404
+ ),
405
+ ];
158
406
  }
159
407
 
160
- function claudeDesktopConfig(home: string, proxyUrl: string, model: string): ProviderFileChange[] {
408
+ function claudeDesktopConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
409
+ const model = pickConfiguredModel(config);
161
410
  const configDir = path.join(home, "Library", "Application Support", "Claude");
162
411
  const configPath = path.join(configDir, "claude_desktop_config.json");
163
412
  const threepDir = path.join(home, "Library", "Application Support", "Claude-3p");
@@ -173,134 +422,223 @@ function claudeDesktopConfig(home: string, proxyUrl: string, model: string): Pro
173
422
 
174
423
  const profile = {
175
424
  disableDeploymentModeChooser: true,
176
- inferenceGatewayApiKey: PLACEHOLDER_API_KEY,
425
+ inferenceGatewayApiKey: PROXY_ACCESS_TOKEN_PLACEHOLDER,
177
426
  inferenceGatewayAuthScheme: "bearer",
178
427
  inferenceGatewayBaseUrl: proxyUrl,
179
428
  inferenceProvider: "gateway",
180
- inferenceModels: [{ name: model }]
429
+ inferenceModels: [{ name: model }],
181
430
  };
182
431
 
183
432
  const meta = readJsonObject(metaPath);
184
433
  const existingEntries = Array.isArray(meta.entries) ? meta.entries : [];
185
434
  meta.appliedId = DESKTOP_PROFILE_ID;
186
435
  meta.entries = [
187
- ...existingEntries.filter((entry) => {
188
- return !(entry && typeof entry === "object" && "id" in entry && entry.id === DESKTOP_PROFILE_ID);
189
- }),
190
- { id: DESKTOP_PROFILE_ID, name: "TokenBuddy" }
436
+ ...existingEntries.filter((entry) => !(entry && typeof entry === "object" && "id" in entry && entry.id === DESKTOP_PROFILE_ID)),
437
+ { id: DESKTOP_PROFILE_ID, name: "TokenBuddy" },
191
438
  ];
192
439
 
193
440
  return [
194
441
  makeChange("claude-desktop", configPath, "enable Claude Desktop 3p deployment mode", jsonContent(primary)),
195
442
  makeChange("claude-desktop", threepConfigPath, "enable Claude Desktop 3p config", jsonContent(threep)),
196
443
  makeChange("claude-desktop", profilePath, "write TokenBuddy Claude Desktop profile", jsonContent(profile)),
197
- makeChange("claude-desktop", metaPath, "select TokenBuddy Claude Desktop profile", jsonContent(meta))
444
+ makeChange("claude-desktop", metaPath, "select TokenBuddy Claude Desktop profile", jsonContent(meta)),
198
445
  ];
199
446
  }
200
447
 
201
- function openclawConfig(home: string, proxyUrl: string, model: string): ProviderFileChange[] {
448
+ function openclawConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
449
+ const model = pickConfiguredModel(config);
202
450
  const configPath = path.join(home, ".openclaw", "config.json");
203
- const config = readJsonObject(configPath);
204
- config.api_url = proxyUrl;
205
- config.api_key = PLACEHOLDER_API_KEY;
206
- config.model = model;
207
- return [makeChange("openclaw", configPath, "configure OpenClaw proxy settings", jsonContent(config))];
451
+ const current = readJsonObject(configPath);
452
+ current.api_url = proxyUrl;
453
+ current.api_key = PROXY_ACCESS_TOKEN_PLACEHOLDER;
454
+ current.model = model;
455
+ return [makeChange("openclaw", configPath, "configure OpenClaw proxy settings", jsonContent(current))];
208
456
  }
209
457
 
210
- function hermesConfig(home: string, proxyUrl: string, model: string): ProviderFileChange[] {
211
- const configPath = path.join(home, ".hermes", "settings.json");
212
- const config = readJsonObject(configPath);
213
- const openai = config.openai && typeof config.openai === "object" && !Array.isArray(config.openai)
214
- ? config.openai as Record<string, unknown>
458
+ function openAiBaseUrl(proxyUrl: string): string {
459
+ const normalized = proxyUrl.replace(/\/+$/, "");
460
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
461
+ }
462
+
463
+ function opencodeConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
464
+ const model = pickConfiguredModel(config);
465
+ const configPath = path.join(home, ".config", "opencode", "opencode.json");
466
+ const current = readJsonObject(configPath);
467
+ const providers = current.provider && typeof current.provider === "object" && !Array.isArray(current.provider)
468
+ ? (current.provider as Record<string, unknown>)
215
469
  : {};
216
- config.openai = {
217
- ...openai,
218
- base_url: proxyUrl,
219
- api_key: PLACEHOLDER_API_KEY,
220
- model
470
+ providers.tokenbuddy = {
471
+ name: "TokenBuddy",
472
+ npm: "@ai-sdk/openai",
473
+ options: {
474
+ apiKey: PROXY_ACCESS_TOKEN_PLACEHOLDER,
475
+ baseURL: openAiBaseUrl(proxyUrl),
476
+ },
477
+ models: {
478
+ [model]: {
479
+ name: model,
480
+ attachment: true,
481
+ tool_call: true,
482
+ },
483
+ },
221
484
  };
222
- return [makeChange("hermes", configPath, "configure Hermes OpenAI proxy settings", jsonContent(config))];
485
+ current.provider = providers;
486
+ return [makeChange("opencode", configPath, "configure OpenCode provider for TokenBuddy proxy", jsonContent(current))];
223
487
  }
224
488
 
225
- function makeChange(providerId: ProviderId, filePath: string, summary: string, content: string): ProviderFileChange {
226
- const existed = fs.existsSync(filePath);
227
- return {
228
- providerId,
229
- path: filePath,
230
- action: existed ? "update" : "create",
231
- existed,
232
- summary,
233
- content
489
+ function hermesConfig(home: string, proxyUrl: string, config: ProviderRuntimeConfig): ProviderFileChange[] {
490
+ const model = pickConfiguredModel(config);
491
+ const configPath = path.join(home, ".hermes", "settings.json");
492
+ const current = readJsonObject(configPath);
493
+ const openai = current.openai && typeof current.openai === "object" && !Array.isArray(current.openai)
494
+ ? (current.openai as Record<string, unknown>)
495
+ : {};
496
+ current.openai = {
497
+ ...openai,
498
+ base_url: proxyUrl,
499
+ api_key: PROXY_ACCESS_TOKEN_PLACEHOLDER,
500
+ model,
234
501
  };
502
+ return [makeChange("hermes", configPath, "configure Hermes OpenAI proxy settings", jsonContent(current))];
235
503
  }
236
504
 
237
505
  const PROVIDERS: ProviderDefinition[] = [
238
506
  {
239
507
  id: "codex",
240
508
  name: "Codex CLI",
509
+ commandName: "codex",
241
510
  configPath: (home) => path.join(home, ".codex", "config.toml"),
242
- changes: codexConfig
511
+ changes: codexConfig,
512
+ modelSelectionKind: "single-model",
513
+ protocolPreference: "responses",
243
514
  },
244
515
  {
245
516
  id: "claude-code",
246
517
  name: "Claude Code CLI",
518
+ commandName: "claude",
247
519
  configPath: (home) => path.join(home, ".claude", "settings.json"),
248
- changes: claudeCodeConfig
520
+ changes: claudeCodeConfig,
521
+ modelSelectionKind: "claude-role-mapping",
522
+ protocolPreference: "messages",
249
523
  },
250
524
  {
251
525
  id: "claude-desktop",
252
526
  name: "Claude Desktop App",
253
527
  configPath: (home) => path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
254
- changes: claudeDesktopConfig
528
+ changes: claudeDesktopConfig,
529
+ modelSelectionKind: "single-model",
530
+ protocolPreference: "messages",
255
531
  },
256
532
  {
257
533
  id: "openclaw",
258
534
  name: "OpenClaw Agent",
535
+ commandName: "openclaw",
259
536
  configPath: (home) => path.join(home, ".openclaw", "config.json"),
260
- changes: openclawConfig
537
+ observedPaths: (home) => [
538
+ path.join(home, ".openclaw", "openclaw.json"),
539
+ path.join(home, ".openclaw", "configs"),
540
+ ],
541
+ changes: openclawConfig,
542
+ modelSelectionKind: "single-model",
543
+ protocolPreference: "chat_completions",
544
+ },
545
+ {
546
+ id: "opencode",
547
+ name: "OpenCode",
548
+ commandName: "opencode",
549
+ configPath: (home) => path.join(home, ".config", "opencode", "opencode.json"),
550
+ changes: opencodeConfig,
551
+ modelSelectionKind: "single-model",
552
+ protocolPreference: "responses",
261
553
  },
262
554
  {
263
555
  id: "hermes",
264
556
  name: "Hermes Terminal",
557
+ commandName: "hermes",
265
558
  configPath: (home) => path.join(home, ".hermes", "settings.json"),
266
- changes: hermesConfig
267
- }
559
+ observedPaths: (home) => [
560
+ path.join(home, ".hermes", "config.yaml"),
561
+ path.join(home, ".hermes", "auth.json"),
562
+ ],
563
+ changes: hermesConfig,
564
+ modelSelectionKind: "single-model",
565
+ protocolPreference: "chat_completions",
566
+ },
268
567
  ];
269
568
 
569
+ function getProviderDefinition(providerId: ProviderId): ProviderDefinition {
570
+ const provider = PROVIDERS.find((entry) => entry.id === providerId);
571
+ if (!provider) {
572
+ throw new Error(`unsupported provider: ${providerId}`);
573
+ }
574
+ return provider;
575
+ }
576
+
270
577
  export function detectProviders(options: ProviderDetectOptions = {}): ProviderCandidate[] {
271
578
  const home = resolveHome(options.home);
272
579
  return PROVIDERS.map((provider) => {
273
580
  const configPath = provider.configPath(home);
274
- const detected = fs.existsSync(configPath);
581
+ const configured = fs.existsSync(configPath);
582
+ const executablePath = provider.commandName ? resolveExecutable(provider.commandName) : undefined;
583
+ const observedPaths = provider.observedPaths?.(home).filter((entry) => fs.existsSync(entry)) || [];
584
+ const installed = Boolean(executablePath) || observedPaths.length > 0;
585
+ const status: ProviderCandidate["status"] = configured
586
+ ? "configured"
587
+ : installed
588
+ ? "installed"
589
+ : "missing";
590
+ const reasonParts: string[] = [];
591
+ if (configured) {
592
+ reasonParts.push(`Configured at ${displayPath(home, configPath)}`);
593
+ } else if (installed) {
594
+ reasonParts.push(`Installed, TokenBuddy config missing at ${displayPath(home, configPath)}`);
595
+ } else {
596
+ reasonParts.push(`Missing ${displayPath(home, configPath)}`);
597
+ }
598
+ if (executablePath) {
599
+ reasonParts.push(`CLI ${displayPath(home, executablePath)}`);
600
+ }
601
+ if (observedPaths.length > 0) {
602
+ reasonParts.push(`Native files ${observedPaths.map((entry) => displayPath(home, entry)).join(", ")}`);
603
+ }
275
604
  return {
276
605
  id: provider.id,
277
606
  name: provider.name,
278
- detected,
607
+ detected: status !== "missing",
608
+ configured,
609
+ status,
279
610
  configPath,
280
- reason: detected ? `Found ${configPath}` : `Missing ${configPath}`
611
+ commandName: provider.commandName,
612
+ executablePath,
613
+ observedPaths,
614
+ reason: reasonParts.join(" · "),
281
615
  };
282
616
  });
283
617
  }
284
618
 
619
+ export function getProviderProtocolPreference(providerId: ProviderId): ProtocolPreference | undefined {
620
+ return getProviderDefinition(providerId).protocolPreference;
621
+ }
622
+
623
+ export function getProviderModelSelectionKind(providerId: ProviderId): ModelSelectionKind {
624
+ return getProviderDefinition(providerId).modelSelectionKind;
625
+ }
626
+
285
627
  export function previewProviderInstall(options: ProviderInstallOptions): ProviderFileChange[] {
286
628
  const home = resolveHome(options.home);
287
629
  const providerIds = assertProviderIds(options.providers);
288
630
  if (!options.proxyUrl || !options.proxyUrl.trim()) {
289
631
  throw new Error("proxyUrl is required");
290
632
  }
291
- if (!options.model || !options.model.trim()) {
292
- throw new Error("model is required");
293
- }
294
633
  return providerIds.flatMap((providerId) => {
295
- const provider = PROVIDERS.find((entry) => entry.id === providerId);
296
- if (!provider) {
297
- throw new Error(`unsupported provider: ${providerId}`);
298
- }
299
- return provider.changes(home, options.proxyUrl, options.model);
634
+ const provider = getProviderDefinition(providerId);
635
+ const runtimeConfig = resolveProviderRuntimeConfig(provider, options);
636
+ return provider.changes(home, options.proxyUrl, runtimeConfig);
300
637
  });
301
638
  }
302
639
 
303
640
  export function applyProviderInstall(options: ProviderInstallOptions, store: BuyerStore): ProviderApplyResult[] {
641
+ const providerIds = assertProviderIds(options.providers);
304
642
  const changes = previewProviderInstall(options);
305
643
  const byProvider = new Map<ProviderId, ProviderFileChange[]>();
306
644
  for (const change of changes) {
@@ -313,12 +651,22 @@ export function applyProviderInstall(options: ProviderInstallOptions, store: Buy
313
651
  files: providerChanges.map((change) => ({
314
652
  path: change.path,
315
653
  existed: fs.existsSync(change.path),
316
- content: readText(change.path)
317
- }))
654
+ content: readText(change.path),
655
+ })),
318
656
  };
319
657
  store.saveProviderInstallSnapshot(snapshot);
320
658
  }
321
659
 
660
+ for (const providerId of providerIds) {
661
+ const provider = getProviderDefinition(providerId);
662
+ const runtimeConfig = resolveProviderRuntimeConfig(provider, options);
663
+ store.saveProviderRuntimeConfig(providerId, runtimeConfig);
664
+ }
665
+
666
+ if (options.sellerRouting) {
667
+ store.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, options.sellerRouting);
668
+ }
669
+
322
670
  const applied: ProviderApplyResult[] = [];
323
671
  for (const change of changes) {
324
672
  const dir = path.dirname(change.path);
@@ -329,7 +677,7 @@ export function applyProviderInstall(options: ProviderInstallOptions, store: Buy
329
677
  applied.push({
330
678
  providerId: change.providerId,
331
679
  path: change.path,
332
- action: change.existed ? "updated" : "created"
680
+ action: change.existed ? "updated" : "created",
333
681
  });
334
682
  }
335
683
  return applied;
@@ -358,6 +706,8 @@ export function rollbackProviderInstall(options: ProviderRollbackOptions, store:
358
706
  }
359
707
  }
360
708
  store.removeProviderInstallSnapshot(providerId);
709
+ store.removeProviderRuntimeConfig(providerId);
361
710
  }
711
+ store.removeDaemonRuntimeConfig(ROUTING_CONFIG_KEY);
362
712
  return results;
363
713
  }