@tokenbuddy/tokenbuddy 1.0.38 → 1.0.39

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.
@@ -3,6 +3,9 @@ import * as os from "os";
3
3
  import * as path from "path";
4
4
  export const PROXY_ACCESS_TOKEN_PLACEHOLDER = "tbp_local_17821_d7f4c9a2b8e1";
5
5
  const DESKTOP_PROFILE_ID = "00000000-0000-4000-8000-000000178210";
6
+ const CODEX_PROVIDER_ID = "TokenBuddy";
7
+ const HERMES_PROVIDER_ID = "TokenBuddy";
8
+ const OPENCLAW_TOKENBUDDY_PROVIDER_IDS = ["tokenbuddy", "tokens-buddy"];
6
9
  const CLAUDE_ONE_M_MARKER = "[1M]";
7
10
  const CLAUDE_CLIENT_HAIKU_MODEL = "claude-haiku-4-5";
8
11
  const CLAUDE_CLIENT_SONNET_MODEL = "claude-sonnet-4-6";
@@ -206,6 +209,100 @@ function removeTopLevelYamlSection(existing, sectionName) {
206
209
  ...lines.slice(sectionEnd),
207
210
  ].join("\n").replace(/\n*$/, "")}\n`;
208
211
  }
212
+ function upsertTopLevelYamlObjectEntry(existing, sectionName, entryName, entryValue) {
213
+ const current = parseSimpleYamlObject(existing);
214
+ const section = isPlainRecord(current[sectionName])
215
+ ? { ...current[sectionName] }
216
+ : {};
217
+ const existingEntry = isPlainRecord(section[entryName])
218
+ ? section[entryName]
219
+ : {};
220
+ section[entryName] = {
221
+ ...existingEntry,
222
+ ...entryValue,
223
+ };
224
+ return replaceTopLevelYamlSection(existing, sectionName, yamlContent(section));
225
+ }
226
+ function removeTopLevelYamlObjectEntry(existing, sectionName, entryName, shouldRemove) {
227
+ const current = parseSimpleYamlObject(existing);
228
+ const section = isPlainRecord(current[sectionName])
229
+ ? { ...current[sectionName] }
230
+ : {};
231
+ const entry = section[entryName];
232
+ if (!isPlainRecord(entry) || !shouldRemove(entry)) {
233
+ return existing;
234
+ }
235
+ delete section[entryName];
236
+ if (Object.keys(section).length === 0) {
237
+ return removeTopLevelYamlSection(existing, sectionName);
238
+ }
239
+ return replaceTopLevelYamlSection(existing, sectionName, yamlContent(section));
240
+ }
241
+ function unquoteYamlScalar(value) {
242
+ const trimmed = value.trim();
243
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
244
+ return trimmed.slice(1, -1);
245
+ }
246
+ return trimmed;
247
+ }
248
+ function normalizedProviderName(value) {
249
+ return value.trim().toLowerCase().replace(/[^a-z0-9]/g, "");
250
+ }
251
+ function isTokenBuddyProviderIdentifier(value) {
252
+ if (typeof value !== "string") {
253
+ return false;
254
+ }
255
+ const normalized = normalizedProviderName(value);
256
+ return normalized === "tokenbuddy" || normalized === "tokensbuddy";
257
+ }
258
+ function isTokenBuddyProxyUrl(value) {
259
+ return typeof value === "string" && /(?:127\.0\.0\.1|localhost):17821\b/.test(value);
260
+ }
261
+ function yamlTopLevelListSectionEnd(lines, sectionStart) {
262
+ let index = sectionStart + 1;
263
+ while (index < lines.length) {
264
+ const line = lines[index];
265
+ if (line.trim() && /^[A-Za-z_][A-Za-z0-9_-]*:/.test(line)) {
266
+ break;
267
+ }
268
+ index += 1;
269
+ }
270
+ return index;
271
+ }
272
+ function removeTopLevelYamlListItems(existing, sectionName, shouldRemove) {
273
+ const lines = existing.split(/\r?\n/);
274
+ const sectionStart = lines.findIndex((line) => line === `${sectionName}:` || line.startsWith(`${sectionName}: `));
275
+ if (sectionStart < 0) {
276
+ return existing;
277
+ }
278
+ const sectionEnd = yamlTopLevelListSectionEnd(lines, sectionStart);
279
+ const sectionLines = lines.slice(sectionStart + 1, sectionEnd);
280
+ const keptLines = [];
281
+ let index = 0;
282
+ while (index < sectionLines.length) {
283
+ const line = sectionLines[index];
284
+ if (!line.startsWith("- ")) {
285
+ keptLines.push(line);
286
+ index += 1;
287
+ continue;
288
+ }
289
+ const itemStart = index;
290
+ index += 1;
291
+ while (index < sectionLines.length && !sectionLines[index].startsWith("- ")) {
292
+ index += 1;
293
+ }
294
+ const itemLines = sectionLines.slice(itemStart, index);
295
+ if (!shouldRemove(itemLines)) {
296
+ keptLines.push(...itemLines);
297
+ }
298
+ }
299
+ const before = lines.slice(0, sectionStart);
300
+ const after = lines.slice(sectionEnd);
301
+ const nextLines = keptLines.some((line) => line.trim())
302
+ ? [...before, `${sectionName}:`, ...keptLines, ...after]
303
+ : [...before, ...after];
304
+ return `${nextLines.join("\n").replace(/\n*$/, "")}\n`;
305
+ }
209
306
  function readObjectField(value, key) {
210
307
  if (!value || typeof value !== "object" || Array.isArray(value)) {
211
308
  return undefined;
@@ -253,8 +350,11 @@ function resolveExecutable(commandName) {
253
350
  function escapeTomlString(value) {
254
351
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
255
352
  }
353
+ function escapeRegex(value) {
354
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
355
+ }
256
356
  function replaceTomlSection(existing, sectionName, sectionBody) {
257
- const sectionPattern = new RegExp(`^\\[${sectionName}\\]\\n[\\s\\S]*?(?=^\\[|\\s*$)`, "m");
357
+ const sectionPattern = new RegExp(`^\\[${escapeRegex(sectionName)}\\]\\n[\\s\\S]*?(?=^\\[|(?![\\s\\S]))`, "m");
258
358
  const normalized = existing.trimEnd();
259
359
  const nextSection = `[${sectionName}]\n${sectionBody.trimEnd()}\n`;
260
360
  if (sectionPattern.test(normalized)) {
@@ -262,6 +362,65 @@ function replaceTomlSection(existing, sectionName, sectionBody) {
262
362
  }
263
363
  return `${normalized}${normalized ? "\n\n" : ""}${nextSection}`;
264
364
  }
365
+ function removeTomlSection(existing, sectionName) {
366
+ const sectionPattern = new RegExp(`^\\[${escapeRegex(sectionName)}\\]\\n[\\s\\S]*?(?=^\\[|(?![\\s\\S]))`, "m");
367
+ return `${existing.trimEnd().replace(sectionPattern, "").trimEnd()}\n`;
368
+ }
369
+ function tomlTopLevelEnd(lines) {
370
+ const sectionStart = lines.findIndex((line) => line.trimStart().startsWith("["));
371
+ return sectionStart >= 0 ? sectionStart : lines.length;
372
+ }
373
+ function upsertTopLevelTomlString(existing, key, value) {
374
+ const lines = existing.trimEnd() ? existing.trimEnd().split(/\r?\n/) : [];
375
+ const keyPattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
376
+ const sectionStart = tomlTopLevelEnd(lines);
377
+ for (let index = 0; index < sectionStart; index += 1) {
378
+ if (keyPattern.test(lines[index])) {
379
+ lines[index] = `${key} = "${escapeTomlString(value)}"`;
380
+ return `${lines.join("\n")}\n`;
381
+ }
382
+ }
383
+ const entry = `${key} = "${escapeTomlString(value)}"`;
384
+ if (sectionStart < lines.length) {
385
+ const before = lines.slice(0, sectionStart);
386
+ const after = lines.slice(sectionStart);
387
+ if (before.length > 0 && before[before.length - 1].trim()) {
388
+ before.push(entry, "");
389
+ }
390
+ else {
391
+ before.push(entry);
392
+ }
393
+ return `${[...before, ...after].join("\n")}\n`;
394
+ }
395
+ lines.push(entry);
396
+ return `${lines.join("\n")}\n`;
397
+ }
398
+ function removeTopLevelTomlKey(existing, key) {
399
+ const lines = existing.trimEnd() ? existing.trimEnd().split(/\r?\n/) : [];
400
+ const keyPattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
401
+ const sectionStart = tomlTopLevelEnd(lines);
402
+ const nextLines = [
403
+ ...lines.slice(0, sectionStart).filter((line) => !keyPattern.test(line)),
404
+ ...lines.slice(sectionStart),
405
+ ];
406
+ return nextLines.length > 0 ? `${nextLines.join("\n").replace(/\n*$/, "")}\n` : "";
407
+ }
408
+ function readTomlSection(existing, sectionName) {
409
+ const sectionPattern = new RegExp(`^\\[${escapeRegex(sectionName)}\\]\\n([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, "m");
410
+ return sectionPattern.exec(existing)?.[1];
411
+ }
412
+ function readTopLevelTomlString(existing, key) {
413
+ const lines = existing.trimEnd() ? existing.trimEnd().split(/\r?\n/) : [];
414
+ const sectionStart = tomlTopLevelEnd(lines);
415
+ const keyPattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*["']([^"']+)["']`);
416
+ for (const line of lines.slice(0, sectionStart)) {
417
+ const match = keyPattern.exec(line);
418
+ if (match) {
419
+ return match[1];
420
+ }
421
+ }
422
+ return undefined;
423
+ }
265
424
  function stripClaudeOneMMarker(model) {
266
425
  const trimmed = model.trimEnd();
267
426
  if (!trimmed.toLowerCase().endsWith(CLAUDE_ONE_M_MARKER.toLowerCase())) {
@@ -303,6 +462,23 @@ function pickConfiguredModel(config) {
303
462
  const haikuModel = config.roles.haiku?.upstreamModel;
304
463
  return sonnetModel || opusModel || haikuModel || config.fallbackModel || "";
305
464
  }
465
+ function modelsForProtocol(config, protocol, defaultModel) {
466
+ if (config.selectionKind === "single-model" && config.availableModelsByProtocol) {
467
+ const models = uniqueModelIds(config.availableModelsByProtocol[protocol]);
468
+ if (models.length > 0) {
469
+ return models;
470
+ }
471
+ }
472
+ return uniqueModelIds([defaultModel]);
473
+ }
474
+ function orderedModelsForProtocol(config, protocol, defaultModel) {
475
+ const models = modelsForProtocol(config, protocol, defaultModel);
476
+ const trimmedDefault = defaultModel.trim();
477
+ if (!trimmedDefault || !models.includes(trimmedDefault)) {
478
+ return models;
479
+ }
480
+ return [trimmedDefault, ...models.filter((model) => model !== trimmedDefault)];
481
+ }
306
482
  function resolveProviderRuntimeConfig(provider, options) {
307
483
  const selection = options.providerSelections?.[provider.id];
308
484
  if (selection) {
@@ -317,14 +493,52 @@ function resolveProviderRuntimeConfig(provider, options) {
317
493
  function codexConfig(home, proxyUrl, config) {
318
494
  const model = pickConfiguredModel(config);
319
495
  const configPath = path.join(home, ".codex", "config.toml");
320
- const existing = readText(configPath) || "";
321
- const content = replaceTomlSection(existing, "tokenbuddy", [
322
- `proxy_url = "${escapeTomlString(proxyUrl)}"`,
323
- `api_key = "${PROXY_ACCESS_TOKEN_PLACEHOLDER}"`,
324
- `model = "${escapeTomlString(model)}"`,
496
+ const existing = removeTomlSection(readText(configPath) || "", "tokenbuddy");
497
+ let content = replaceTomlSection(existing, `model_providers.${CODEX_PROVIDER_ID}`, [
498
+ `name = "TokenBuddy"`,
499
+ `base_url = "${escapeTomlString(openAiBaseUrl(proxyUrl))}"`,
500
+ `wire_api = "responses"`,
501
+ `requires_openai_auth = true`,
502
+ `experimental_bearer_token = "${PROXY_ACCESS_TOKEN_PLACEHOLDER}"`,
325
503
  ].join("\n"));
504
+ content = upsertTopLevelTomlString(content, "model_provider", CODEX_PROVIDER_ID);
505
+ content = upsertTopLevelTomlString(content, "model", model);
326
506
  return [makeChange("codex", configPath, "configure TokenBuddy proxy for Codex", content)];
327
507
  }
508
+ function isCodexTokenBuddyConfigured(filePath) {
509
+ const text = readText(filePath) || "";
510
+ const providerSection = readTomlSection(text, `model_providers.${CODEX_PROVIDER_ID}`);
511
+ const legacySection = readTomlSection(text, "tokenbuddy");
512
+ const topLevelProvider = readTopLevelTomlString(text, "model_provider");
513
+ const hasCurrentProvider = Boolean(providerSection) &&
514
+ topLevelProvider === CODEX_PROVIDER_ID &&
515
+ /wire_api\s*=\s*["']responses["']/.test(providerSection || "") &&
516
+ /base_url\s*=\s*["'][^"']*127\.0\.0\.1[^"']*\/v1["']/.test(providerSection || "") &&
517
+ new RegExp(`experimental_bearer_token\\s*=\\s*["']${escapeRegex(PROXY_ACCESS_TOKEN_PLACEHOLDER)}["']`).test(providerSection || "");
518
+ const hasLegacyProvider = Boolean(legacySection) &&
519
+ /proxy_url\s*=\s*["'][^"']*127\.0\.0\.1/.test(legacySection || "") &&
520
+ new RegExp(`api_key\\s*=\\s*["']${escapeRegex(PROXY_ACCESS_TOKEN_PLACEHOLDER)}["']`).test(legacySection || "");
521
+ return hasCurrentProvider || hasLegacyProvider;
522
+ }
523
+ function cleanupCodexConfig(home) {
524
+ const configPath = path.join(home, ".codex", "config.toml");
525
+ if (!fs.existsSync(configPath) || !isCodexTokenBuddyConfigured(configPath)) {
526
+ return [];
527
+ }
528
+ let next = readText(configPath) || "";
529
+ next = removeTomlSection(next, `model_providers.${CODEX_PROVIDER_ID}`);
530
+ next = removeTomlSection(next, "tokenbuddy");
531
+ if (readTopLevelTomlString(next, "model_provider") === CODEX_PROVIDER_ID) {
532
+ next = removeTopLevelTomlKey(next, "model_provider");
533
+ next = removeTopLevelTomlKey(next, "model");
534
+ }
535
+ if (next.trim()) {
536
+ fs.writeFileSync(configPath, next, "utf8");
537
+ return [{ providerId: "codex", path: configPath, action: "cleaned" }];
538
+ }
539
+ fs.rmSync(configPath, { force: true });
540
+ return [{ providerId: "codex", path: configPath, action: "removed" }];
541
+ }
328
542
  function resolveClaudeFallbackAlias(config) {
329
543
  if (config.roles.sonnet?.upstreamModel) {
330
544
  return setClaudeOneMMarker(CLAUDE_CLIENT_SONNET_MODEL, Boolean(config.roles.sonnet.declareOneM));
@@ -448,6 +662,7 @@ function cleanupClaudeCodeConfig(home) {
448
662
  }
449
663
  function claudeDesktopConfig(home, proxyUrl, config) {
450
664
  const model = pickConfiguredModel(config);
665
+ const models = orderedModelsForProtocol(config, "messages", model);
451
666
  const configDir = path.join(home, "Library", "Application Support", "Claude");
452
667
  const configPath = path.join(configDir, "claude_desktop_config.json");
453
668
  const threepDir = path.join(home, "Library", "Application Support", "Claude-3p");
@@ -465,7 +680,7 @@ function claudeDesktopConfig(home, proxyUrl, config) {
465
680
  inferenceGatewayAuthScheme: "bearer",
466
681
  inferenceGatewayBaseUrl: proxyUrl,
467
682
  inferenceProvider: "gateway",
468
- inferenceModels: [{ name: model }],
683
+ inferenceModels: models.map((modelName) => ({ name: modelName })),
469
684
  };
470
685
  const meta = readJsonObject(metaPath);
471
686
  const existingEntries = Array.isArray(meta.entries) ? meta.entries : [];
@@ -507,12 +722,11 @@ function isClaudeDesktopTokenBuddyConfigured(_filePath, home) {
507
722
  }
508
723
  function cleanupClaudeDesktopConfig(home) {
509
724
  const paths = claudeDesktopPaths(home);
510
- if (!isClaudeDesktopTokenBuddyConfigured(paths.configPath, home)) {
511
- return [];
512
- }
513
725
  const results = [];
726
+ const meta = readJsonObject(paths.metaPath);
727
+ const tokenBuddyIsActive = meta.appliedId === DESKTOP_PROFILE_ID;
514
728
  const primary = readJsonObject(paths.configPath);
515
- if (primary.deploymentMode === "3p") {
729
+ if (tokenBuddyIsActive && primary.deploymentMode === "3p") {
516
730
  delete primary.deploymentMode;
517
731
  if (Object.keys(primary).length > 0) {
518
732
  fs.writeFileSync(paths.configPath, jsonContent(primary), "utf8");
@@ -524,7 +738,7 @@ function cleanupClaudeDesktopConfig(home) {
524
738
  }
525
739
  }
526
740
  const threep = readJsonObject(paths.threepConfigPath);
527
- if (threep.deploymentMode === "3p") {
741
+ if (tokenBuddyIsActive && threep.deploymentMode === "3p") {
528
742
  delete threep.deploymentMode;
529
743
  if (Object.keys(threep).length > 0) {
530
744
  fs.writeFileSync(paths.threepConfigPath, jsonContent(threep), "utf8");
@@ -539,7 +753,6 @@ function cleanupClaudeDesktopConfig(home) {
539
753
  fs.rmSync(paths.profilePath, { force: true });
540
754
  results.push({ providerId: "claude-desktop", path: paths.profilePath, action: "removed" });
541
755
  }
542
- const meta = readJsonObject(paths.metaPath);
543
756
  let changedMeta = false;
544
757
  if (meta.appliedId === DESKTOP_PROFILE_ID) {
545
758
  delete meta.appliedId;
@@ -547,7 +760,8 @@ function cleanupClaudeDesktopConfig(home) {
547
760
  }
548
761
  if (Array.isArray(meta.entries)) {
549
762
  const nextEntries = meta.entries.filter((entry) => {
550
- return !(isPlainRecord(entry) && entry.id === DESKTOP_PROFILE_ID);
763
+ return !(isPlainRecord(entry) &&
764
+ (entry.id === DESKTOP_PROFILE_ID || entry.name === "TokenBuddy"));
551
765
  });
552
766
  if (nextEntries.length !== meta.entries.length) {
553
767
  meta.entries = nextEntries;
@@ -562,23 +776,19 @@ function cleanupClaudeDesktopConfig(home) {
562
776
  }
563
777
  function openclawConfig(home, proxyUrl, config) {
564
778
  const model = pickConfiguredModel(config);
779
+ const configuredModels = orderedModelsForProtocol(config, "chat_completions", model);
780
+ const defaultModel = configuredModels.includes(model) ? model : configuredModels[0] || model;
565
781
  const configPath = path.join(home, ".openclaw", "openclaw.json");
566
782
  const current = readJsonObject(configPath);
567
783
  const models = isPlainRecord(current.models) ? current.models : {};
568
784
  const providers = isPlainRecord(models.providers) ? models.providers : {};
569
785
  const existingProvider = isPlainRecord(providers.tokenbuddy) ? providers.tokenbuddy : {};
570
- const existingModels = Array.isArray(existingProvider.models) ? existingProvider.models : [];
571
- const nextModels = [
572
- ...existingModels.filter((entry) => {
573
- return !(isPlainRecord(entry) && entry.id === model);
574
- }),
575
- {
576
- id: model,
577
- name: model,
578
- api: "openai-completions",
579
- input: ["text", "image"],
580
- },
581
- ];
786
+ const nextModels = configuredModels.map((modelName) => ({
787
+ id: modelName,
788
+ name: modelName,
789
+ api: "openai-completions",
790
+ input: ["text", "image"],
791
+ }));
582
792
  providers.tokenbuddy = {
583
793
  ...existingProvider,
584
794
  baseUrl: openAiBaseUrl(proxyUrl),
@@ -591,29 +801,43 @@ function openclawConfig(home, proxyUrl, config) {
591
801
  current.models = models;
592
802
  const agents = isPlainRecord(current.agents) ? current.agents : {};
593
803
  const defaults = isPlainRecord(agents.defaults) ? agents.defaults : {};
594
- defaults.model = `tokenbuddy/${model}`;
804
+ defaults.model = `tokenbuddy/${defaultModel}`;
595
805
  agents.defaults = defaults;
596
806
  current.agents = agents;
597
807
  return [makeChange("openclaw", configPath, "configure OpenClaw proxy settings", jsonContent(current))];
598
808
  }
809
+ function isOpenclawTokenBuddyModelRef(value) {
810
+ if (typeof value !== "string") {
811
+ return false;
812
+ }
813
+ return isTokenBuddyProviderIdentifier(value.split("/", 1)[0]);
814
+ }
599
815
  function isOpenclawTokenBuddyConfigured(filePath) {
600
816
  const current = readJsonObject(filePath);
601
- const tokenbuddy = readObjectField(readObjectField(readObjectField(current, "models"), "providers"), "tokenbuddy");
817
+ const providers = readObjectField(readObjectField(current, "models"), "providers");
602
818
  const defaults = readObjectField(readObjectField(current, "agents"), "defaults");
603
- if (!tokenbuddy || !defaults) {
604
- return false;
819
+ if (providers) {
820
+ for (const providerId of OPENCLAW_TOKENBUDDY_PROVIDER_IDS) {
821
+ if (Object.prototype.hasOwnProperty.call(providers, providerId)) {
822
+ return true;
823
+ }
824
+ }
825
+ }
826
+ if (defaults && isOpenclawTokenBuddyModelRef(defaults.model)) {
827
+ return true;
605
828
  }
606
- const defaultModel = defaults.model;
607
- return tokenbuddy.apiKey === PROXY_ACCESS_TOKEN_PLACEHOLDER &&
608
- typeof tokenbuddy.baseUrl === "string" &&
609
- tokenbuddy.baseUrl.includes("127.0.0.1") &&
610
- tokenbuddy.baseUrl.endsWith("/v1") &&
611
- typeof defaultModel === "string" &&
612
- defaultModel.startsWith("tokenbuddy/");
829
+ const defaultModels = readObjectField(defaults, "models");
830
+ if (defaultModels && Object.keys(defaultModels).some((modelRef) => isOpenclawTokenBuddyModelRef(modelRef))) {
831
+ return true;
832
+ }
833
+ const agentsList = readObjectField(current, "agents")?.list;
834
+ return Array.isArray(agentsList) && agentsList.some((agent) => {
835
+ return isPlainRecord(agent) && isOpenclawTokenBuddyModelRef(agent.model);
836
+ });
613
837
  }
614
838
  function cleanupOpenclawConfig(home) {
615
839
  const configPath = path.join(home, ".openclaw", "openclaw.json");
616
- if (!fs.existsSync(configPath) || !isOpenclawTokenBuddyConfigured(configPath)) {
840
+ if (!fs.existsSync(configPath)) {
617
841
  return [];
618
842
  }
619
843
  const current = readJsonObject(configPath);
@@ -623,15 +847,35 @@ function cleanupOpenclawConfig(home) {
623
847
  const defaults = readObjectField(agents, "defaults");
624
848
  let changed = false;
625
849
  if (providers) {
626
- changed = removeObjectKey(providers, "tokenbuddy") || changed;
850
+ for (const providerId of OPENCLAW_TOKENBUDDY_PROVIDER_IDS) {
851
+ changed = removeObjectKey(providers, providerId) || changed;
852
+ }
627
853
  if (models && Object.keys(providers).length === 0) {
628
854
  delete models.providers;
629
855
  }
630
856
  }
631
- if (defaults && typeof defaults.model === "string" && defaults.model.startsWith("tokenbuddy/")) {
857
+ if (defaults && isOpenclawTokenBuddyModelRef(defaults.model)) {
632
858
  delete defaults.model;
633
859
  changed = true;
634
860
  }
861
+ const defaultModels = readObjectField(defaults, "models");
862
+ if (defaultModels) {
863
+ for (const modelRef of Object.keys(defaultModels)) {
864
+ changed = (isOpenclawTokenBuddyModelRef(modelRef) && removeObjectKey(defaultModels, modelRef)) || changed;
865
+ }
866
+ if (Object.keys(defaultModels).length === 0 && defaults) {
867
+ delete defaults.models;
868
+ }
869
+ }
870
+ const agentsList = agents?.list;
871
+ if (Array.isArray(agentsList)) {
872
+ for (const agent of agentsList) {
873
+ if (isPlainRecord(agent) && isOpenclawTokenBuddyModelRef(agent.model)) {
874
+ delete agent.model;
875
+ changed = true;
876
+ }
877
+ }
878
+ }
635
879
  if (!changed) {
636
880
  return [];
637
881
  }
@@ -642,6 +886,77 @@ function openAiBaseUrl(proxyUrl) {
642
886
  const normalized = proxyUrl.replace(/\/+$/, "");
643
887
  return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
644
888
  }
889
+ function anthropicBaseUrl(proxyUrl) {
890
+ const normalized = proxyUrl.replace(/\/+$/, "");
891
+ return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized;
892
+ }
893
+ const OPENCODE_TOKENBUDDY_PROVIDERS = [
894
+ {
895
+ providerId: "tokenbuddy",
896
+ protocol: "chat_completions",
897
+ name: "TokenBuddy",
898
+ npm: "@ai-sdk/openai-compatible",
899
+ baseUrl: openAiBaseUrl,
900
+ },
901
+ {
902
+ providerId: "tokenbuddy-responses",
903
+ protocol: "responses",
904
+ name: "TokenBuddy Responses",
905
+ npm: "@ai-sdk/openai",
906
+ baseUrl: openAiBaseUrl,
907
+ },
908
+ {
909
+ providerId: "tokenbuddy-messages",
910
+ protocol: "messages",
911
+ name: "TokenBuddy Messages",
912
+ npm: "@ai-sdk/anthropic",
913
+ baseUrl: anthropicBaseUrl,
914
+ },
915
+ ];
916
+ function uniqueModelIds(models) {
917
+ const seen = new Set();
918
+ const output = [];
919
+ for (const model of models ?? []) {
920
+ const trimmed = model.trim();
921
+ if (!trimmed || seen.has(trimmed)) {
922
+ continue;
923
+ }
924
+ seen.add(trimmed);
925
+ output.push(trimmed);
926
+ }
927
+ return output;
928
+ }
929
+ function opencodeModelsForProtocol(config, protocol, defaultModel) {
930
+ if (config.selectionKind === "single-model" && config.availableModelsByProtocol) {
931
+ return uniqueModelIds(config.availableModelsByProtocol[protocol]);
932
+ }
933
+ return protocol === "chat_completions" ? uniqueModelIds([defaultModel]) : [];
934
+ }
935
+ function opencodeModelConfig(model) {
936
+ return {
937
+ name: model,
938
+ attachment: true,
939
+ tool_call: true,
940
+ };
941
+ }
942
+ function opencodeDefaultModelRef(defaultModel, modelsByProvider) {
943
+ for (const provider of OPENCODE_TOKENBUDDY_PROVIDERS) {
944
+ if (modelsByProvider.get(provider.providerId)?.includes(defaultModel)) {
945
+ return `${provider.providerId}/${defaultModel}`;
946
+ }
947
+ }
948
+ for (const provider of OPENCODE_TOKENBUDDY_PROVIDERS) {
949
+ const firstModel = modelsByProvider.get(provider.providerId)?.[0];
950
+ if (firstModel) {
951
+ return `${provider.providerId}/${firstModel}`;
952
+ }
953
+ }
954
+ return `tokenbuddy/${defaultModel}`;
955
+ }
956
+ function isOpencodeTokenBuddyModelRef(value) {
957
+ return typeof value === "string" &&
958
+ OPENCODE_TOKENBUDDY_PROVIDERS.some((provider) => value.startsWith(`${provider.providerId}/`));
959
+ }
645
960
  function opencodeConfig(home, proxyUrl, config) {
646
961
  const model = pickConfiguredModel(config);
647
962
  const configPath = path.join(home, ".config", "opencode", "opencode.json");
@@ -649,63 +964,86 @@ function opencodeConfig(home, proxyUrl, config) {
649
964
  const providers = current.provider && typeof current.provider === "object" && !Array.isArray(current.provider)
650
965
  ? current.provider
651
966
  : {};
652
- providers.tokenbuddy = {
653
- name: "TokenBuddy",
654
- npm: "@ai-sdk/openai-compatible",
655
- options: {
656
- apiKey: PROXY_ACCESS_TOKEN_PLACEHOLDER,
657
- baseURL: openAiBaseUrl(proxyUrl),
658
- },
659
- models: {
660
- [model]: {
661
- name: model,
662
- attachment: true,
663
- tool_call: true,
967
+ for (const provider of OPENCODE_TOKENBUDDY_PROVIDERS) {
968
+ delete providers[provider.providerId];
969
+ }
970
+ const modelsByProvider = new Map();
971
+ for (const provider of OPENCODE_TOKENBUDDY_PROVIDERS) {
972
+ const models = opencodeModelsForProtocol(config, provider.protocol, model);
973
+ if (models.length === 0) {
974
+ continue;
975
+ }
976
+ modelsByProvider.set(provider.providerId, models);
977
+ providers[provider.providerId] = {
978
+ name: provider.name,
979
+ npm: provider.npm,
980
+ options: {
981
+ apiKey: PROXY_ACCESS_TOKEN_PLACEHOLDER,
982
+ baseURL: provider.baseUrl(proxyUrl),
664
983
  },
665
- },
666
- };
984
+ models: Object.fromEntries(models.map((modelId) => [modelId, opencodeModelConfig(modelId)])),
985
+ };
986
+ }
667
987
  current.provider = providers;
668
988
  // 写顶层 model / small_model,让 opencode 默认走 tokenbuddy 而不是残留的 openai/qwen-plus 死链
669
- current.model = `tokenbuddy/${model}`;
670
- current.small_model = `tokenbuddy/${model}`;
989
+ const defaultModelRef = opencodeDefaultModelRef(model, modelsByProvider);
990
+ current.model = defaultModelRef;
991
+ current.small_model = defaultModelRef;
671
992
  return [makeChange("opencode", configPath, "configure OpenCode provider for TokenBuddy proxy", jsonContent(current))];
672
993
  }
994
+ function isLegacyOpencodeTokenBuddyProvider(providerId, providerConfig) {
995
+ if (providerId !== "openai" || !isPlainRecord(providerConfig)) {
996
+ return false;
997
+ }
998
+ const options = readObjectField(providerConfig, "options");
999
+ const apiKey = options?.apiKey;
1000
+ const tokenBuddyKey = apiKey === PROXY_ACCESS_TOKEN_PLACEHOLDER ||
1001
+ (typeof apiKey === "string" && apiKey.toUpperCase().startsWith("TOKENBUDDY_"));
1002
+ return tokenBuddyKey && isTokenBuddyProxyUrl(options?.baseURL);
1003
+ }
673
1004
  function isOpencodeTokenBuddyConfigured(filePath) {
674
1005
  const current = readJsonObject(filePath);
675
- const tokenbuddy = readObjectField(readObjectField(current, "provider"), "tokenbuddy");
676
- const options = readObjectField(tokenbuddy, "options");
677
- if (!tokenbuddy || !options) {
678
- return false;
1006
+ const providers = readObjectField(current, "provider");
1007
+ if (providers) {
1008
+ for (const provider of OPENCODE_TOKENBUDDY_PROVIDERS) {
1009
+ if (Object.prototype.hasOwnProperty.call(providers, provider.providerId)) {
1010
+ return true;
1011
+ }
1012
+ }
1013
+ for (const [providerId, providerConfig] of Object.entries(providers)) {
1014
+ if (isLegacyOpencodeTokenBuddyProvider(providerId, providerConfig)) {
1015
+ return true;
1016
+ }
1017
+ }
679
1018
  }
680
- return tokenbuddy.npm === "@ai-sdk/openai-compatible" &&
681
- options.apiKey === PROXY_ACCESS_TOKEN_PLACEHOLDER &&
682
- typeof options.baseURL === "string" &&
683
- options.baseURL.includes("127.0.0.1") &&
684
- options.baseURL.endsWith("/v1") &&
685
- typeof current.model === "string" &&
686
- current.model.startsWith("tokenbuddy/") &&
687
- typeof current.small_model === "string" &&
688
- current.small_model.startsWith("tokenbuddy/");
1019
+ return isOpencodeTokenBuddyModelRef(current.model) || isOpencodeTokenBuddyModelRef(current.small_model);
689
1020
  }
690
1021
  function cleanupOpencodeConfig(home) {
691
1022
  const configPath = path.join(home, ".config", "opencode", "opencode.json");
692
- if (!fs.existsSync(configPath) || !isOpencodeTokenBuddyConfigured(configPath)) {
1023
+ if (!fs.existsSync(configPath)) {
693
1024
  return [];
694
1025
  }
695
1026
  const current = readJsonObject(configPath);
696
1027
  const providers = readObjectField(current, "provider");
697
1028
  let changed = false;
698
1029
  if (providers) {
699
- changed = removeObjectKey(providers, "tokenbuddy") || changed;
1030
+ for (const provider of OPENCODE_TOKENBUDDY_PROVIDERS) {
1031
+ changed = removeObjectKey(providers, provider.providerId) || changed;
1032
+ }
1033
+ for (const [providerId, providerConfig] of Object.entries(providers)) {
1034
+ changed = (isLegacyOpencodeTokenBuddyProvider(providerId, providerConfig) && removeObjectKey(providers, providerId)) || changed;
1035
+ }
700
1036
  if (Object.keys(providers).length === 0) {
701
1037
  delete current.provider;
702
1038
  }
703
1039
  }
704
- if (typeof current.model === "string" && current.model.startsWith("tokenbuddy/")) {
1040
+ const model = current.model;
1041
+ if (isOpencodeTokenBuddyModelRef(model)) {
705
1042
  delete current.model;
706
1043
  changed = true;
707
1044
  }
708
- if (typeof current.small_model === "string" && current.small_model.startsWith("tokenbuddy/")) {
1045
+ const smallModel = current.small_model;
1046
+ if (isOpencodeTokenBuddyModelRef(smallModel)) {
709
1047
  delete current.small_model;
710
1048
  changed = true;
711
1049
  }
@@ -725,39 +1063,117 @@ function hermesConfig(home, proxyUrl, config) {
725
1063
  const existing = readText(configPath) || "";
726
1064
  const current = parseSimpleYamlObject(existing);
727
1065
  const modelConfig = isPlainRecord(current.model) ? current.model : {};
1066
+ const baseUrl = openAiBaseUrl(proxyUrl);
728
1067
  const nextModelConfig = {
729
1068
  ...modelConfig,
730
1069
  default: model,
731
- provider: "custom",
732
- base_url: openAiBaseUrl(proxyUrl),
1070
+ provider: HERMES_PROVIDER_ID,
1071
+ base_url: baseUrl,
733
1072
  api_key: PROXY_ACCESS_TOKEN_PLACEHOLDER,
734
1073
  api_mode: "chat_completions",
735
1074
  };
736
- const content = replaceTopLevelYamlSection(existing, "model", yamlContent(nextModelConfig));
1075
+ const withModel = replaceTopLevelYamlSection(existing, "model", yamlContent(nextModelConfig));
1076
+ const content = upsertTopLevelYamlObjectEntry(withModel, "providers", HERMES_PROVIDER_ID, {
1077
+ name: HERMES_PROVIDER_ID,
1078
+ base_url: baseUrl,
1079
+ api_key: PROXY_ACCESS_TOKEN_PLACEHOLDER,
1080
+ transport: "chat_completions",
1081
+ default_model: model,
1082
+ });
737
1083
  return [makeChange("hermes", configPath, "configure Hermes OpenAI proxy settings", content)];
738
1084
  }
739
- function isHermesTokenBuddyConfigured(filePath) {
740
- const current = readYamlObject(filePath);
741
- const modelConfig = readObjectField(current, "model");
742
- if (!modelConfig) {
1085
+ function isHermesTokenBuddyBaseUrl(value) {
1086
+ return typeof value === "string" &&
1087
+ value.includes("127.0.0.1") &&
1088
+ value.endsWith("/v1");
1089
+ }
1090
+ function isHermesTokenBuddyProviderValue(value) {
1091
+ if (typeof value !== "string") {
743
1092
  return false;
744
1093
  }
745
- return modelConfig.provider === "custom" &&
1094
+ const normalized = value.trim().toLowerCase();
1095
+ return normalized === "custom" ||
1096
+ normalized === HERMES_PROVIDER_ID.toLowerCase() ||
1097
+ normalized === "custom:tokenbuddy";
1098
+ }
1099
+ function isHermesTokenBuddyProviderEntry(entry) {
1100
+ return entry.api_key === PROXY_ACCESS_TOKEN_PLACEHOLDER &&
1101
+ isHermesTokenBuddyBaseUrl(entry.base_url) &&
1102
+ (entry.transport === "chat_completions" || entry.api_mode === "chat_completions");
1103
+ }
1104
+ function isHermesTokenBuddyCustomProviderItem(itemLines) {
1105
+ let name = "";
1106
+ let baseUrl = "";
1107
+ let apiKey = "";
1108
+ for (const line of itemLines) {
1109
+ const nameMatch = /^\s*-\s*name:\s*(.+)\s*$/.exec(line) ?? /^\s*name:\s*(.+)\s*$/.exec(line);
1110
+ if (nameMatch) {
1111
+ name = unquoteYamlScalar(nameMatch[1]);
1112
+ continue;
1113
+ }
1114
+ const baseUrlMatch = /^\s*base_url:\s*(.+)\s*$/.exec(line);
1115
+ if (baseUrlMatch) {
1116
+ baseUrl = unquoteYamlScalar(baseUrlMatch[1]);
1117
+ continue;
1118
+ }
1119
+ const apiKeyMatch = /^\s*api_key:\s*(.+)\s*$/.exec(line);
1120
+ if (apiKeyMatch) {
1121
+ apiKey = unquoteYamlScalar(apiKeyMatch[1]);
1122
+ }
1123
+ }
1124
+ const normalizedName = normalizedProviderName(name);
1125
+ return normalizedName === "tokenbuddy" ||
1126
+ normalizedName === "tokensbuddy" ||
1127
+ (isHermesTokenBuddyBaseUrl(baseUrl) && apiKey === PROXY_ACCESS_TOKEN_PLACEHOLDER);
1128
+ }
1129
+ function hasHermesTokenBuddyCustomProvider(text) {
1130
+ let found = false;
1131
+ removeTopLevelYamlListItems(text, "custom_providers", (itemLines) => {
1132
+ if (isHermesTokenBuddyCustomProviderItem(itemLines)) {
1133
+ found = true;
1134
+ }
1135
+ return false;
1136
+ });
1137
+ return found;
1138
+ }
1139
+ function isHermesTokenBuddyModelConfig(modelConfig, namedProviderConfigured) {
1140
+ return isHermesTokenBuddyProviderValue(modelConfig.provider) &&
746
1141
  modelConfig.api_key === PROXY_ACCESS_TOKEN_PLACEHOLDER &&
747
1142
  modelConfig.api_mode === "chat_completions" &&
748
- typeof modelConfig.base_url === "string" &&
749
- modelConfig.base_url.includes("127.0.0.1") &&
750
- modelConfig.base_url.endsWith("/v1") &&
1143
+ (isHermesTokenBuddyBaseUrl(modelConfig.base_url) || namedProviderConfigured) &&
751
1144
  typeof modelConfig.default === "string" &&
752
1145
  modelConfig.default.length > 0;
753
1146
  }
1147
+ function isHermesTokenBuddyConfigured(filePath) {
1148
+ const text = readText(filePath) || "";
1149
+ const current = readYamlObject(filePath);
1150
+ const modelConfig = readObjectField(current, "model");
1151
+ if (!modelConfig) {
1152
+ return hasHermesTokenBuddyCustomProvider(text);
1153
+ }
1154
+ const hasTokenBuddyCustomProvider = hasHermesTokenBuddyCustomProvider(text);
1155
+ const providersConfig = readObjectField(current, "providers");
1156
+ const tokenBuddyProvider = readObjectField(providersConfig, HERMES_PROVIDER_ID);
1157
+ const namedProviderConfigured = Boolean(tokenBuddyProvider && isHermesTokenBuddyProviderEntry(tokenBuddyProvider));
1158
+ const hasTokenBuddyModel = isHermesTokenBuddyModelConfig(modelConfig, namedProviderConfigured);
1159
+ return hasTokenBuddyModel || hasTokenBuddyCustomProvider;
1160
+ }
754
1161
  function cleanupHermesConfig(home) {
755
1162
  const configPath = path.join(home, ".hermes", "config.yaml");
756
1163
  if (!fs.existsSync(configPath) || !isHermesTokenBuddyConfigured(configPath)) {
757
1164
  return [];
758
1165
  }
759
1166
  const existing = readText(configPath) || "";
760
- const next = removeTopLevelYamlSection(existing, "model");
1167
+ const current = parseSimpleYamlObject(existing);
1168
+ const modelConfig = readObjectField(current, "model");
1169
+ const providersConfig = readObjectField(current, "providers");
1170
+ const tokenBuddyProvider = readObjectField(providersConfig, HERMES_PROVIDER_ID);
1171
+ const namedProviderConfigured = Boolean(tokenBuddyProvider && isHermesTokenBuddyProviderEntry(tokenBuddyProvider));
1172
+ const withoutModel = modelConfig && isHermesTokenBuddyModelConfig(modelConfig, namedProviderConfigured)
1173
+ ? removeTopLevelYamlSection(existing, "model")
1174
+ : existing;
1175
+ const withoutProvider = removeTopLevelYamlObjectEntry(withoutModel, "providers", HERMES_PROVIDER_ID, isHermesTokenBuddyProviderEntry);
1176
+ const next = removeTopLevelYamlListItems(withoutProvider, "custom_providers", isHermesTokenBuddyCustomProviderItem);
761
1177
  if (next.trim()) {
762
1178
  fs.writeFileSync(configPath, next, "utf8");
763
1179
  return [{ providerId: "hermes", path: configPath, action: "cleaned" }];
@@ -771,7 +1187,9 @@ const PROVIDERS = [
771
1187
  name: "Codex CLI",
772
1188
  commandName: "codex",
773
1189
  configPath: (home) => path.join(home, ".codex", "config.toml"),
1190
+ isConfigured: isCodexTokenBuddyConfigured,
774
1191
  changes: codexConfig,
1192
+ cleanup: cleanupCodexConfig,
775
1193
  modelSelectionKind: "single-model",
776
1194
  protocolPreference: "responses",
777
1195
  },
@@ -982,6 +1400,8 @@ export function applyProviderInstall(options, store) {
982
1400
  /**
983
1401
  * 回滚 provider 安装。
984
1402
  * 从 `store` 读取安装前的快照,恢复原文件(如果快照里有原始内容)。
1403
+ * 恢复后仍会执行 provider cleanup,确保 repeated apply 或旧 TokenBuddy
1404
+ * 配置升级后的 disconnect 语义是移除 TokenBuddy,而不是恢复旧 TokenBuddy。
985
1405
  * 没有快照的 provider 标记为 `missing_snapshot`。
986
1406
  *
987
1407
  * @param options 回滚选项
@@ -1020,6 +1440,7 @@ export function rollbackProviderInstall(options, store) {
1020
1440
  results.push({ providerId, path: file.path, action: "removed" });
1021
1441
  }
1022
1442
  }
1443
+ results.push(...(provider.cleanup?.(home) ?? []));
1023
1444
  store.removeProviderInstallSnapshot(providerId);
1024
1445
  store.removeProviderRuntimeConfig(providerId);
1025
1446
  }