betahi-copilot-bridge 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -112,7 +112,13 @@ limit (about 900k succeeds; around 1,000,046 is rejected as too long).
112
112
 
113
113
  Bridge endpoints: `POST /v1/messages`, `POST /v1/messages/count_tokens`.
114
114
 
115
- Point Claude Code at the bridge through **`~/.claude/settings.json`**:
115
+ `copilot-bridge start` automatically writes `ANTHROPIC_BASE_URL` (and a dummy
116
+ `ANTHROPIC_AUTH_TOKEN` if absent) into **`~/.claude/settings.json`**, so
117
+ `claude` works the moment the bridge is listening — even on a different host
118
+ or port. Pass `--no-claude-setup` to skip this. All other keys in your
119
+ settings file (model overrides, plugins, marketplaces) are preserved.
120
+
121
+ If you prefer to wire it yourself:
116
122
 
117
123
  ```json
118
124
  {
package/dist/main.js CHANGED
@@ -281,6 +281,111 @@ const auth = defineCommand({
281
281
  }
282
282
  });
283
283
 
284
+ //#endregion
285
+ //#region src/lib/claude-settings.ts
286
+ const getClaudeSettingsPaths = () => {
287
+ const cwd = process.cwd();
288
+ const home = process.env.HOME ?? os.homedir();
289
+ return [
290
+ path.join(home, ".claude", "settings.json"),
291
+ path.join(cwd, ".claude", "settings.json"),
292
+ path.join(cwd, ".claude", "settings.local.json")
293
+ ];
294
+ };
295
+ const readClaudeSettingsFile = async (filePath) => {
296
+ try {
297
+ const content = await fs.readFile(filePath, "utf8");
298
+ return JSON.parse(content);
299
+ } catch (error) {
300
+ if (error.code === "ENOENT") return;
301
+ consola.warn(`Failed to read Claude settings from ${filePath}:`, error);
302
+ return;
303
+ }
304
+ };
305
+ /**
306
+ * Read just the `ANTHROPIC_BASE_URL` from a single Claude settings file.
307
+ * Used at startup so the user can pin the bridge port by editing one place
308
+ * instead of passing `--port` every time.
309
+ */
310
+ const readClaudeBaseUrl = async (configPath) => {
311
+ const value = (await readClaudeSettingsFile(configPath))?.env?.ANTHROPIC_BASE_URL;
312
+ return typeof value === "string" ? value : void 0;
313
+ };
314
+ /**
315
+ * Parse the port from an `ANTHROPIC_BASE_URL`-style string. Returns
316
+ * `undefined` if the URL is malformed or has no explicit port.
317
+ */
318
+ const parsePortFromBaseUrl = (baseUrl) => {
319
+ if (!baseUrl) return void 0;
320
+ try {
321
+ const url = new URL(baseUrl);
322
+ if (!url.port) return void 0;
323
+ const port = Number.parseInt(url.port, 10);
324
+ return Number.isFinite(port) && port > 0 ? port : void 0;
325
+ } catch {
326
+ return;
327
+ }
328
+ };
329
+ const getClaudeSettingsEnv = async () => {
330
+ const merged = {};
331
+ for (const filePath of getClaudeSettingsPaths()) {
332
+ const settings = await readClaudeSettingsFile(filePath);
333
+ if (!settings?.env) continue;
334
+ for (const [key, value] of Object.entries(settings.env)) if (typeof value === "string") merged[key] = value;
335
+ }
336
+ return merged;
337
+ };
338
+ /**
339
+ * Update `~/.claude/settings.json` so its env block points Claude Code at the
340
+ * running bridge. Preserves all unrelated keys (model overrides, plugins,
341
+ * marketplaces, etc.). Sets a dummy auth token only if none is present.
342
+ */
343
+ async function applyClaudeConfig(input) {
344
+ const { configPath, baseUrl } = input;
345
+ let raw = "";
346
+ let created = false;
347
+ try {
348
+ raw = await fs.readFile(configPath, "utf8");
349
+ } catch (error) {
350
+ if (error.code === "ENOENT") created = true;
351
+ else throw error;
352
+ }
353
+ let parsed = {};
354
+ if (raw.trim().length > 0) try {
355
+ parsed = JSON.parse(raw);
356
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) parsed = {};
357
+ } catch {
358
+ return {
359
+ configPath,
360
+ changed: false,
361
+ created: false
362
+ };
363
+ }
364
+ const env = typeof parsed.env === "object" && parsed.env !== null && !Array.isArray(parsed.env) ? { ...parsed.env } : {};
365
+ const previousBaseUrl = typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : void 0;
366
+ env.ANTHROPIC_BASE_URL = baseUrl;
367
+ if (typeof env.ANTHROPIC_AUTH_TOKEN !== "string" || !env.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = "dummy";
368
+ const next = {
369
+ ...parsed,
370
+ env
371
+ };
372
+ const serialized = `${JSON.stringify(next, null, 2)}\n`;
373
+ if (serialized === raw) return {
374
+ configPath,
375
+ changed: false,
376
+ created: false,
377
+ previousBaseUrl
378
+ };
379
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
380
+ await fs.writeFile(configPath, serialized);
381
+ return {
382
+ configPath,
383
+ changed: true,
384
+ created,
385
+ previousBaseUrl
386
+ };
387
+ }
388
+
284
389
  //#endregion
285
390
  //#region src/lib/codex-config.ts
286
391
  const BEGIN_MARK = "# >>> copilot-bridge managed block — auto-generated, do not edit between markers >>>";
@@ -395,6 +500,18 @@ async function readCodexUserConfigFromDisk(configPath) {
395
500
  }
396
501
  }
397
502
 
503
+ //#endregion
504
+ //#region src/lib/defaults.ts
505
+ const DEFAULT_HOST = "127.0.0.1";
506
+ const DEFAULT_PORT = 4142;
507
+ const CODEX_DEFAULTS = {
508
+ providerId: "bridge",
509
+ providerName: "Copilot Bridge",
510
+ setAsDefault: true,
511
+ configPath: path.join(os.homedir(), ".codex", "config.toml")
512
+ };
513
+ const CLAUDE_CONFIG_PATH = path.join(os.homedir(), ".claude", "settings.json");
514
+
398
515
  //#endregion
399
516
  //#region src/lib/model-capabilities.ts
400
517
  const MODEL_CAPABILITIES = [
@@ -615,49 +732,6 @@ const clampReasoningEffort = (modelId, requested) => {
615
732
  };
616
733
  };
617
734
 
618
- //#endregion
619
- //#region src/lib/settings.ts
620
- const SETTINGS_DIR = path.join(os.homedir(), ".config", "copilot-bridge");
621
- const SETTINGS_FILE = path.join(SETTINGS_DIR, "settings.json");
622
- const DEFAULT_SETTINGS = {
623
- host: "127.0.0.1",
624
- port: 4142,
625
- codex: {
626
- enabled: true,
627
- providerId: "bridge",
628
- providerName: "Copilot Bridge",
629
- setAsDefault: true,
630
- configPath: path.join(os.homedir(), ".codex", "config.toml")
631
- }
632
- };
633
- function deepMerge(base, override) {
634
- if (typeof base !== "object" || base === null || Array.isArray(base) || typeof override !== "object" || override === null || Array.isArray(override)) return override ?? base;
635
- const out = { ...base };
636
- for (const [key, value] of Object.entries(override)) out[key] = deepMerge(base[key], value);
637
- return out;
638
- }
639
- async function loadSettings() {
640
- try {
641
- const raw = await fs.readFile(SETTINGS_FILE, "utf8");
642
- return deepMerge(DEFAULT_SETTINGS, JSON.parse(raw));
643
- } catch {
644
- return {
645
- ...DEFAULT_SETTINGS,
646
- codex: { ...DEFAULT_SETTINGS.codex }
647
- };
648
- }
649
- }
650
- async function ensureSettingsFile() {
651
- const settings = await loadSettings();
652
- try {
653
- await fs.access(SETTINGS_FILE);
654
- } catch {
655
- await fs.mkdir(SETTINGS_DIR, { recursive: true });
656
- await fs.writeFile(SETTINGS_FILE, `${JSON.stringify(settings, null, 2)}\n`);
657
- }
658
- return settings;
659
- }
660
-
661
735
  //#endregion
662
736
  //#region src/lib/rate-limit.ts
663
737
  const sleep = (ms) => new Promise((resolve) => {
@@ -702,37 +776,6 @@ var RateLimitError = class extends Error {
702
776
  }
703
777
  };
704
778
 
705
- //#endregion
706
- //#region src/lib/claude-settings.ts
707
- const getClaudeSettingsPaths = () => {
708
- const cwd = process.cwd();
709
- const home = process.env.HOME ?? os.homedir();
710
- return [
711
- path.join(home, ".claude", "settings.json"),
712
- path.join(cwd, ".claude", "settings.json"),
713
- path.join(cwd, ".claude", "settings.local.json")
714
- ];
715
- };
716
- const readClaudeSettingsFile = async (filePath) => {
717
- try {
718
- const content = await fs.readFile(filePath, "utf8");
719
- return JSON.parse(content);
720
- } catch (error) {
721
- if (error.code === "ENOENT") return;
722
- consola.warn(`Failed to read Claude settings from ${filePath}:`, error);
723
- return;
724
- }
725
- };
726
- const getClaudeSettingsEnv = async () => {
727
- const merged = {};
728
- for (const filePath of getClaudeSettingsPaths()) {
729
- const settings = await readClaudeSettingsFile(filePath);
730
- if (!settings?.env) continue;
731
- for (const [key, value] of Object.entries(settings.env)) if (typeof value === "string") merged[key] = value;
732
- }
733
- return merged;
734
- };
735
-
736
779
  //#endregion
737
780
  //#region src/services/copilot/responses.ts
738
781
  const RESPONSES_ONLY_MODEL_PATTERN = /^(?:gpt-5\.3-codex|gpt-5\.4-mini)(?:-|$)/i;
@@ -961,7 +1004,7 @@ function getFinishReason(response, hasFunctionCalls) {
961
1004
  //#endregion
962
1005
  //#region src/services/copilot/create-chat-completions.ts
963
1006
  const usesMaxCompletionTokens = (modelId) => modelId.startsWith("gpt-5");
964
- const isClaudeOpus47Model$1 = (modelId) => modelId === "claude-opus-4.7";
1007
+ const isClaudeOpus47Model$1 = (modelId) => modelId.startsWith("claude-opus-4.7");
965
1008
  const MAX_USER_LENGTH = 64;
966
1009
  const defaultReasoningEffort = (modelId) => usesMaxCompletionTokens(modelId) ? "medium" : void 0;
967
1010
  const getAllowedReasoningEfforts = (modelId) => {
@@ -1272,7 +1315,7 @@ function isClaudeModel(modelId) {
1272
1315
  return modelId.startsWith("claude-");
1273
1316
  }
1274
1317
  function isClaudeOpus47Model(modelId) {
1275
- return modelId === "claude-opus-4.7";
1318
+ return modelId.startsWith("claude-opus-4.7");
1276
1319
  }
1277
1320
  function normalizeClaudeEffort(value) {
1278
1321
  switch (value?.toLowerCase()) {
@@ -2428,11 +2471,11 @@ const start = defineCommand({
2428
2471
  args: {
2429
2472
  host: {
2430
2473
  type: "string",
2431
- description: "Host to bind (overrides settings.json)."
2474
+ description: `Host to bind (default: ${DEFAULT_HOST}).`
2432
2475
  },
2433
2476
  port: {
2434
2477
  type: "string",
2435
- description: "Port to listen on (overrides settings.json)."
2478
+ description: `Port to listen on (default: ${DEFAULT_PORT}).`
2436
2479
  },
2437
2480
  "show-token": {
2438
2481
  type: "boolean",
@@ -2444,15 +2487,20 @@ const start = defineCommand({
2444
2487
  default: false,
2445
2488
  description: "Skip writing the bridge provider into ~/.codex/config.toml."
2446
2489
  },
2490
+ "no-claude-setup": {
2491
+ type: "boolean",
2492
+ default: false,
2493
+ description: "Skip writing ANTHROPIC_BASE_URL into ~/.claude/settings.json."
2494
+ },
2447
2495
  "select-model": {
2448
2496
  type: "boolean",
2449
2497
  default: false,
2450
- description: "Force the model picker even when codex.model is set."
2498
+ description: "Force the model picker even when ~/.codex/config.toml already has a model."
2451
2499
  },
2452
2500
  "no-prompt": {
2453
2501
  type: "boolean",
2454
2502
  default: false,
2455
- description: "Never prompt; use codex.model from settings.json as-is."
2503
+ description: "Never prompt; use the existing model from ~/.codex/config.toml as-is."
2456
2504
  },
2457
2505
  "rate-limit": {
2458
2506
  type: "string",
@@ -2465,10 +2513,13 @@ const start = defineCommand({
2465
2513
  }
2466
2514
  },
2467
2515
  async run({ args }) {
2468
- const settings = await ensureSettingsFile();
2516
+ const claudeConfigPath = CLAUDE_CONFIG_PATH;
2517
+ const claudePort = parsePortFromBaseUrl(await readClaudeBaseUrl(claudeConfigPath));
2518
+ const envPortRaw = process.env.PORT;
2519
+ const envPort = envPortRaw && Number.isFinite(Number(envPortRaw)) ? Number(envPortRaw) : void 0;
2469
2520
  const config = readBridgeConfig({
2470
- host: args.host ? String(args.host) : settings.host,
2471
- port: args.port ? Number(args.port) : settings.port
2521
+ host: args.host ? String(args.host) : DEFAULT_HOST,
2522
+ port: args.port ? Number(args.port) : envPort !== void 0 ? envPort : claudePort !== void 0 ? claudePort : DEFAULT_PORT
2472
2523
  });
2473
2524
  const rateLimitRaw = args["rate-limit"];
2474
2525
  if (rateLimitRaw !== void 0) {
@@ -2485,48 +2536,75 @@ const start = defineCommand({
2485
2536
  const server = startServer(config);
2486
2537
  consola.success(`copilot-bridge listening on http://${config.host}:${config.port}`);
2487
2538
  consola.info(`copilot base url: ${config.copilotBaseUrl}`);
2539
+ const baseUrl = `http://${config.host}:${config.port}`;
2540
+ const codexConfigPath = CODEX_DEFAULTS.configPath;
2541
+ if (!args["no-claude-setup"]) try {
2542
+ const claudeResult = await applyClaudeConfig({
2543
+ baseUrl,
2544
+ configPath: claudeConfigPath
2545
+ });
2546
+ if (claudeResult.changed) {
2547
+ consola.success(`claude settings ${claudeResult.created ? "created" : "updated"}: ${claudeResult.configPath}`);
2548
+ if (claudeResult.previousBaseUrl && claudeResult.previousBaseUrl !== baseUrl) consola.info(`ANTHROPIC_BASE_URL: ${claudeResult.previousBaseUrl} → ${baseUrl}`);
2549
+ } else consola.info(`claude settings already up to date: ${claudeResult.configPath}`);
2550
+ } catch (error) {
2551
+ consola.warn(`Could not update claude settings (${claudeConfigPath}):`, error);
2552
+ }
2553
+ const codexUserConfig = await readCodexUserConfigFromDisk(codexConfigPath);
2554
+ let chosenModel = codexUserConfig.model;
2555
+ let chosenEffort = codexUserConfig.modelReasoningEffort;
2556
+ if (!args["no-codex-setup"]) try {
2557
+ const result = await applyCodexConfig({
2558
+ baseUrl: `${baseUrl}/v1`,
2559
+ configPath: codexConfigPath,
2560
+ settings: CODEX_DEFAULTS,
2561
+ model: chosenModel,
2562
+ modelReasoningEffort: chosenEffort
2563
+ });
2564
+ if (result.changed) {
2565
+ consola.success(`codex config ${result.created ? "created" : "updated"}: ${result.configPath}`);
2566
+ if (CODEX_DEFAULTS.setAsDefault) consola.info(`codex now defaults to provider "${CODEX_DEFAULTS.providerId}". Run \`codex exec "..."\` directly.`);
2567
+ else consola.info(`provider "${CODEX_DEFAULTS.providerId}" registered. Use \`codex -c model_provider="${CODEX_DEFAULTS.providerId}" ...\``);
2568
+ } else consola.info(`codex config already up to date: ${result.configPath}`);
2569
+ } catch (error) {
2570
+ consola.warn(`Could not update codex config (${codexConfigPath}):`, error);
2571
+ }
2488
2572
  const models = await fetchAvailableModels(config);
2489
2573
  const supportedIds = new Set(MODEL_CAPABILITIES.map((m) => m.id));
2490
2574
  const pickable = models.length > 0 ? models.filter((id) => supportedIds.has(id)) : MODEL_CAPABILITIES.map((m) => m.id);
2491
2575
  const finalPickable = pickable.length > 0 ? pickable : MODEL_CAPABILITIES.map((m) => m.id);
2492
2576
  if (models.length > 0) consola.info(`Available models:\n${models.map((id) => `- ${id}${supportedIds.has(id) ? " (bridge-supported)" : ""}`).join("\n")}`);
2493
2577
  else consola.warn("Could not fetch model list from upstream Copilot API");
2494
- const codexUserConfig = await readCodexUserConfigFromDisk(settings.codex.configPath);
2495
- let chosenModel = codexUserConfig.model;
2496
- let chosenEffort = codexUserConfig.modelReasoningEffort;
2578
+ consola.box([
2579
+ `🌐 Usage viewer`,
2580
+ ``,
2581
+ ` https://betahi.github.io/copilot-bridge?endpoint=${baseUrl}/usage`
2582
+ ].join("\n"));
2497
2583
  if (!args["no-prompt"] && finalPickable.length > 0 && (args["select-model"] || !chosenModel)) {
2498
2584
  const defaultId = chosenModel && finalPickable.includes(chosenModel) ? chosenModel : finalPickable.includes("gpt-5.3-codex") ? "gpt-5.3-codex" : finalPickable[0];
2499
- const selected = await consola.prompt(`Select a model for codex (writes to ${settings.codex.configPath})`, {
2585
+ const selected = await consola.prompt(`Select a model for codex (writes to ${codexConfigPath})`, {
2500
2586
  type: "select",
2501
2587
  options: finalPickable,
2502
2588
  initial: defaultId
2503
2589
  });
2504
- if (selected) {
2590
+ if (selected && selected !== chosenModel) {
2505
2591
  chosenModel = selected;
2506
2592
  const supported = getModelCapability(selected)?.reasoning?.supported;
2507
2593
  if (chosenEffort && supported && !supported.includes(chosenEffort)) chosenEffort = void 0;
2594
+ if (!args["no-codex-setup"]) try {
2595
+ const result = await applyCodexConfig({
2596
+ baseUrl: `${baseUrl}/v1`,
2597
+ configPath: codexConfigPath,
2598
+ settings: CODEX_DEFAULTS,
2599
+ model: chosenModel,
2600
+ modelReasoningEffort: chosenEffort
2601
+ });
2602
+ if (result.changed) consola.success(`codex config updated: ${result.configPath}`);
2603
+ } catch (error) {
2604
+ consola.warn(`Could not update codex config (${codexConfigPath}):`, error);
2605
+ }
2508
2606
  }
2509
2607
  }
2510
- const baseUrl = `http://${config.host}:${config.port}`;
2511
- consola.box([
2512
- `🌐 Usage viewer`,
2513
- ``,
2514
- ` https://betahi.github.io/copilot-bridge?endpoint=${baseUrl}/usage`
2515
- ].join("\n"));
2516
- if (settings.codex.enabled && !args["no-codex-setup"]) {
2517
- const result = await applyCodexConfig({
2518
- baseUrl: `${baseUrl}/v1`,
2519
- configPath: settings.codex.configPath,
2520
- settings: settings.codex,
2521
- model: chosenModel,
2522
- modelReasoningEffort: chosenEffort
2523
- });
2524
- if (result.changed) {
2525
- consola.success(`codex config ${result.created ? "created" : "updated"}: ${result.configPath}`);
2526
- if (settings.codex.setAsDefault) consola.info(`codex now defaults to provider "${settings.codex.providerId}". Run \`codex exec "..."\` directly.`);
2527
- else consola.info(`provider "${settings.codex.providerId}" registered. Use \`codex -c model_provider="${settings.codex.providerId}" ...\``);
2528
- } else consola.info(`codex config already up to date: ${result.configPath}`);
2529
- }
2530
2608
  await new Promise((resolve, reject) => {
2531
2609
  server.on("close", resolve);
2532
2610
  server.on("error", reject);