copilot-relay 0.3.8 → 0.3.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/dist/main.js +157 -25
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from "citty";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
5
|
import { inspect, promisify } from "node:util";
|
|
5
6
|
import consola from "consola";
|
|
6
7
|
import os from "node:os";
|
|
@@ -9,7 +10,6 @@ import { fileURLToPath } from "node:url";
|
|
|
9
10
|
import { execFile } from "node:child_process";
|
|
10
11
|
import { readFileSync } from "node:fs";
|
|
11
12
|
import { createAdaptorServer } from "@hono/node-server";
|
|
12
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
13
13
|
import { Agent, fetch as fetch$1 } from "undici";
|
|
14
14
|
import { events } from "fetch-event-stream";
|
|
15
15
|
import { Hono } from "hono";
|
|
@@ -407,7 +407,16 @@ const writeStoredCopilotToken = async (input) => {
|
|
|
407
407
|
refreshIn: input.refresh_in,
|
|
408
408
|
token: input.token
|
|
409
409
|
};
|
|
410
|
-
|
|
410
|
+
const temporaryPath = `${paths.copilotTokenPath}.${randomUUID()}.tmp`;
|
|
411
|
+
try {
|
|
412
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
413
|
+
mode: 384,
|
|
414
|
+
flag: "wx"
|
|
415
|
+
});
|
|
416
|
+
await fs.rename(temporaryPath, paths.copilotTokenPath);
|
|
417
|
+
} finally {
|
|
418
|
+
await fs.rm(temporaryPath, { force: true });
|
|
419
|
+
}
|
|
411
420
|
};
|
|
412
421
|
const getStoredTokenRemainingSeconds = (token) => {
|
|
413
422
|
const ageSeconds = Math.floor((Date.now() - token.refreshedAt) / 1e3);
|
|
@@ -460,8 +469,11 @@ const getGitHubUser = async (githubToken, vsCodeVersion) => {
|
|
|
460
469
|
if (!response.ok) throw new HTTPError("Failed to get GitHub user", response);
|
|
461
470
|
return await response.json();
|
|
462
471
|
};
|
|
463
|
-
const getCopilotToken = async (githubToken, vsCodeVersion) => {
|
|
464
|
-
const response = await fetch(`${githubApiBaseUrl}/copilot_internal/v2/token`, {
|
|
472
|
+
const getCopilotToken = async (githubToken, vsCodeVersion, signal) => {
|
|
473
|
+
const response = await fetch(`${githubApiBaseUrl}/copilot_internal/v2/token`, {
|
|
474
|
+
headers: githubHeaders(githubToken, vsCodeVersion),
|
|
475
|
+
signal
|
|
476
|
+
});
|
|
465
477
|
if (!response.ok) throw new HTTPError("Failed to get Copilot token", response);
|
|
466
478
|
return await response.json();
|
|
467
479
|
};
|
|
@@ -489,11 +501,21 @@ const loadGitHubLogin = async (githubToken, vsCodeVersion) => {
|
|
|
489
501
|
};
|
|
490
502
|
const setupProxyAuth = async (config, options = {}) => {
|
|
491
503
|
let githubToken = await ensureGitHubToken(options);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
504
|
+
let refreshInFlight;
|
|
505
|
+
const applyCopilotToken = () => {
|
|
506
|
+
if (refreshInFlight) return refreshInFlight;
|
|
507
|
+
refreshInFlight = (async () => {
|
|
508
|
+
const tokenResponse = await getCopilotToken(githubToken, config.vsCodeVersion, AbortSignal.timeout(config.upstreamTimeoutMs > 0 ? config.upstreamTimeoutMs : 18e4));
|
|
509
|
+
if (typeof tokenResponse.token !== "string" || !tokenResponse.token.trim() || !Number.isFinite(tokenResponse.refresh_in) || tokenResponse.refresh_in <= 0) throw new Error("Copilot token exchange returned invalid credentials metadata.");
|
|
510
|
+
await writeStoredCopilotToken(tokenResponse);
|
|
511
|
+
config.copilotToken = tokenResponse.token;
|
|
512
|
+
config.copilotTokenGeneration = (config.copilotTokenGeneration ?? 0) + 1;
|
|
513
|
+
scheduleCopilotTokenRefresh(tokenResponse.refresh_in);
|
|
514
|
+
return tokenResponse.refresh_in;
|
|
515
|
+
})().finally(() => {
|
|
516
|
+
refreshInFlight = void 0;
|
|
517
|
+
});
|
|
518
|
+
return refreshInFlight;
|
|
497
519
|
};
|
|
498
520
|
const scheduleCopilotTokenRefresh = (refreshIn$1) => {
|
|
499
521
|
if (copilotTokenRefreshTimer) clearTimeout(copilotTokenRefreshTimer);
|
|
@@ -501,11 +523,10 @@ const setupProxyAuth = async (config, options = {}) => {
|
|
|
501
523
|
log.info(`Next Copilot token refresh in ${Math.round(refreshMs / 1e3)}s`);
|
|
502
524
|
copilotTokenRefreshTimer = setTimeout(async () => {
|
|
503
525
|
try {
|
|
504
|
-
|
|
526
|
+
await applyCopilotToken();
|
|
505
527
|
log.info("Refreshed Copilot token");
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
log.error("Failed to refresh Copilot token:", error);
|
|
528
|
+
} catch {
|
|
529
|
+
log.error("Failed to refresh Copilot token; retrying in 60s");
|
|
509
530
|
scheduleCopilotTokenRefresh(60);
|
|
510
531
|
}
|
|
511
532
|
}, refreshMs);
|
|
@@ -517,6 +538,7 @@ const setupProxyAuth = async (config, options = {}) => {
|
|
|
517
538
|
config.copilotToken = storedToken.token;
|
|
518
539
|
refreshIn = getStoredTokenRemainingSeconds(storedToken);
|
|
519
540
|
log.info(`Using cached Copilot token at ${paths.copilotTokenPath}`);
|
|
541
|
+
scheduleCopilotTokenRefresh(refreshIn);
|
|
520
542
|
} else try {
|
|
521
543
|
refreshIn = await applyCopilotToken();
|
|
522
544
|
} catch (error) {
|
|
@@ -528,7 +550,14 @@ const setupProxyAuth = async (config, options = {}) => {
|
|
|
528
550
|
});
|
|
529
551
|
refreshIn = await applyCopilotToken();
|
|
530
552
|
}
|
|
531
|
-
|
|
553
|
+
config.refreshCopilotToken = async (rejectedToken, generation) => {
|
|
554
|
+
if (config.copilotToken !== rejectedToken || (config.copilotTokenGeneration ?? 0) !== generation) return;
|
|
555
|
+
try {
|
|
556
|
+
await applyCopilotToken();
|
|
557
|
+
} catch {
|
|
558
|
+
throw new Error("Copilot token refresh failed; upstream request was not replayed.");
|
|
559
|
+
}
|
|
560
|
+
};
|
|
532
561
|
return {
|
|
533
562
|
githubLogin: await loadGitHubLogin(githubToken, config.vsCodeVersion),
|
|
534
563
|
source: "github-token"
|
|
@@ -1332,7 +1361,13 @@ const copilotDispatcher = new Agent({
|
|
|
1332
1361
|
});
|
|
1333
1362
|
const getCopilotProviderContext = (config) => ({
|
|
1334
1363
|
baseUrl: config.copilotBaseUrl,
|
|
1335
|
-
token
|
|
1364
|
+
get token() {
|
|
1365
|
+
return config.copilotToken;
|
|
1366
|
+
},
|
|
1367
|
+
get tokenGeneration() {
|
|
1368
|
+
return config.copilotTokenGeneration ?? 0;
|
|
1369
|
+
},
|
|
1370
|
+
refreshToken: config.refreshCopilotToken,
|
|
1336
1371
|
vsCodeVersion: config.vsCodeVersion
|
|
1337
1372
|
});
|
|
1338
1373
|
const shouldRetryResponse = (response) => response.status >= 500 && response.status <= 599;
|
|
@@ -1398,27 +1433,68 @@ const logUpstreamLifecycle = (requestId, message) => {
|
|
|
1398
1433
|
}
|
|
1399
1434
|
log.debug(message);
|
|
1400
1435
|
};
|
|
1436
|
+
const waitWithSignal = (pending, signal) => {
|
|
1437
|
+
if (!signal) return pending;
|
|
1438
|
+
if (signal.aborted) {
|
|
1439
|
+
pending.catch(() => {});
|
|
1440
|
+
return Promise.reject(signal.reason);
|
|
1441
|
+
}
|
|
1442
|
+
return new Promise((resolve$1, reject) => {
|
|
1443
|
+
const abort = () => reject(signal.reason);
|
|
1444
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1445
|
+
pending.then(resolve$1, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
1446
|
+
});
|
|
1447
|
+
};
|
|
1448
|
+
const isAuthRejection = async (response, signal) => {
|
|
1449
|
+
if (response.status === 401) return true;
|
|
1450
|
+
if (response.status !== 403) return false;
|
|
1451
|
+
const reader = response.clone().body?.getReader();
|
|
1452
|
+
if (!reader) return false;
|
|
1453
|
+
const decoder = new TextDecoder();
|
|
1454
|
+
let text = "";
|
|
1455
|
+
let size = 0;
|
|
1456
|
+
try {
|
|
1457
|
+
for (;;) {
|
|
1458
|
+
const chunk = await waitWithSignal(reader.read(), signal);
|
|
1459
|
+
if (chunk.done) return (text + decoder.decode()).trim().toLowerCase() === "forbidden";
|
|
1460
|
+
size += chunk.value.byteLength;
|
|
1461
|
+
if (size > 128) return false;
|
|
1462
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
1463
|
+
}
|
|
1464
|
+
} catch (error) {
|
|
1465
|
+
if (signal?.aborted) throw error;
|
|
1466
|
+
return false;
|
|
1467
|
+
} finally {
|
|
1468
|
+
reader.cancel().catch(() => {});
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1401
1471
|
const fetchCopilot = async (provider, path$1, init, options = {}) => {
|
|
1402
1472
|
if (!provider.token) throw new ProxyNotImplementedError("Copilot token is not configured for copilot-relay.");
|
|
1403
1473
|
const timeoutMs = options.timeoutMs ?? copilotRequestTimeoutMs;
|
|
1404
1474
|
const signal = createCopilotRequestSignal(options.signal, timeoutMs);
|
|
1405
1475
|
let lastError;
|
|
1406
|
-
|
|
1476
|
+
let transientRetries = 0;
|
|
1477
|
+
let authRecoveryUsed = false;
|
|
1478
|
+
for (let attempt = 1; attempt <= maxFetchAttempts + 1; attempt++) {
|
|
1407
1479
|
const upstreamRequestId = randomUUID();
|
|
1480
|
+
const attemptedToken = provider.token;
|
|
1481
|
+
const attemptedGeneration = provider.tokenGeneration ?? 0;
|
|
1482
|
+
let response;
|
|
1408
1483
|
try {
|
|
1409
1484
|
const started = performance.now();
|
|
1410
1485
|
logUpstreamLifecycle(options.requestId, `send upstream method=${init.method ?? "GET"} path=${path$1} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
|
|
1411
|
-
|
|
1486
|
+
response = await fetch$1(`${provider.baseUrl}${path$1}`, {
|
|
1412
1487
|
...init,
|
|
1413
|
-
headers: buildHeaders(
|
|
1488
|
+
headers: buildHeaders({
|
|
1489
|
+
...provider,
|
|
1490
|
+
token: attemptedToken
|
|
1491
|
+
}, init, options, upstreamRequestId),
|
|
1414
1492
|
dispatcher: copilotDispatcher,
|
|
1415
1493
|
signal
|
|
1416
1494
|
});
|
|
1417
1495
|
const ms = Math.round(performance.now() - started);
|
|
1418
1496
|
logUpstreamLifecycle(options.requestId, `return from upstream method=${init.method ?? "GET"} path=${path$1} status=${response.status} ms=${ms} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
|
|
1419
1497
|
log.debug(`${formatRequestId(options.requestId)}Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt}) upstream_request_id=${upstreamRequestId}`);
|
|
1420
|
-
if (!shouldRetryResponse(response) || attempt === maxFetchAttempts) return response;
|
|
1421
|
-
log.error(`${formatRequestId(options.requestId)}Copilot ${path$1} returned ${response.status}; retrying (${attempt}/${maxFetchAttempts}) upstream_request_id=${upstreamRequestId}`);
|
|
1422
1498
|
} catch (error) {
|
|
1423
1499
|
const abortError = toCopilotAbortHTTPError(error, signal, timeoutMs);
|
|
1424
1500
|
if (abortError) {
|
|
@@ -1427,12 +1503,37 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
|
|
|
1427
1503
|
}
|
|
1428
1504
|
logUpstreamLifecycle(options.requestId, `upstream failed method=${init.method ?? "GET"} path=${path$1} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
|
|
1429
1505
|
lastError = error;
|
|
1430
|
-
if (
|
|
1506
|
+
if (transientRetries >= maxFetchAttempts - 1) {
|
|
1431
1507
|
log.error(`${formatRequestId(options.requestId)}Copilot ${path$1} request failed after ${attempt} attempts upstream_request_id=${upstreamRequestId}`, error);
|
|
1432
1508
|
throw error;
|
|
1433
1509
|
}
|
|
1434
|
-
|
|
1510
|
+
transientRetries++;
|
|
1511
|
+
log.error(`${formatRequestId(options.requestId)}Copilot ${path$1} request failed; retrying (${transientRetries}/${maxFetchAttempts}) upstream_request_id=${upstreamRequestId}`, error);
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
try {
|
|
1515
|
+
signal?.throwIfAborted();
|
|
1516
|
+
if (!authRecoveryUsed && provider.refreshToken && await isAuthRejection(response, signal)) {
|
|
1517
|
+
authRecoveryUsed = true;
|
|
1518
|
+
log.info(`${formatRequestId(options.requestId)}Copilot ${path$1} authentication rejected status=${response.status}; refreshing token`);
|
|
1519
|
+
await response.body?.cancel();
|
|
1520
|
+
signal?.throwIfAborted();
|
|
1521
|
+
await waitWithSignal(provider.refreshToken(attemptedToken, attemptedGeneration), signal);
|
|
1522
|
+
signal?.throwIfAborted();
|
|
1523
|
+
log.info(`${formatRequestId(options.requestId)}Copilot token refresh completed; retrying ${path$1}`);
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
} catch (error) {
|
|
1527
|
+
response.body?.cancel().catch(() => {});
|
|
1528
|
+
const abortError = toCopilotAbortHTTPError(error, signal, timeoutMs);
|
|
1529
|
+
if (abortError) throw abortError;
|
|
1530
|
+
log.error(`${formatRequestId(options.requestId)}Copilot token recovery failed; request not replayed`);
|
|
1531
|
+
throw new Error("Copilot token recovery failed; request not replayed.");
|
|
1435
1532
|
}
|
|
1533
|
+
if (!shouldRetryResponse(response) || transientRetries >= maxFetchAttempts - 1) return response;
|
|
1534
|
+
transientRetries++;
|
|
1535
|
+
log.error(`${formatRequestId(options.requestId)}Copilot ${path$1} returned ${response.status}; retrying (${transientRetries}/${maxFetchAttempts}) upstream_request_id=${upstreamRequestId}`);
|
|
1536
|
+
await response.body?.cancel();
|
|
1436
1537
|
}
|
|
1437
1538
|
throw lastError;
|
|
1438
1539
|
};
|
|
@@ -1981,7 +2082,8 @@ function translateTools(tools) {
|
|
|
1981
2082
|
type: "function",
|
|
1982
2083
|
name: tool.function.name,
|
|
1983
2084
|
description: tool.function.description,
|
|
1984
|
-
parameters: normalizeResponsesToolSchema(tool.function.parameters)
|
|
2085
|
+
parameters: normalizeResponsesToolSchema(tool.function.parameters),
|
|
2086
|
+
strict: false
|
|
1985
2087
|
}));
|
|
1986
2088
|
}
|
|
1987
2089
|
function translateToolChoice(toolChoice) {
|
|
@@ -2861,6 +2963,36 @@ const createFailedSearchExecution = (payload, requestedQuery, model, message) =>
|
|
|
2861
2963
|
results: [],
|
|
2862
2964
|
text: message
|
|
2863
2965
|
});
|
|
2966
|
+
const getSearchFailureCategory = (status$1) => {
|
|
2967
|
+
if (status$1 === 503) return "service unavailable";
|
|
2968
|
+
if (status$1 >= 500) return "server failure";
|
|
2969
|
+
if (status$1 === 429) return "rate limited";
|
|
2970
|
+
if (status$1 === 401) return "authentication rejected";
|
|
2971
|
+
if (status$1 === 403) return "access denied";
|
|
2972
|
+
return "request failed";
|
|
2973
|
+
};
|
|
2974
|
+
const getSearchFailureDetail = (body, token) => {
|
|
2975
|
+
let detail = body.trim();
|
|
2976
|
+
try {
|
|
2977
|
+
const parsed = JSON.parse(detail);
|
|
2978
|
+
const error = isRecord(parsed) && isRecord(parsed.error) ? parsed.error : void 0;
|
|
2979
|
+
detail = typeof error?.message === "string" ? error.message : typeof error?.code === "string" ? error.code : "";
|
|
2980
|
+
} catch {
|
|
2981
|
+
if (/^[{[<]/.test(detail)) return "";
|
|
2982
|
+
}
|
|
2983
|
+
detail = scrubSensitiveUrls(sanitizeTerminalString(detail));
|
|
2984
|
+
detail = detail.replace(/https?:\/\/[^\s'"`<>]+/gi, (raw) => {
|
|
2985
|
+
try {
|
|
2986
|
+
const url = new URL(raw);
|
|
2987
|
+
return url.username || url.password || url.search || url.hash ? `${url.origin}/[redacted]` : raw;
|
|
2988
|
+
} catch {
|
|
2989
|
+
return "[redacted]";
|
|
2990
|
+
}
|
|
2991
|
+
});
|
|
2992
|
+
if (token) detail = detail.replaceAll(token, "[redacted]");
|
|
2993
|
+
if (/\b(?:authorization|bearer|api[_ -]?key|password|secret|access[_ -]?token|refresh[_ -]?token)\b/i.test(detail) || /\btoken\s*[=:]/i.test(detail) || /\b(?:gh[pousr]_|github_pat_|sk-|eyJ)[A-Za-z0-9_-]+/.test(detail) || /\b(?:request|payload|prompt|messages|input|headers|conversation)\b["']?\s*[:=]/i.test(detail)) return "";
|
|
2994
|
+
return detail.trim().slice(0, 240);
|
|
2995
|
+
};
|
|
2864
2996
|
const createClaudeWebSearchExecution = async (config, payload, requestedQuery, options = {}) => {
|
|
2865
2997
|
const backendModel = getWebSearchBackendModel(config);
|
|
2866
2998
|
const signal = createCopilotRequestSignal(options.signal, options.timeoutMs);
|
|
@@ -2878,8 +3010,8 @@ const createClaudeWebSearchExecution = async (config, payload, requestedQuery, o
|
|
|
2878
3010
|
timeoutMs: options.timeoutMs
|
|
2879
3011
|
});
|
|
2880
3012
|
if (!response.ok) {
|
|
2881
|
-
const detail = await readCopilotText(response, signal, options.timeoutMs).catch(() => "");
|
|
2882
|
-
return createFailedSearchExecution(payload, requestedQuery, backendModel, [`Copilot web search
|
|
3013
|
+
const detail = getSearchFailureDetail(await readCopilotText(response, signal, options.timeoutMs).catch(() => ""), config.copilotToken);
|
|
3014
|
+
return createFailedSearchExecution(payload, requestedQuery, backendModel, [`Copilot web search upstream ${getSearchFailureCategory(response.status)} (HTTP ${response.status}; model ${backendModel}).`, detail ? `Upstream response: ${detail}` : ""].filter(Boolean).join("\n"));
|
|
2883
3015
|
}
|
|
2884
3016
|
const upstream = await readCopilotJson(response, signal, options.timeoutMs);
|
|
2885
3017
|
const text = getResponseText(upstream);
|