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/LICENSE +21 -21
- package/README.md +206 -206
- package/dist/main.js +204 -63
- package/dist/main.js.map +1 -1
- package/package.json +2 -5
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
function appDir() {
|
|
23
|
+
return path.join(os.homedir(), ".local", "share", "github-router");
|
|
24
|
+
}
|
|
25
25
|
const PATHS = {
|
|
26
|
-
APP_DIR
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
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({
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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({
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
212
|
-
*
|
|
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
|
|
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
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1377
|
+
const headers = {
|
|
1310
1378
|
...copilotHeaders(state),
|
|
1311
1379
|
accept: "application/json",
|
|
1312
|
-
"openai-intent": "
|
|
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
|
-
|
|
1451
|
-
|
|
1452
|
-
originalModel
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
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
|
|
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,
|
|
1636
|
-
* Returns the body string plus the original
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
originalModel
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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
|
|
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", "
|
|
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. */
|