betahi-copilot-bridge 0.20.7 → 0.20.9

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.7" 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.8" 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
@@ -169,7 +183,7 @@ COPILOT_WEB_SEARCH_BACKEND = "gpt-5.5"
169
183
 
170
184
  | Value | Search path | Requirement |
171
185
  | ----- | ----------- | ----------- |
172
- | Copilot model id, for example `gpt-5.5` | Copilot HTTP `/responses` + `web_search_preview` | The model must support Copilot Responses web search. |
186
+ | 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
187
  | `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
188
  | `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
189
 
@@ -254,11 +268,11 @@ accepts upstream.
254
268
  | Model | Reasoning efforts | Notes |
255
269
  | -------------------------------- | --------------------------------------- | -------------------------------------- |
256
270
  | `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`. |
271
+ | `claude-opus-4.7-1m` | `low`, `medium`, `high`, `xhigh` | 1M-token context window, prefer use `claude-opus-4.7-[1m]` in config|
272
+ | `claude-opus-4.7-high` | `high` | Fixed high reasoning|
273
+ | `claude-opus-4.7-xhigh` | `xhigh` | Fixed extra-high reasoning|
260
274
  | `claude-opus-4.6` | `low`, `medium`, `high` | |
261
- | `claude-opus-4.6-1m` | `low`, `medium`, `high` | 1M-token context window. |
275
+ | `claude-opus-4.6-1m` | `low`, `medium`, `high` | 1M-token context window, prefer use `claude-opus-4.6-[1m]` in config |
262
276
  | `claude-sonnet-4.6` | `low`, `medium`, `high` | |
263
277
  | `claude-opus-4.5` | — | Reasoning not accepted upstream. |
264
278
  | `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
  });
@@ -475,9 +487,11 @@ function tomlEscape(value) {
475
487
  }
476
488
  function buildManagedBlock(input) {
477
489
  const { baseUrl, settings } = input;
490
+ const modelContextWindow = normalizeModelContextWindow(input.modelContextWindow);
478
491
  const lines = [];
479
492
  lines.push(BEGIN_MARK);
480
493
  if (settings.setAsDefault) lines.push(`model_provider = "${tomlEscape(settings.providerId)}"`);
494
+ if (modelContextWindow !== void 0) lines.push(`model_context_window = ${modelContextWindow}`);
481
495
  lines.push("model_supports_reasoning_summaries = true");
482
496
  lines.push("");
483
497
  lines.push(`[model_providers.${settings.providerId}]`);
@@ -489,6 +503,15 @@ function buildManagedBlock(input) {
489
503
  lines.push(END_MARK);
490
504
  return lines.join("\n");
491
505
  }
506
+ function extractPreservedContentFromManagedBlock(content) {
507
+ const lines = content.split("\n");
508
+ const preserveStart = lines.findIndex((line) => {
509
+ const trimmed = line.trim();
510
+ return trimmed.startsWith("[") && !trimmed.startsWith("[model_providers.");
511
+ });
512
+ if (preserveStart === -1) return "";
513
+ return lines.slice(preserveStart).join("\n").replace(/^\n+|\n+$/g, "");
514
+ }
492
515
  function stripManagedBlock(content) {
493
516
  let next = content;
494
517
  for (const [begin, end] of [[BEGIN_MARK, END_MARK], [LEGACY_BEGIN_MARK, LEGACY_END_MARK]]) while (true) {
@@ -496,8 +519,9 @@ function stripManagedBlock(content) {
496
519
  if (beginIdx === -1) break;
497
520
  const endIdx = next.indexOf(end, beginIdx);
498
521
  if (endIdx === -1) break;
522
+ const preserved = extractPreservedContentFromManagedBlock(next.slice(beginIdx + begin.length, endIdx));
499
523
  const before = next.slice(0, beginIdx).replace(/\n*$/, "");
500
- const after = next.slice(endIdx + end.length).replace(/^\n+/, "");
524
+ const after = [preserved, next.slice(endIdx + end.length)].filter(Boolean).join("\n").replace(/^\n+/, "");
501
525
  if (before.length === 0) next = after;
502
526
  else if (after.length === 0) next = `${before}\n`;
503
527
  else next = `${before}\n\n${after}`;
@@ -541,6 +565,19 @@ function removeTopKey(topSection, key) {
541
565
  const re = /* @__PURE__ */ new RegExp(`^\\s*${key}\\s*=`);
542
566
  return lines.filter((line) => !re.test(line)).join("\n");
543
567
  }
568
+ function normalizeModelContextWindow(value) {
569
+ if (value === void 0 || !Number.isFinite(value)) return;
570
+ const normalized = Math.trunc(value);
571
+ return normalized > 0 ? normalized : void 0;
572
+ }
573
+ function removeTopLevelContextWindowWhenManaged(content, input) {
574
+ if (normalizeModelContextWindow(input.modelContextWindow) === void 0) return content;
575
+ const { top, rest } = splitTopSection(content);
576
+ const nextTop = removeTopKey(top, "model_context_window");
577
+ if (nextTop === top) return content;
578
+ if (rest.length === 0) return nextTop.endsWith("\n") ? nextTop : `${nextTop}\n`;
579
+ return `${nextTop}${nextTop.endsWith("\n") ? "" : "\n"}${rest}`;
580
+ }
544
581
  function sanitizeTopReasoningEffort(topSection) {
545
582
  const match = topSection.match(scalarRegex("model_reasoning_effort"));
546
583
  if (!match) return topSection;
@@ -571,9 +608,14 @@ async function applyCodexConfig(input) {
571
608
  }
572
609
  let stripped = stripManagedBlock(existing);
573
610
  stripped = applyUserScalars(stripped, input);
611
+ stripped = removeTopLevelContextWindowWhenManaged(stripped, input);
574
612
  const block = buildManagedBlock(input);
575
- const trimmed = stripped.replace(/\n+$/, "");
576
- const next = trimmed.length === 0 ? `${block}\n` : `${trimmed}\n\n${block}\n`;
613
+ const { top, rest } = splitTopSection(stripped);
614
+ const next = `${[
615
+ top,
616
+ block,
617
+ rest
618
+ ].map((part) => part.replace(/^\n+|\n+$/g, "")).filter(Boolean).join("\n\n")}\n`;
577
619
  if (next === existing) return {
578
620
  configPath,
579
621
  changed: false,
@@ -858,10 +900,28 @@ const clampReasoningEffort = (modelId, requested) => {
858
900
  };
859
901
  };
860
902
 
903
+ //#endregion
904
+ //#region src/lib/version.ts
905
+ const PACKAGE_NAME = "betahi-copilot-bridge";
906
+ const readPackageVersion = () => {
907
+ let currentDir = dirname(fileURLToPath(import.meta.url));
908
+ while (true) {
909
+ try {
910
+ const packageJsonPath = resolve(currentDir, "package.json");
911
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
912
+ if (packageJson.name === PACKAGE_NAME && typeof packageJson.version === "string") return packageJson.version;
913
+ } catch {}
914
+ const parentDir = dirname(currentDir);
915
+ if (parentDir === currentDir) return "unknown";
916
+ currentDir = parentDir;
917
+ }
918
+ };
919
+ const BRIDGE_VERSION = readPackageVersion();
920
+
861
921
  //#endregion
862
922
  //#region src/lib/rate-limit.ts
863
- const sleep = (ms) => new Promise((resolve) => {
864
- setTimeout(resolve, ms);
923
+ const sleep = (ms) => new Promise((resolve$1) => {
924
+ setTimeout(resolve$1, ms);
865
925
  });
866
926
  /**
867
927
  * Enforces the configured rate limit window between mutating upstream calls.
@@ -2492,7 +2552,7 @@ const findCopilotCliCommand = () => {
2492
2552
  const candidates = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).flatMap((directory) => copilotExecutableNames().map((name) => path.join(directory, name))).filter(canExecute);
2493
2553
  return candidates.find((candidate) => !COPILOT_CHAT_WRAPPER_PATH_PATTERN.test(candidate)) ?? candidates[0] ?? "copilot";
2494
2554
  };
2495
- const runCopilotCliCommand = (args, timeout) => new Promise((resolve, reject) => {
2555
+ const runCopilotCliCommand = (args, timeout) => new Promise((resolve$1, reject) => {
2496
2556
  execFile(findCopilotCliCommand(), args, {
2497
2557
  encoding: "utf8",
2498
2558
  timeout
@@ -2505,7 +2565,7 @@ const runCopilotCliCommand = (args, timeout) => new Promise((resolve, reject) =>
2505
2565
  });
2506
2566
  return;
2507
2567
  }
2508
- resolve({
2568
+ resolve$1({
2509
2569
  stdout,
2510
2570
  stderr
2511
2571
  });
@@ -2666,6 +2726,9 @@ const webSearchResponseToEvents = (response) => {
2666
2726
  content_block: block.type === "text" ? {
2667
2727
  type: "text",
2668
2728
  text: ""
2729
+ } : block.type === "server_tool_use" || block.type === "tool_use" ? {
2730
+ ...block,
2731
+ input: {}
2669
2732
  } : block
2670
2733
  });
2671
2734
  if (block.type === "text" && block.text) events$1.push({
@@ -2676,7 +2739,7 @@ const webSearchResponseToEvents = (response) => {
2676
2739
  text: block.text
2677
2740
  }
2678
2741
  });
2679
- else if (block.type === "server_tool_use") events$1.push({
2742
+ else if (block.type === "server_tool_use" || block.type === "tool_use") events$1.push({
2680
2743
  type: "content_block_delta",
2681
2744
  index,
2682
2745
  delta: {
@@ -4064,6 +4127,12 @@ const fetchAvailableModels = async (config) => {
4064
4127
  return [];
4065
4128
  }
4066
4129
  };
4130
+ const getCodexModelContextWindow = (model) => {
4131
+ if (!model) return void 0;
4132
+ const resolvedModel = resolveModelId(model);
4133
+ 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;
4134
+ return typeof contextWindow === "number" && contextWindow > 0 ? Math.trunc(contextWindow) : void 0;
4135
+ };
4067
4136
  const start = defineCommand({
4068
4137
  meta: {
4069
4138
  name: "start",
@@ -4139,8 +4208,12 @@ const start = defineCommand({
4139
4208
  runtimeState.rateLimitWait = Boolean(args.wait);
4140
4209
  consola.info(`Rate limit: ${parsed}s between requests (${runtimeState.rateLimitWait ? "wait" : "reject"} on overflow)`);
4141
4210
  }
4142
- await setupBridgeAuth(config, { showToken: args["show-token"] });
4211
+ const authSession = await setupBridgeAuth(config, { showToken: args["show-token"] });
4143
4212
  const server = startServer(config);
4213
+ consola.info(`copilot-bridge version: ${BRIDGE_VERSION}`);
4214
+ if (authSession.githubLogin) consola.info(`GitHub user: ${authSession.githubLogin}`);
4215
+ else if (authSession.source === "copilot-token") consola.info("GitHub user: unavailable (using COPILOT_TOKEN)");
4216
+ else consola.warn("GitHub user: unavailable");
4144
4217
  consola.success(`copilot-bridge listening on http://${config.host}:${config.port}`);
4145
4218
  consola.info(`copilot base url: ${config.copilotBaseUrl}`);
4146
4219
  const baseUrl = `http://${config.host}:${config.port}`;
@@ -4177,7 +4250,8 @@ const start = defineCommand({
4177
4250
  configPath: codexConfigPath,
4178
4251
  settings: CODEX_DEFAULTS,
4179
4252
  model: chosenModel,
4180
- modelReasoningEffort: chosenEffort
4253
+ modelReasoningEffort: chosenEffort,
4254
+ modelContextWindow: getCodexModelContextWindow(chosenModel)
4181
4255
  });
4182
4256
  if (result.changed) {
4183
4257
  consola.success(`codex config ${result.created ? "created" : "updated"}: ${result.configPath}`);
@@ -4218,7 +4292,8 @@ const start = defineCommand({
4218
4292
  configPath: codexConfigPath,
4219
4293
  settings: CODEX_DEFAULTS,
4220
4294
  model: chosenModel,
4221
- modelReasoningEffort: chosenEffort
4295
+ modelReasoningEffort: chosenEffort,
4296
+ modelContextWindow: getCodexModelContextWindow(chosenModel)
4222
4297
  });
4223
4298
  if (result.changed) consola.success(`codex config updated: ${result.configPath}`);
4224
4299
  } catch (error) {
@@ -4226,8 +4301,8 @@ const start = defineCommand({
4226
4301
  }
4227
4302
  }
4228
4303
  }
4229
- await new Promise((resolve, reject) => {
4230
- server.on("close", resolve);
4304
+ await new Promise((resolve$1, reject) => {
4305
+ server.on("close", resolve$1);
4231
4306
  server.on("error", reject);
4232
4307
  });
4233
4308
  }