github-router 0.3.13 → 0.3.15

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/dist/main.js CHANGED
@@ -19,13 +19,19 @@ import { events } from "fetch-event-stream";
19
19
  import clipboard from "clipboardy";
20
20
 
21
21
  //#region src/lib/paths.ts
22
- const APP_DIR = path.join(os.homedir(), ".local", "share", "github-router");
23
- const GITHUB_TOKEN_PATH = path.join(APP_DIR, "github_token");
24
- const ERROR_LOG_PATH = path.join(APP_DIR, "error.log");
22
+ function appDir() {
23
+ return path.join(os.homedir(), ".local", "share", "github-router");
24
+ }
25
25
  const PATHS = {
26
- APP_DIR,
27
- GITHUB_TOKEN_PATH,
28
- ERROR_LOG_PATH
26
+ get APP_DIR() {
27
+ return appDir();
28
+ },
29
+ get GITHUB_TOKEN_PATH() {
30
+ return path.join(appDir(), "github_token");
31
+ },
32
+ get ERROR_LOG_PATH() {
33
+ return path.join(appDir(), "error.log");
34
+ }
29
35
  };
30
36
  async function ensurePaths() {
31
37
  await fs.mkdir(PATHS.APP_DIR, { recursive: true });
@@ -47,6 +53,7 @@ const state = {
47
53
  manualApprove: false,
48
54
  rateLimitWait: false,
49
55
  showToken: false,
56
+ extendedBetas: false,
50
57
  sessionId: randomUUID(),
51
58
  machineId: randomBytes(32).toString("hex")
52
59
  };
@@ -57,19 +64,21 @@ const standardHeaders = () => ({
57
64
  "content-type": "application/json",
58
65
  accept: "application/json"
59
66
  });
60
- const COPILOT_VERSION = "0.38.2026021302";
61
- const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}`;
62
- const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`;
67
+ const DEFAULT_COPILOT_VERSION = "0.43.2026033101";
68
+ function copilotVersion(state$1) {
69
+ return state$1.copilotVersion ?? DEFAULT_COPILOT_VERSION;
70
+ }
63
71
  const API_VERSION = "2025-10-01";
64
72
  const copilotBaseUrl = (state$1) => state$1.copilotApiUrl ?? "https://api.githubcopilot.com";
65
73
  const copilotHeaders = (state$1, vision = false, integrationId = "vscode-chat") => {
74
+ const version = copilotVersion(state$1);
66
75
  const headers = {
67
76
  Authorization: `Bearer ${state$1.copilotToken}`,
68
77
  "content-type": standardHeaders()["content-type"],
69
78
  "copilot-integration-id": integrationId,
70
79
  "editor-version": `vscode/${state$1.vsCodeVersion}`,
71
- "editor-plugin-version": EDITOR_PLUGIN_VERSION,
72
- "user-agent": USER_AGENT,
80
+ "editor-plugin-version": `copilot-chat/${version}`,
81
+ "user-agent": `GitHubCopilotChat/${version}`,
73
82
  "openai-intent": "conversation-panel",
74
83
  "x-interaction-type": "conversation-panel",
75
84
  "x-github-api-version": API_VERSION,
@@ -86,8 +95,8 @@ const githubHeaders = (state$1) => ({
86
95
  ...standardHeaders(),
87
96
  authorization: `token ${state$1.githubToken}`,
88
97
  "editor-version": `vscode/${state$1.vsCodeVersion}`,
89
- "editor-plugin-version": EDITOR_PLUGIN_VERSION,
90
- "user-agent": USER_AGENT,
98
+ "editor-plugin-version": `copilot-chat/${copilotVersion(state$1)}`,
99
+ "user-agent": `GitHubCopilotChat/${copilotVersion(state$1)}`,
91
100
  "x-github-api-version": API_VERSION,
92
101
  "x-vscode-user-agent-library-version": "electron-fetch"
93
102
  });
@@ -114,17 +123,27 @@ async function forwardError(c, error) {
114
123
  } catch {
115
124
  errorJson = void 0;
116
125
  }
126
+ if (isAnthropicError(errorJson)) {
127
+ consola.error("HTTP error:", errorJson);
128
+ return c.json(errorJson, error.response.status);
129
+ }
117
130
  const message = resolveErrorMessage(errorJson, errorText);
118
131
  consola.error("HTTP error:", errorJson ?? errorText);
119
- return c.json({ error: {
120
- message,
121
- type: "error"
122
- } }, error.response.status);
132
+ return c.json({
133
+ type: "error",
134
+ error: {
135
+ type: resolveErrorType(error.response.status),
136
+ message
137
+ }
138
+ }, error.response.status);
123
139
  }
124
- return c.json({ error: {
125
- message: error instanceof Error ? error.message : String(error),
126
- type: "error"
127
- } }, 500);
140
+ return c.json({
141
+ type: "error",
142
+ error: {
143
+ type: "api_error",
144
+ message: error instanceof Error ? error.message : String(error)
145
+ }
146
+ }, 500);
128
147
  }
129
148
  function resolveErrorMessage(errorJson, fallback) {
130
149
  if (typeof errorJson !== "object" || errorJson === null) return fallback;
@@ -136,6 +155,30 @@ function resolveErrorMessage(errorJson, fallback) {
136
155
  }
137
156
  return fallback;
138
157
  }
158
+ /**
159
+ * Check if a parsed JSON body is already in Anthropic error format:
160
+ * { type: "error", error: { type: "...", message: "..." } }
161
+ */
162
+ function isAnthropicError(json) {
163
+ if (typeof json !== "object" || json === null) return false;
164
+ const record = json;
165
+ if (record.type !== "error") return false;
166
+ if (typeof record.error !== "object" || record.error === null) return false;
167
+ const inner = record.error;
168
+ return typeof inner.type === "string" && typeof inner.message === "string";
169
+ }
170
+ /**
171
+ * Map HTTP status to Anthropic error type.
172
+ */
173
+ function resolveErrorType(status) {
174
+ if (status === 400) return "invalid_request_error";
175
+ if (status === 401) return "authentication_error";
176
+ if (status === 403) return "permission_error";
177
+ if (status === 404) return "not_found_error";
178
+ if (status === 429) return "rate_limit_error";
179
+ if (status === 529) return "overloaded_error";
180
+ return "api_error";
181
+ }
139
182
 
140
183
  //#endregion
141
184
  //#region src/services/github/get-copilot-token.ts
@@ -181,6 +224,39 @@ const getModels = async () => {
181
224
  return await response.json();
182
225
  };
183
226
 
227
+ //#endregion
228
+ //#region src/services/get-copilot-version.ts
229
+ const FALLBACK$1 = "0.43.2026033101";
230
+ async function getCopilotChatVersion() {
231
+ const controller = new AbortController();
232
+ const timeout = setTimeout(() => {
233
+ controller.abort();
234
+ }, 5e3);
235
+ try {
236
+ const response = await fetch("https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery", {
237
+ method: "POST",
238
+ headers: {
239
+ "Content-Type": "application/json",
240
+ Accept: "application/json;api-version=7.1-preview.1"
241
+ },
242
+ body: JSON.stringify({
243
+ filters: [{ criteria: [{
244
+ filterType: 7,
245
+ value: "GitHub.copilot-chat"
246
+ }] }],
247
+ flags: 914
248
+ }),
249
+ signal: controller.signal
250
+ });
251
+ if (!response.ok) return FALLBACK$1;
252
+ return (await response.json())?.results?.[0]?.extensions?.[0]?.versions?.[0]?.version ?? FALLBACK$1;
253
+ } catch {
254
+ return FALLBACK$1;
255
+ } finally {
256
+ clearTimeout(timeout);
257
+ }
258
+ }
259
+
184
260
  //#endregion
185
261
  //#region src/services/get-vscode-version.ts
186
262
  const FALLBACK = "1.104.3";
@@ -208,23 +284,50 @@ const sleep = (ms) => new Promise((resolve) => {
208
284
  });
209
285
  const isNullish = (value) => value === null || value === void 0;
210
286
  /**
211
- * Beta values that VS Code Copilot Chat actually sends to the Copilot API.
212
- * Only these are forwarded; everything else (e.g. context-1m-*) is stripped
213
- * so our requests match what VS Code produces.
287
+ * Beta prefixes VS Code Copilot Chat v0.43 actually sends.
288
+ * Default mode makes proxy traffic indistinguishable from VS Code.
214
289
  */
215
- const ALLOWED_BETA_PREFIXES = [
290
+ const VSCODE_BETA_PREFIXES = [
216
291
  "interleaved-thinking-",
217
292
  "context-management-",
218
- "advanced-tool-use-",
219
- "token-counting-"
293
+ "advanced-tool-use-"
220
294
  ];
221
295
  /**
222
- * Filter an `anthropic-beta` header value, keeping only beta flags that
223
- * VS Code Copilot is known to send. Returns the filtered comma-separated
224
- * string, or undefined if nothing remains.
296
+ * Extended beta prefixes for Claude CLI compatibility.
297
+ * Enabled via --extended-betas flag. Includes all betas confirmed
298
+ * to work with the Copilot API.
299
+ *
300
+ * Notably absent: output-128k- (Copilot returns 400).
301
+ */
302
+ const EXTENDED_BETA_PREFIXES = [
303
+ ...VSCODE_BETA_PREFIXES,
304
+ "claude-code-",
305
+ "context-1m-",
306
+ "effort-",
307
+ "prompt-caching-",
308
+ "computer-use-",
309
+ "pdfs-",
310
+ "max-tokens-",
311
+ "token-counting-",
312
+ "compact-",
313
+ "structured-outputs-",
314
+ "fast-mode-",
315
+ "skills-",
316
+ "mcp-client-",
317
+ "mcp-servers-",
318
+ "files-api-",
319
+ "redact-thinking-",
320
+ "web-search-"
321
+ ];
322
+ /**
323
+ * Filter an `anthropic-beta` header value, keeping only beta flags
324
+ * in the active whitelist. Uses extended prefixes when --extended-betas
325
+ * is enabled, VS Code-only prefixes otherwise.
326
+ * Returns the filtered comma-separated string, or undefined if nothing remains.
225
327
  */
226
328
  function filterBetaHeader(value) {
227
- return value.split(",").map((v) => v.trim()).filter((v) => v && ALLOWED_BETA_PREFIXES.some((prefix) => v.startsWith(prefix))).join(",") || void 0;
329
+ const prefixes = state.extendedBetas ? EXTENDED_BETA_PREFIXES : VSCODE_BETA_PREFIXES;
330
+ return value.split(",").map((v) => v.trim()).filter((v) => v && prefixes.some((prefix) => v.startsWith(prefix))).join(",") || void 0;
228
331
  }
229
332
  /**
230
333
  * Normalize a model ID for fuzzy comparison: lowercase, replace dots with
@@ -297,6 +400,11 @@ const cacheVSCodeVersion = async () => {
297
400
  state.vsCodeVersion = response;
298
401
  consola.info(`Using VSCode version: ${response}`);
299
402
  };
403
+ const cacheCopilotVersion = async () => {
404
+ const version = await getCopilotChatVersion();
405
+ state.copilotVersion = version;
406
+ consola.info(`Using Copilot Chat version: ${version}`);
407
+ };
300
408
 
301
409
  //#endregion
302
410
  //#region src/services/github/poll-access-token.ts
@@ -1306,16 +1414,18 @@ embeddingRoutes.post("/", async (c) => {
1306
1414
  * (anthropic-beta) so Copilot enables extended features.
1307
1415
  */
1308
1416
  function buildHeaders(extraHeaders) {
1309
- return {
1417
+ const headers = {
1310
1418
  ...copilotHeaders(state),
1311
1419
  accept: "application/json",
1312
- "openai-intent": "conversation-agent",
1420
+ "openai-intent": "messages-proxy",
1313
1421
  "x-interaction-type": "conversation-agent",
1314
1422
  "X-Initiator": "agent",
1315
1423
  "anthropic-version": "2023-06-01",
1316
1424
  "X-Interaction-Id": randomUUID(),
1317
1425
  ...extraHeaders
1318
1426
  };
1427
+ delete headers["copilot-integration-id"];
1428
+ return headers;
1319
1429
  }
1320
1430
  /**
1321
1431
  * Forward an Anthropic Messages API request to Copilot's native /v1/messages endpoint.
@@ -1437,7 +1547,7 @@ async function handleCountTokens(c) {
1437
1547
  return c.json(responseBody);
1438
1548
  }
1439
1549
  /**
1440
- * Parse the JSON body, resolve the model name, and re-serialize.
1550
+ * Parse the JSON body, resolve the model name, sanitize cache_control, and re-serialize.
1441
1551
  */
1442
1552
  function resolveModelInBody$1(rawBody) {
1443
1553
  let parsed;
@@ -1447,23 +1557,41 @@ function resolveModelInBody$1(rawBody) {
1447
1557
  return { body: rawBody };
1448
1558
  }
1449
1559
  const originalModel = typeof parsed.model === "string" ? parsed.model : void 0;
1450
- if (!originalModel) return {
1451
- body: rawBody,
1452
- originalModel
1453
- };
1454
- const resolved = resolveModel(originalModel);
1455
- if (resolved === originalModel) return {
1456
- body: rawBody,
1457
- originalModel,
1458
- resolvedModel: originalModel
1459
- };
1460
- parsed.model = resolved;
1560
+ let modified = false;
1561
+ if (originalModel) {
1562
+ const resolved = resolveModel(originalModel);
1563
+ if (resolved !== originalModel) {
1564
+ parsed.model = resolved;
1565
+ modified = true;
1566
+ }
1567
+ }
1568
+ if (rawBody.includes("\"scope\"") && sanitizeCacheControl$1(parsed)) modified = true;
1569
+ const resolvedModel = typeof parsed.model === "string" ? parsed.model : originalModel;
1461
1570
  return {
1462
- body: JSON.stringify(parsed),
1571
+ body: modified ? JSON.stringify(parsed) : rawBody,
1463
1572
  originalModel,
1464
- resolvedModel: resolved
1573
+ resolvedModel
1465
1574
  };
1466
1575
  }
1576
+ function sanitizeCacheControl$1(body) {
1577
+ let stripped = false;
1578
+ function stripScope(block) {
1579
+ if (block.cache_control?.scope !== void 0) {
1580
+ delete block.cache_control.scope;
1581
+ if (Object.keys(block.cache_control).length === 0) delete block.cache_control;
1582
+ stripped = true;
1583
+ }
1584
+ }
1585
+ if (Array.isArray(body.system)) for (const block of body.system) stripScope(block);
1586
+ if (Array.isArray(body.messages)) {
1587
+ for (const msg of body.messages) if (Array.isArray(msg.content)) for (const block of msg.content) {
1588
+ stripScope(block);
1589
+ if (Array.isArray(block.content)) for (const nested of block.content) stripScope(nested);
1590
+ }
1591
+ }
1592
+ if (Array.isArray(body.tools)) for (const tool of body.tools) stripScope(tool);
1593
+ return stripped;
1594
+ }
1467
1595
 
1468
1596
  //#endregion
1469
1597
  //#region src/routes/messages/handler.ts
@@ -1609,13 +1737,18 @@ async function handleCompletion(c) {
1609
1737
  streaming: true
1610
1738
  }, selectedModel, startTime);
1611
1739
  if (debugEnabled) consola.debug("Streaming response from Copilot /v1/messages");
1740
+ const streamHeaders = {
1741
+ "content-type": "text/event-stream",
1742
+ "cache-control": "no-cache",
1743
+ connection: "keep-alive"
1744
+ };
1745
+ const requestId = response.headers.get("x-request-id");
1746
+ if (requestId) streamHeaders["x-request-id"] = requestId;
1747
+ const reqId = response.headers.get("request-id");
1748
+ if (reqId) streamHeaders["request-id"] = reqId;
1612
1749
  return new Response(response.body, {
1613
1750
  status: response.status,
1614
- headers: {
1615
- "content-type": "text/event-stream",
1616
- "cache-control": "no-cache",
1617
- connection: "keep-alive"
1618
- }
1751
+ headers: streamHeaders
1619
1752
  });
1620
1753
  }
1621
1754
  const responseBody = await response.json();
@@ -1629,11 +1762,18 @@ async function handleCompletion(c) {
1629
1762
  status: response.status
1630
1763
  }, selectedModel, startTime);
1631
1764
  if (debugEnabled) consola.debug("Non-streaming response from Copilot /v1/messages:", JSON.stringify(responseBody).slice(0, 2e3));
1765
+ const xRequestId = response.headers.get("x-request-id");
1766
+ if (xRequestId) c.header("x-request-id", xRequestId);
1767
+ const requestIdHeader = response.headers.get("request-id");
1768
+ if (requestIdHeader) c.header("request-id", requestIdHeader);
1632
1769
  return c.json(responseBody, response.status);
1633
1770
  }
1634
1771
  /**
1635
- * Parse the JSON body, resolve the model name, and re-serialize.
1636
- * Returns the body string plus the original and resolved model names.
1772
+ * Parse the JSON body, resolve the model name, sanitize cache_control
1773
+ * fields, and re-serialize. Returns the body string plus the original
1774
+ * and resolved model names.
1775
+ *
1776
+ * Re-serialization is skipped when no modifications are needed.
1637
1777
  */
1638
1778
  function resolveModelInBody(rawBody) {
1639
1779
  let parsed;
@@ -1643,24 +1783,50 @@ function resolveModelInBody(rawBody) {
1643
1783
  return { body: rawBody };
1644
1784
  }
1645
1785
  const originalModel = typeof parsed.model === "string" ? parsed.model : void 0;
1646
- if (!originalModel) return {
1647
- body: rawBody,
1648
- originalModel
1649
- };
1650
- const resolved = resolveModel(originalModel);
1651
- if (resolved === originalModel) return {
1652
- body: rawBody,
1653
- originalModel,
1654
- resolvedModel: originalModel
1655
- };
1656
- parsed.model = resolved;
1786
+ let modified = false;
1787
+ if (originalModel) {
1788
+ const resolved = resolveModel(originalModel);
1789
+ if (resolved !== originalModel) {
1790
+ parsed.model = resolved;
1791
+ modified = true;
1792
+ }
1793
+ }
1794
+ if (rawBody.includes("\"scope\"") && sanitizeCacheControl(parsed)) modified = true;
1795
+ const resolvedModel = typeof parsed.model === "string" ? parsed.model : originalModel;
1657
1796
  return {
1658
- body: JSON.stringify(parsed),
1797
+ body: modified ? JSON.stringify(parsed) : rawBody,
1659
1798
  originalModel,
1660
- resolvedModel: resolved
1799
+ resolvedModel
1661
1800
  };
1662
1801
  }
1663
1802
  /**
1803
+ * Strip the `scope` field from all `cache_control` objects in the body.
1804
+ * Claude CLI 2.1.88+ sends {"type":"ephemeral","scope":"global"} which
1805
+ * Copilot rejects. Mutates the parsed object in place.
1806
+ *
1807
+ * Covers: system blocks, message content blocks (including nested
1808
+ * tool_result content), and tool definitions.
1809
+ */
1810
+ function sanitizeCacheControl(body) {
1811
+ let stripped = false;
1812
+ function stripScope(block) {
1813
+ if (block.cache_control?.scope !== void 0) {
1814
+ delete block.cache_control.scope;
1815
+ if (Object.keys(block.cache_control).length === 0) delete block.cache_control;
1816
+ stripped = true;
1817
+ }
1818
+ }
1819
+ if (Array.isArray(body.system)) for (const block of body.system) stripScope(block);
1820
+ if (Array.isArray(body.messages)) {
1821
+ for (const msg of body.messages) if (Array.isArray(msg.content)) for (const block of msg.content) {
1822
+ stripScope(block);
1823
+ if (Array.isArray(block.content)) for (const nested of block.content) stripScope(nested);
1824
+ }
1825
+ }
1826
+ if (Array.isArray(body.tools)) for (const tool of body.tools) stripScope(tool);
1827
+ return stripped;
1828
+ }
1829
+ /**
1664
1830
  * Apply default anthropic-beta values for Claude models when the client
1665
1831
  * (e.g. curl) sends no beta headers. Claude CLI sends its own betas,
1666
1832
  * so this only fires as a safety net for bare clients.
@@ -1670,7 +1836,7 @@ function applyDefaultBetas(betaHeaders, modelId) {
1670
1836
  if (!modelId || !modelId.startsWith("claude-")) return betaHeaders;
1671
1837
  return {
1672
1838
  ...betaHeaders,
1673
- "anthropic-beta": ["interleaved-thinking-2025-05-14", "token-counting-2024-11-01"].join(",")
1839
+ "anthropic-beta": ["interleaved-thinking-2025-05-14", "context-management-2025-06-27"].join(",")
1674
1840
  };
1675
1841
  }
1676
1842
 
@@ -2037,6 +2203,7 @@ usageRoute.get("/", async (c) => {
2037
2203
  const server = new Hono();
2038
2204
  server.use(cors());
2039
2205
  server.get("/", (c) => c.text("Server running"));
2206
+ server.on("HEAD", ["/"], (c) => c.body(null, 200));
2040
2207
  server.route("/chat/completions", completionRoutes);
2041
2208
  server.route("/responses", responsesRoutes);
2042
2209
  server.route("/models", modelRoutes);
@@ -2050,6 +2217,13 @@ server.route("/v1/models", modelRoutes);
2050
2217
  server.route("/v1/embeddings", embeddingRoutes);
2051
2218
  server.route("/v1/search", searchRoutes);
2052
2219
  server.route("/v1/messages", messageRoutes);
2220
+ server.notFound((c) => c.json({
2221
+ type: "error",
2222
+ error: {
2223
+ type: "not_found_error",
2224
+ message: `${c.req.method} ${c.req.path} not found`
2225
+ }
2226
+ }, 404));
2053
2227
 
2054
2228
  //#endregion
2055
2229
  //#region src/lib/server-setup.ts
@@ -2066,9 +2240,11 @@ async function setupAndServe(options) {
2066
2240
  state.rateLimitSeconds = options.rateLimit;
2067
2241
  state.rateLimitWait = options.rateLimitWait;
2068
2242
  state.showToken = options.showToken;
2243
+ state.extendedBetas = options.extendedBetas;
2069
2244
  if (process.env.COPILOT_API_URL) state.copilotApiUrl = process.env.COPILOT_API_URL;
2070
2245
  await ensurePaths();
2071
2246
  await cacheVSCodeVersion();
2247
+ await cacheCopilotVersion();
2072
2248
  if (options.githubToken) {
2073
2249
  state.githubToken = options.githubToken;
2074
2250
  consola.info("Using provided GitHub token");
@@ -2162,6 +2338,11 @@ const sharedServerArgs = {
2162
2338
  type: "boolean",
2163
2339
  default: false,
2164
2340
  description: "Initialize proxy from environment variables"
2341
+ },
2342
+ "extended-betas": {
2343
+ type: "boolean",
2344
+ default: false,
2345
+ description: "Forward extended beta headers for Claude CLI compatibility (default: VS Code-only)"
2165
2346
  }
2166
2347
  };
2167
2348
  const allowedAccountTypes = new Set([
@@ -2197,7 +2378,8 @@ function parseSharedArgs(args) {
2197
2378
  rateLimitWait,
2198
2379
  githubToken,
2199
2380
  showToken: args["show-token"],
2200
- proxyEnv: args["proxy-env"]
2381
+ proxyEnv: args["proxy-env"],
2382
+ extendedBetas: args["extended-betas"]
2201
2383
  };
2202
2384
  }
2203
2385
  /** Build environment variables for Claude Code. */