betahi-copilot-bridge 0.20.8 → 0.20.10

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
@@ -1,7 +1,7 @@
1
1
  <h1 align="center">copilot-bridge</h1>
2
2
 
3
3
  <p align="center">
4
- <a href="https://www.npmjs.com/package/betahi-copilot-bridge"><img src="https://img.shields.io/npm/v/betahi-copilot-bridge.svg?v=0.20.8" alt="npm version"></a>
4
+ <a href="https://www.npmjs.com/package/betahi-copilot-bridge"><img src="https://img.shields.io/npm/v/betahi-copilot-bridge.svg?v=0.20.10" alt="npm version"></a>
5
5
  <a href="https://github.com/betahi/copilot-bridge/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/betahi-copilot-bridge.svg" alt="license"></a>
6
6
  </p>
7
7
 
@@ -13,6 +13,7 @@
13
13
 
14
14
  ## Contents
15
15
  - [Demo](#demo)
16
+ - [Why this bridge?](#why-this-bridge)
16
17
  - [Install & run](#install--run)
17
18
  - [Configure Codex CLI](#configure-codex-cli)
18
19
  - [Configure Claude Code](#configure-claude-code)
@@ -34,6 +35,19 @@
34
35
 
35
36
  ![Claude demo](assets/screenshots/claude_demo.png)
36
37
 
38
+ ## Why this bridge?
39
+
40
+ copilot-bridge is more than a raw HTTP proxy:
41
+
42
+ 1. **Codex and Claude support.** Exposes Codex `/v1/responses`, Claude Code
43
+ `/v1/messages`, and OpenAI-compatible chat, embeddings, and models routes.
44
+ 2. **Web search support.** Model-selected web search can be executed by the
45
+ bridge and fed back into a final model pass.
46
+ 3. **End-to-end reasoning support.** Normalizes model aliases and
47
+ `reasoning_effort`, routes through the right upstream API, and preserves
48
+ returned `reasoning_text` / `reasoning_content` in streaming and
49
+ non-streaming responses.
50
+
37
51
  ## Install & run
38
52
 
39
53
  ```sh
@@ -83,6 +97,12 @@ requires_openai_auth = false
83
97
  Use `--no-codex-setup` to skip this writer if you manage
84
98
  `~/.codex/config.toml` yourself.
85
99
 
100
+ The managed config also keeps Codex CLI's `/status` context-window display in
101
+ sync with Copilot model metadata. For example, GPT-5.5 shows a 1.05M context
102
+ window through the bridge:
103
+
104
+ ![Codex context window](assets/screenshots/codex_2.png)
105
+
86
106
  ### Codex warning: "Model metadata ... not found"
87
107
 
88
108
  This is a Codex client-side metadata warning, not a bridge routing failure, requests can still complete through the bridge.
@@ -169,7 +189,7 @@ COPILOT_WEB_SEARCH_BACKEND = "gpt-5.5"
169
189
 
170
190
  | Value | Search path | Requirement |
171
191
  | ----- | ----------- | ----------- |
172
- | Copilot model id, for example `gpt-5.5` | Copilot HTTP `/responses` + `web_search_preview` | The model must support Copilot Responses web search. |
192
+ | Copilot model id, for example `gpt-5.5` | Copilot HTTP `/responses` + `web_search_preview` | Recommended: `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.3-codex`, `gpt-5.2`, `gpt-5.2-codex`. |
173
193
  | `searxng`, use `"COPILOT_WEB_SEARCH_BACKEND": "searxng"` | Local SearXNG at `http://localhost:8080` | Start SearXNG yourself. Setup guide: https://github.com/betaHi/openclaw-searxng-search. |
174
194
  | `copilot-cli` or `copilot`, use `"COPILOT_WEB_SEARCH_BACKEND": "copilot-cli"` | GitHub Copilot CLI `web_search` tool, using the current request model | Install and sign in to GitHub Copilot CLI yourself. |
175
195
 
@@ -254,11 +274,11 @@ accepts upstream.
254
274
  | Model | Reasoning efforts | Notes |
255
275
  | -------------------------------- | --------------------------------------- | -------------------------------------- |
256
276
  | `claude-opus-4.7` | `medium` | Effort sent as `output_config.effort`. |
257
- | `claude-opus-4.7-1m` | `low`, `medium`, `high`, `xhigh` | 1M-token context window; effort sent as `output_config.effort`. |
258
- | `claude-opus-4.7-high` | `high` | Fixed high reasoning; effort sent as `output_config.effort`. |
259
- | `claude-opus-4.7-xhigh` | `xhigh` | Fixed extra-high reasoning; effort sent as `output_config.effort`. |
277
+ | `claude-opus-4.7-1m` | `low`, `medium`, `high`, `xhigh` | 1M-token context window, prefer use `claude-opus-4.7-[1m]` in config|
278
+ | `claude-opus-4.7-high` | `high` | Fixed high reasoning|
279
+ | `claude-opus-4.7-xhigh` | `xhigh` | Fixed extra-high reasoning|
260
280
  | `claude-opus-4.6` | `low`, `medium`, `high` | |
261
- | `claude-opus-4.6-1m` | `low`, `medium`, `high` | 1M-token context window. |
281
+ | `claude-opus-4.6-1m` | `low`, `medium`, `high` | 1M-token context window, prefer use `claude-opus-4.6-[1m]` in config |
262
282
  | `claude-sonnet-4.6` | `low`, `medium`, `high` | |
263
283
  | `claude-opus-4.5` | — | Reasoning not accepted upstream. |
264
284
  | `claude-sonnet-4.5` | — | Reasoning not accepted upstream. |
package/dist/main.js CHANGED
@@ -3,9 +3,11 @@ import { defineCommand, runMain } from "citty";
3
3
  import consola from "consola";
4
4
  import fs, { appendFile } from "node:fs/promises";
5
5
  import os from "node:os";
6
- import path from "node:path";
6
+ import path, { dirname, resolve } from "node:path";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import { z } from "zod";
9
+ import { accessSync, constants, readFileSync } from "node:fs";
10
+ import { fileURLToPath } from "node:url";
9
11
  import { serve } from "@hono/node-server";
10
12
  import { Hono } from "hono";
11
13
  import { cors } from "hono/cors";
@@ -13,7 +15,6 @@ import { logger } from "hono/logger";
13
15
  import { streamSSE } from "hono/streaming";
14
16
  import { events } from "fetch-event-stream";
15
17
  import { execFile } from "node:child_process";
16
- import { accessSync, constants } from "node:fs";
17
18
 
18
19
  //#region src/lib/error.ts
19
20
  var BridgeNotImplementedError = class extends Error {
@@ -148,8 +149,8 @@ const standardHeaders = () => ({
148
149
  accept: "application/json",
149
150
  "content-type": "application/json"
150
151
  });
151
- const sleep$1 = (ms) => new Promise((resolve) => {
152
- setTimeout(resolve, ms);
152
+ const sleep$1 = (ms) => new Promise((resolve$1) => {
153
+ setTimeout(resolve$1, ms);
153
154
  });
154
155
  const readGitHubToken$1 = async () => {
155
156
  return (await fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8")).trim();
@@ -208,7 +209,7 @@ const getCopilotToken = async (githubToken, vsCodeVersion) => {
208
209
  return await response.json();
209
210
  };
210
211
  const isCopilotTokenError = (error) => error instanceof HTTPError && error.message === "Failed to get Copilot token";
211
- const ensureGitHubToken = async (config, options = {}) => {
212
+ const ensureGitHubToken = async (options = {}) => {
212
213
  await ensurePaths();
213
214
  const existingToken = options.force ? "" : await readGitHubToken$1();
214
215
  if (existingToken) return existingToken;
@@ -217,17 +218,23 @@ const ensureGitHubToken = async (config, options = {}) => {
217
218
  const githubToken = await pollAccessToken(deviceCode);
218
219
  await writeGitHubToken(githubToken);
219
220
  if (options.showToken) consola.info("GitHub token:", githubToken);
220
- const user = await getGitHubUser(githubToken, config.vsCodeVersion);
221
- consola.success(`Logged in as ${user.login}`);
222
221
  return githubToken;
223
222
  };
223
+ const loadGitHubLogin = async (githubToken, vsCodeVersion) => {
224
+ try {
225
+ return (await getGitHubUser(githubToken, vsCodeVersion)).login;
226
+ } catch (error) {
227
+ consola.warn("Could not load GitHub user:", error);
228
+ return;
229
+ }
230
+ };
224
231
  const setupBridgeAuth = async (config, options = {}) => {
225
232
  if (config.copilotToken) {
226
233
  if (options.showToken) consola.info("Using COPILOT_TOKEN from environment");
227
234
  await loadModels(config);
228
- return;
235
+ return { source: "copilot-token" };
229
236
  }
230
- let githubToken = await ensureGitHubToken(config, options);
237
+ let githubToken = await ensureGitHubToken(options);
231
238
  const applyCopilotToken = async () => {
232
239
  const { refresh_in, token } = await getCopilotToken(githubToken, config.vsCodeVersion);
233
240
  config.copilotToken = token;
@@ -240,7 +247,7 @@ const setupBridgeAuth = async (config, options = {}) => {
240
247
  } catch (error) {
241
248
  if (options.force || !isCopilotTokenError(error)) throw error;
242
249
  consola.warn("Cached GitHub auth could not get a Copilot token; running device auth again.");
243
- githubToken = await ensureGitHubToken(config, {
250
+ githubToken = await ensureGitHubToken({
244
251
  ...options,
245
252
  force: true
246
253
  });
@@ -256,6 +263,10 @@ const setupBridgeAuth = async (config, options = {}) => {
256
263
  }
257
264
  }, refreshInterval);
258
265
  await loadModels(config);
266
+ return {
267
+ githubLogin: await loadGitHubLogin(githubToken, config.vsCodeVersion),
268
+ source: "github-token"
269
+ };
259
270
  };
260
271
  const loadModels = async (config) => {
261
272
  try {
@@ -321,10 +332,11 @@ const auth = defineCommand({
321
332
  port: Number(args.port)
322
333
  });
323
334
  config.copilotToken = void 0;
324
- await setupBridgeAuth(config, {
335
+ const authSession = await setupBridgeAuth(config, {
325
336
  force: true,
326
337
  showToken: args["show-token"]
327
338
  });
339
+ if (authSession.githubLogin) consola.info(`Logged in as ${authSession.githubLogin}`);
328
340
  consola.success("copilot-bridge auth completed");
329
341
  }
330
342
  });
@@ -458,6 +470,12 @@ const BEGIN_MARK = "# >>> copilot-bridge managed block — auto-generated, do no
458
470
  const END_MARK = "# <<< copilot-bridge managed block — edits outside this block are preserved <<<";
459
471
  const LEGACY_BEGIN_MARK = "# >>> copilot-bridge managed (do not edit) >>>";
460
472
  const LEGACY_END_MARK = "# <<< copilot-bridge managed (do not edit) <<<";
473
+ const MANAGED_MARKERS = new Set([
474
+ BEGIN_MARK,
475
+ END_MARK,
476
+ LEGACY_BEGIN_MARK,
477
+ LEGACY_END_MARK
478
+ ]);
461
479
  function normalizeCodexConfigReasoningEffort(value) {
462
480
  switch (value?.toLowerCase()) {
463
481
  case "none":
@@ -475,9 +493,11 @@ function tomlEscape(value) {
475
493
  }
476
494
  function buildManagedBlock(input) {
477
495
  const { baseUrl, settings } = input;
496
+ const modelContextWindow = normalizeModelContextWindow(input.modelContextWindow);
478
497
  const lines = [];
479
498
  lines.push(BEGIN_MARK);
480
499
  if (settings.setAsDefault) lines.push(`model_provider = "${tomlEscape(settings.providerId)}"`);
500
+ if (modelContextWindow !== void 0) lines.push(`model_context_window = ${modelContextWindow}`);
481
501
  lines.push("model_supports_reasoning_summaries = true");
482
502
  lines.push("");
483
503
  lines.push(`[model_providers.${settings.providerId}]`);
@@ -489,6 +509,15 @@ function buildManagedBlock(input) {
489
509
  lines.push(END_MARK);
490
510
  return lines.join("\n");
491
511
  }
512
+ function extractPreservedContentFromManagedBlock(content) {
513
+ const lines = content.split("\n");
514
+ const preserveStart = lines.findIndex((line) => {
515
+ const trimmed = line.trim();
516
+ return trimmed.startsWith("[") && !trimmed.startsWith("[model_providers.");
517
+ });
518
+ if (preserveStart === -1) return "";
519
+ return lines.slice(preserveStart).join("\n").replace(/^\n+|\n+$/g, "");
520
+ }
492
521
  function stripManagedBlock(content) {
493
522
  let next = content;
494
523
  for (const [begin, end] of [[BEGIN_MARK, END_MARK], [LEGACY_BEGIN_MARK, LEGACY_END_MARK]]) while (true) {
@@ -496,14 +525,33 @@ function stripManagedBlock(content) {
496
525
  if (beginIdx === -1) break;
497
526
  const endIdx = next.indexOf(end, beginIdx);
498
527
  if (endIdx === -1) break;
528
+ const preserved = extractPreservedContentFromManagedBlock(next.slice(beginIdx + begin.length, endIdx));
499
529
  const before = next.slice(0, beginIdx).replace(/\n*$/, "");
500
- const after = next.slice(endIdx + end.length).replace(/^\n+/, "");
530
+ const after = [preserved, next.slice(endIdx + end.length)].filter(Boolean).join("\n").replace(/^\n+/, "");
501
531
  if (before.length === 0) next = after;
502
532
  else if (after.length === 0) next = `${before}\n`;
503
533
  else next = `${before}\n\n${after}`;
504
534
  }
505
535
  return next;
506
536
  }
537
+ function removeStrayManagedMarkerLines(content) {
538
+ return content.split("\n").filter((line) => !MANAGED_MARKERS.has(line.trim())).join("\n");
539
+ }
540
+ function removeManagedProviderTables(content, providerId) {
541
+ const lines = content.split("\n");
542
+ const out = [];
543
+ const tableHeader = `[model_providers.${providerId}]`;
544
+ for (let i = 0; i < lines.length;) {
545
+ if (lines[i].trim() === tableHeader) {
546
+ i += 1;
547
+ while (i < lines.length && !/^\s*\[/.test(lines[i])) i += 1;
548
+ continue;
549
+ }
550
+ out.push(lines[i]);
551
+ i += 1;
552
+ }
553
+ return out.join("\n").replace(/\n{3,}/g, "\n\n");
554
+ }
507
555
  function splitTopSection(content) {
508
556
  const lines = content.split("\n");
509
557
  let cut = lines.length;
@@ -541,6 +589,22 @@ function removeTopKey(topSection, key) {
541
589
  const re = /* @__PURE__ */ new RegExp(`^\\s*${key}\\s*=`);
542
590
  return lines.filter((line) => !re.test(line)).join("\n");
543
591
  }
592
+ function normalizeModelContextWindow(value) {
593
+ if (value === void 0 || !Number.isFinite(value)) return;
594
+ const normalized = Math.trunc(value);
595
+ return normalized > 0 ? normalized : void 0;
596
+ }
597
+ function removeManagedTopLevelKeys(content, input) {
598
+ const { top, rest } = splitTopSection(content);
599
+ let nextTop = top;
600
+ if (input.settings.setAsDefault) nextTop = removeTopKey(nextTop, "model_provider");
601
+ if (normalizeModelContextWindow(input.modelContextWindow) !== void 0) nextTop = removeTopKey(nextTop, "model_context_window");
602
+ nextTop = removeTopKey(nextTop, "model_supports_reasoning_summaries");
603
+ if (nextTop === top) return content;
604
+ if (rest.length === 0) return nextTop.endsWith("\n") ? nextTop : `${nextTop}\n`;
605
+ const sep = nextTop.endsWith("\n") ? "" : "\n";
606
+ return `${nextTop}${sep}${rest}`;
607
+ }
544
608
  function sanitizeTopReasoningEffort(topSection) {
545
609
  const match = topSection.match(scalarRegex("model_reasoning_effort"));
546
610
  if (!match) return topSection;
@@ -570,10 +634,17 @@ async function applyCodexConfig(input) {
570
634
  created = true;
571
635
  }
572
636
  let stripped = stripManagedBlock(existing);
637
+ stripped = removeStrayManagedMarkerLines(stripped);
638
+ stripped = removeManagedProviderTables(stripped, input.settings.providerId);
573
639
  stripped = applyUserScalars(stripped, input);
640
+ stripped = removeManagedTopLevelKeys(stripped, input);
574
641
  const block = buildManagedBlock(input);
575
- const trimmed = stripped.replace(/\n+$/, "");
576
- const next = trimmed.length === 0 ? `${block}\n` : `${trimmed}\n\n${block}\n`;
642
+ const { top, rest } = splitTopSection(stripped);
643
+ const next = `${[
644
+ top,
645
+ block,
646
+ rest
647
+ ].map((part) => part.replace(/^\n+|\n+$/g, "")).filter(Boolean).join("\n\n")}\n`;
577
648
  if (next === existing) return {
578
649
  configPath,
579
650
  changed: false,
@@ -858,10 +929,28 @@ const clampReasoningEffort = (modelId, requested) => {
858
929
  };
859
930
  };
860
931
 
932
+ //#endregion
933
+ //#region src/lib/version.ts
934
+ const PACKAGE_NAME = "betahi-copilot-bridge";
935
+ const readPackageVersion = () => {
936
+ let currentDir = dirname(fileURLToPath(import.meta.url));
937
+ while (true) {
938
+ try {
939
+ const packageJsonPath = resolve(currentDir, "package.json");
940
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
941
+ if (packageJson.name === PACKAGE_NAME && typeof packageJson.version === "string") return packageJson.version;
942
+ } catch {}
943
+ const parentDir = dirname(currentDir);
944
+ if (parentDir === currentDir) return "unknown";
945
+ currentDir = parentDir;
946
+ }
947
+ };
948
+ const BRIDGE_VERSION = readPackageVersion();
949
+
861
950
  //#endregion
862
951
  //#region src/lib/rate-limit.ts
863
- const sleep = (ms) => new Promise((resolve) => {
864
- setTimeout(resolve, ms);
952
+ const sleep = (ms) => new Promise((resolve$1) => {
953
+ setTimeout(resolve$1, ms);
865
954
  });
866
955
  /**
867
956
  * Enforces the configured rate limit window between mutating upstream calls.
@@ -2492,7 +2581,7 @@ const findCopilotCliCommand = () => {
2492
2581
  const candidates = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).flatMap((directory) => copilotExecutableNames().map((name) => path.join(directory, name))).filter(canExecute);
2493
2582
  return candidates.find((candidate) => !COPILOT_CHAT_WRAPPER_PATH_PATTERN.test(candidate)) ?? candidates[0] ?? "copilot";
2494
2583
  };
2495
- const runCopilotCliCommand = (args, timeout) => new Promise((resolve, reject) => {
2584
+ const runCopilotCliCommand = (args, timeout) => new Promise((resolve$1, reject) => {
2496
2585
  execFile(findCopilotCliCommand(), args, {
2497
2586
  encoding: "utf8",
2498
2587
  timeout
@@ -2505,7 +2594,7 @@ const runCopilotCliCommand = (args, timeout) => new Promise((resolve, reject) =>
2505
2594
  });
2506
2595
  return;
2507
2596
  }
2508
- resolve({
2597
+ resolve$1({
2509
2598
  stdout,
2510
2599
  stderr
2511
2600
  });
@@ -4067,6 +4156,12 @@ const fetchAvailableModels = async (config) => {
4067
4156
  return [];
4068
4157
  }
4069
4158
  };
4159
+ const getCodexModelContextWindow = (model) => {
4160
+ if (!model) return void 0;
4161
+ const resolvedModel = resolveModelId(model);
4162
+ const contextWindow = (runtimeState.models?.data.find((candidate) => candidate.id === resolvedModel || candidate.id === model || getPublicModelId(candidate.id) === model || getPublicModelId(candidate.id) === resolvedModel))?.capabilities?.limits?.max_context_window_tokens;
4163
+ return typeof contextWindow === "number" && contextWindow > 0 ? Math.trunc(contextWindow) : void 0;
4164
+ };
4070
4165
  const start = defineCommand({
4071
4166
  meta: {
4072
4167
  name: "start",
@@ -4142,8 +4237,12 @@ const start = defineCommand({
4142
4237
  runtimeState.rateLimitWait = Boolean(args.wait);
4143
4238
  consola.info(`Rate limit: ${parsed}s between requests (${runtimeState.rateLimitWait ? "wait" : "reject"} on overflow)`);
4144
4239
  }
4145
- await setupBridgeAuth(config, { showToken: args["show-token"] });
4240
+ const authSession = await setupBridgeAuth(config, { showToken: args["show-token"] });
4146
4241
  const server = startServer(config);
4242
+ consola.info(`copilot-bridge version: ${BRIDGE_VERSION}`);
4243
+ if (authSession.githubLogin) consola.info(`GitHub user: ${authSession.githubLogin}`);
4244
+ else if (authSession.source === "copilot-token") consola.info("GitHub user: unavailable (using COPILOT_TOKEN)");
4245
+ else consola.warn("GitHub user: unavailable");
4147
4246
  consola.success(`copilot-bridge listening on http://${config.host}:${config.port}`);
4148
4247
  consola.info(`copilot base url: ${config.copilotBaseUrl}`);
4149
4248
  const baseUrl = `http://${config.host}:${config.port}`;
@@ -4180,7 +4279,8 @@ const start = defineCommand({
4180
4279
  configPath: codexConfigPath,
4181
4280
  settings: CODEX_DEFAULTS,
4182
4281
  model: chosenModel,
4183
- modelReasoningEffort: chosenEffort
4282
+ modelReasoningEffort: chosenEffort,
4283
+ modelContextWindow: getCodexModelContextWindow(chosenModel)
4184
4284
  });
4185
4285
  if (result.changed) {
4186
4286
  consola.success(`codex config ${result.created ? "created" : "updated"}: ${result.configPath}`);
@@ -4221,7 +4321,8 @@ const start = defineCommand({
4221
4321
  configPath: codexConfigPath,
4222
4322
  settings: CODEX_DEFAULTS,
4223
4323
  model: chosenModel,
4224
- modelReasoningEffort: chosenEffort
4324
+ modelReasoningEffort: chosenEffort,
4325
+ modelContextWindow: getCodexModelContextWindow(chosenModel)
4225
4326
  });
4226
4327
  if (result.changed) consola.success(`codex config updated: ${result.configPath}`);
4227
4328
  } catch (error) {
@@ -4229,8 +4330,8 @@ const start = defineCommand({
4229
4330
  }
4230
4331
  }
4231
4332
  }
4232
- await new Promise((resolve, reject) => {
4233
- server.on("close", resolve);
4333
+ await new Promise((resolve$1, reject) => {
4334
+ server.on("close", resolve$1);
4234
4335
  server.on("error", reject);
4235
4336
  });
4236
4337
  }