github-router 0.3.13 → 0.3.14

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,7 +64,7 @@ const standardHeaders = () => ({
57
64
  "content-type": "application/json",
58
65
  accept: "application/json"
59
66
  });
60
- const COPILOT_VERSION = "0.38.2026021302";
67
+ const COPILOT_VERSION = "0.43.2026033101";
61
68
  const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}`;
62
69
  const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`;
63
70
  const API_VERSION = "2025-10-01";
@@ -114,17 +121,27 @@ async function forwardError(c, error) {
114
121
  } catch {
115
122
  errorJson = void 0;
116
123
  }
124
+ if (isAnthropicError(errorJson)) {
125
+ consola.error("HTTP error:", errorJson);
126
+ return c.json(errorJson, error.response.status);
127
+ }
117
128
  const message = resolveErrorMessage(errorJson, errorText);
118
129
  consola.error("HTTP error:", errorJson ?? errorText);
119
- return c.json({ error: {
120
- message,
121
- type: "error"
122
- } }, error.response.status);
130
+ return c.json({
131
+ type: "error",
132
+ error: {
133
+ type: resolveErrorType(error.response.status),
134
+ message
135
+ }
136
+ }, error.response.status);
123
137
  }
124
- return c.json({ error: {
125
- message: error instanceof Error ? error.message : String(error),
126
- type: "error"
127
- } }, 500);
138
+ return c.json({
139
+ type: "error",
140
+ error: {
141
+ type: "api_error",
142
+ message: error instanceof Error ? error.message : String(error)
143
+ }
144
+ }, 500);
128
145
  }
129
146
  function resolveErrorMessage(errorJson, fallback) {
130
147
  if (typeof errorJson !== "object" || errorJson === null) return fallback;
@@ -136,6 +153,30 @@ function resolveErrorMessage(errorJson, fallback) {
136
153
  }
137
154
  return fallback;
138
155
  }
156
+ /**
157
+ * Check if a parsed JSON body is already in Anthropic error format:
158
+ * { type: "error", error: { type: "...", message: "..." } }
159
+ */
160
+ function isAnthropicError(json) {
161
+ if (typeof json !== "object" || json === null) return false;
162
+ const record = json;
163
+ if (record.type !== "error") return false;
164
+ if (typeof record.error !== "object" || record.error === null) return false;
165
+ const inner = record.error;
166
+ return typeof inner.type === "string" && typeof inner.message === "string";
167
+ }
168
+ /**
169
+ * Map HTTP status to Anthropic error type.
170
+ */
171
+ function resolveErrorType(status) {
172
+ if (status === 400) return "invalid_request_error";
173
+ if (status === 401) return "authentication_error";
174
+ if (status === 403) return "permission_error";
175
+ if (status === 404) return "not_found_error";
176
+ if (status === 429) return "rate_limit_error";
177
+ if (status === 529) return "overloaded_error";
178
+ return "api_error";
179
+ }
139
180
 
140
181
  //#endregion
141
182
  //#region src/services/github/get-copilot-token.ts
@@ -208,23 +249,50 @@ const sleep = (ms) => new Promise((resolve) => {
208
249
  });
209
250
  const isNullish = (value) => value === null || value === void 0;
210
251
  /**
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.
252
+ * Beta prefixes VS Code Copilot Chat v0.43 actually sends.
253
+ * Default mode makes proxy traffic indistinguishable from VS Code.
214
254
  */
215
- const ALLOWED_BETA_PREFIXES = [
255
+ const VSCODE_BETA_PREFIXES = [
216
256
  "interleaved-thinking-",
217
257
  "context-management-",
218
- "advanced-tool-use-",
219
- "token-counting-"
258
+ "advanced-tool-use-"
220
259
  ];
221
260
  /**
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.
261
+ * Extended beta prefixes for Claude CLI compatibility.
262
+ * Enabled via --extended-betas flag. Includes all betas confirmed
263
+ * to work with the Copilot API.
264
+ *
265
+ * Notably absent: output-128k- (Copilot returns 400).
266
+ */
267
+ const EXTENDED_BETA_PREFIXES = [
268
+ ...VSCODE_BETA_PREFIXES,
269
+ "claude-code-",
270
+ "context-1m-",
271
+ "effort-",
272
+ "prompt-caching-",
273
+ "computer-use-",
274
+ "pdfs-",
275
+ "max-tokens-",
276
+ "token-counting-",
277
+ "compact-",
278
+ "structured-outputs-",
279
+ "fast-mode-",
280
+ "skills-",
281
+ "mcp-client-",
282
+ "mcp-servers-",
283
+ "files-api-",
284
+ "redact-thinking-",
285
+ "web-search-"
286
+ ];
287
+ /**
288
+ * Filter an `anthropic-beta` header value, keeping only beta flags
289
+ * in the active whitelist. Uses extended prefixes when --extended-betas
290
+ * is enabled, VS Code-only prefixes otherwise.
291
+ * Returns the filtered comma-separated string, or undefined if nothing remains.
225
292
  */
226
293
  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;
294
+ const prefixes = state.extendedBetas ? EXTENDED_BETA_PREFIXES : VSCODE_BETA_PREFIXES;
295
+ return value.split(",").map((v) => v.trim()).filter((v) => v && prefixes.some((prefix) => v.startsWith(prefix))).join(",") || void 0;
228
296
  }
229
297
  /**
230
298
  * Normalize a model ID for fuzzy comparison: lowercase, replace dots with
@@ -1306,16 +1374,18 @@ embeddingRoutes.post("/", async (c) => {
1306
1374
  * (anthropic-beta) so Copilot enables extended features.
1307
1375
  */
1308
1376
  function buildHeaders(extraHeaders) {
1309
- return {
1377
+ const headers = {
1310
1378
  ...copilotHeaders(state),
1311
1379
  accept: "application/json",
1312
- "openai-intent": "conversation-agent",
1380
+ "openai-intent": "messages-proxy",
1313
1381
  "x-interaction-type": "conversation-agent",
1314
1382
  "X-Initiator": "agent",
1315
1383
  "anthropic-version": "2023-06-01",
1316
1384
  "X-Interaction-Id": randomUUID(),
1317
1385
  ...extraHeaders
1318
1386
  };
1387
+ delete headers["copilot-integration-id"];
1388
+ return headers;
1319
1389
  }
1320
1390
  /**
1321
1391
  * Forward an Anthropic Messages API request to Copilot's native /v1/messages endpoint.
@@ -1437,7 +1507,7 @@ async function handleCountTokens(c) {
1437
1507
  return c.json(responseBody);
1438
1508
  }
1439
1509
  /**
1440
- * Parse the JSON body, resolve the model name, and re-serialize.
1510
+ * Parse the JSON body, resolve the model name, sanitize cache_control, and re-serialize.
1441
1511
  */
1442
1512
  function resolveModelInBody$1(rawBody) {
1443
1513
  let parsed;
@@ -1447,23 +1517,41 @@ function resolveModelInBody$1(rawBody) {
1447
1517
  return { body: rawBody };
1448
1518
  }
1449
1519
  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;
1520
+ let modified = false;
1521
+ if (originalModel) {
1522
+ const resolved = resolveModel(originalModel);
1523
+ if (resolved !== originalModel) {
1524
+ parsed.model = resolved;
1525
+ modified = true;
1526
+ }
1527
+ }
1528
+ if (rawBody.includes("\"scope\"")) {
1529
+ sanitizeCacheControl$1(parsed);
1530
+ modified = true;
1531
+ }
1532
+ const resolvedModel = typeof parsed.model === "string" ? parsed.model : originalModel;
1461
1533
  return {
1462
- body: JSON.stringify(parsed),
1534
+ body: modified ? JSON.stringify(parsed) : rawBody,
1463
1535
  originalModel,
1464
- resolvedModel: resolved
1536
+ resolvedModel
1465
1537
  };
1466
1538
  }
1539
+ function sanitizeCacheControl$1(body) {
1540
+ function stripScope(block) {
1541
+ if (block.cache_control?.scope !== void 0) {
1542
+ delete block.cache_control.scope;
1543
+ if (Object.keys(block.cache_control).length === 0) delete block.cache_control;
1544
+ }
1545
+ }
1546
+ if (Array.isArray(body.system)) for (const block of body.system) stripScope(block);
1547
+ if (Array.isArray(body.messages)) {
1548
+ for (const msg of body.messages) if (Array.isArray(msg.content)) for (const block of msg.content) {
1549
+ stripScope(block);
1550
+ if (Array.isArray(block.content)) for (const nested of block.content) stripScope(nested);
1551
+ }
1552
+ }
1553
+ if (Array.isArray(body.tools)) for (const tool of body.tools) stripScope(tool);
1554
+ }
1467
1555
 
1468
1556
  //#endregion
1469
1557
  //#region src/routes/messages/handler.ts
@@ -1609,13 +1697,18 @@ async function handleCompletion(c) {
1609
1697
  streaming: true
1610
1698
  }, selectedModel, startTime);
1611
1699
  if (debugEnabled) consola.debug("Streaming response from Copilot /v1/messages");
1700
+ const streamHeaders = {
1701
+ "content-type": "text/event-stream",
1702
+ "cache-control": "no-cache",
1703
+ connection: "keep-alive"
1704
+ };
1705
+ const requestId = response.headers.get("x-request-id");
1706
+ if (requestId) streamHeaders["x-request-id"] = requestId;
1707
+ const reqId = response.headers.get("request-id");
1708
+ if (reqId) streamHeaders["request-id"] = reqId;
1612
1709
  return new Response(response.body, {
1613
1710
  status: response.status,
1614
- headers: {
1615
- "content-type": "text/event-stream",
1616
- "cache-control": "no-cache",
1617
- connection: "keep-alive"
1618
- }
1711
+ headers: streamHeaders
1619
1712
  });
1620
1713
  }
1621
1714
  const responseBody = await response.json();
@@ -1629,11 +1722,18 @@ async function handleCompletion(c) {
1629
1722
  status: response.status
1630
1723
  }, selectedModel, startTime);
1631
1724
  if (debugEnabled) consola.debug("Non-streaming response from Copilot /v1/messages:", JSON.stringify(responseBody).slice(0, 2e3));
1725
+ const xRequestId = response.headers.get("x-request-id");
1726
+ if (xRequestId) c.header("x-request-id", xRequestId);
1727
+ const requestIdHeader = response.headers.get("request-id");
1728
+ if (requestIdHeader) c.header("request-id", requestIdHeader);
1632
1729
  return c.json(responseBody, response.status);
1633
1730
  }
1634
1731
  /**
1635
- * Parse the JSON body, resolve the model name, and re-serialize.
1636
- * Returns the body string plus the original and resolved model names.
1732
+ * Parse the JSON body, resolve the model name, sanitize cache_control
1733
+ * fields, and re-serialize. Returns the body string plus the original
1734
+ * and resolved model names.
1735
+ *
1736
+ * Re-serialization is skipped when no modifications are needed.
1637
1737
  */
1638
1738
  function resolveModelInBody(rawBody) {
1639
1739
  let parsed;
@@ -1643,24 +1743,50 @@ function resolveModelInBody(rawBody) {
1643
1743
  return { body: rawBody };
1644
1744
  }
1645
1745
  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;
1746
+ let modified = false;
1747
+ if (originalModel) {
1748
+ const resolved = resolveModel(originalModel);
1749
+ if (resolved !== originalModel) {
1750
+ parsed.model = resolved;
1751
+ modified = true;
1752
+ }
1753
+ }
1754
+ if (rawBody.includes("\"scope\"")) {
1755
+ sanitizeCacheControl(parsed);
1756
+ modified = true;
1757
+ }
1758
+ const resolvedModel = typeof parsed.model === "string" ? parsed.model : originalModel;
1657
1759
  return {
1658
- body: JSON.stringify(parsed),
1760
+ body: modified ? JSON.stringify(parsed) : rawBody,
1659
1761
  originalModel,
1660
- resolvedModel: resolved
1762
+ resolvedModel
1661
1763
  };
1662
1764
  }
1663
1765
  /**
1766
+ * Strip the `scope` field from all `cache_control` objects in the body.
1767
+ * Claude CLI 2.1.88+ sends {"type":"ephemeral","scope":"global"} which
1768
+ * Copilot rejects. Mutates the parsed object in place.
1769
+ *
1770
+ * Covers: system blocks, message content blocks (including nested
1771
+ * tool_result content), and tool definitions.
1772
+ */
1773
+ function sanitizeCacheControl(body) {
1774
+ function stripScope(block) {
1775
+ if (block.cache_control?.scope !== void 0) {
1776
+ delete block.cache_control.scope;
1777
+ if (Object.keys(block.cache_control).length === 0) delete block.cache_control;
1778
+ }
1779
+ }
1780
+ if (Array.isArray(body.system)) for (const block of body.system) stripScope(block);
1781
+ if (Array.isArray(body.messages)) {
1782
+ for (const msg of body.messages) if (Array.isArray(msg.content)) for (const block of msg.content) {
1783
+ stripScope(block);
1784
+ if (Array.isArray(block.content)) for (const nested of block.content) stripScope(nested);
1785
+ }
1786
+ }
1787
+ if (Array.isArray(body.tools)) for (const tool of body.tools) stripScope(tool);
1788
+ }
1789
+ /**
1664
1790
  * Apply default anthropic-beta values for Claude models when the client
1665
1791
  * (e.g. curl) sends no beta headers. Claude CLI sends its own betas,
1666
1792
  * so this only fires as a safety net for bare clients.
@@ -1670,7 +1796,7 @@ function applyDefaultBetas(betaHeaders, modelId) {
1670
1796
  if (!modelId || !modelId.startsWith("claude-")) return betaHeaders;
1671
1797
  return {
1672
1798
  ...betaHeaders,
1673
- "anthropic-beta": ["interleaved-thinking-2025-05-14", "token-counting-2024-11-01"].join(",")
1799
+ "anthropic-beta": ["interleaved-thinking-2025-05-14", "context-management-2025-06-27"].join(",")
1674
1800
  };
1675
1801
  }
1676
1802
 
@@ -2037,6 +2163,7 @@ usageRoute.get("/", async (c) => {
2037
2163
  const server = new Hono();
2038
2164
  server.use(cors());
2039
2165
  server.get("/", (c) => c.text("Server running"));
2166
+ server.on("HEAD", ["/"], (c) => c.body(null, 200));
2040
2167
  server.route("/chat/completions", completionRoutes);
2041
2168
  server.route("/responses", responsesRoutes);
2042
2169
  server.route("/models", modelRoutes);
@@ -2050,6 +2177,13 @@ server.route("/v1/models", modelRoutes);
2050
2177
  server.route("/v1/embeddings", embeddingRoutes);
2051
2178
  server.route("/v1/search", searchRoutes);
2052
2179
  server.route("/v1/messages", messageRoutes);
2180
+ server.notFound((c) => c.json({
2181
+ type: "error",
2182
+ error: {
2183
+ type: "not_found_error",
2184
+ message: `${c.req.method} ${c.req.path} not found`
2185
+ }
2186
+ }, 404));
2053
2187
 
2054
2188
  //#endregion
2055
2189
  //#region src/lib/server-setup.ts
@@ -2066,6 +2200,7 @@ async function setupAndServe(options) {
2066
2200
  state.rateLimitSeconds = options.rateLimit;
2067
2201
  state.rateLimitWait = options.rateLimitWait;
2068
2202
  state.showToken = options.showToken;
2203
+ state.extendedBetas = options.extendedBetas;
2069
2204
  if (process.env.COPILOT_API_URL) state.copilotApiUrl = process.env.COPILOT_API_URL;
2070
2205
  await ensurePaths();
2071
2206
  await cacheVSCodeVersion();
@@ -2162,6 +2297,11 @@ const sharedServerArgs = {
2162
2297
  type: "boolean",
2163
2298
  default: false,
2164
2299
  description: "Initialize proxy from environment variables"
2300
+ },
2301
+ "extended-betas": {
2302
+ type: "boolean",
2303
+ default: false,
2304
+ description: "Forward extended beta headers for Claude CLI compatibility (default: VS Code-only)"
2165
2305
  }
2166
2306
  };
2167
2307
  const allowedAccountTypes = new Set([
@@ -2197,7 +2337,8 @@ function parseSharedArgs(args) {
2197
2337
  rateLimitWait,
2198
2338
  githubToken,
2199
2339
  showToken: args["show-token"],
2200
- proxyEnv: args["proxy-env"]
2340
+ proxyEnv: args["proxy-env"],
2341
+ extendedBetas: args["extended-betas"]
2201
2342
  };
2202
2343
  }
2203
2344
  /** Build environment variables for Claude Code. */