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 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
- await fs.writeFile(paths.copilotTokenPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 384 });
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`, { headers: githubHeaders(githubToken, vsCodeVersion) });
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
- const applyCopilotToken = async () => {
493
- const tokenResponse = await getCopilotToken(githubToken, config.vsCodeVersion);
494
- config.copilotToken = tokenResponse.token;
495
- await writeStoredCopilotToken(tokenResponse);
496
- return tokenResponse.refresh_in;
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
- const nextRefreshIn = await applyCopilotToken();
526
+ await applyCopilotToken();
505
527
  log.info("Refreshed Copilot token");
506
- scheduleCopilotTokenRefresh(nextRefreshIn);
507
- } catch (error) {
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
- scheduleCopilotTokenRefresh(refreshIn);
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: config.copilotToken,
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
- for (let attempt = 1; attempt <= maxFetchAttempts; attempt++) {
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
- const response = await fetch$1(`${provider.baseUrl}${path$1}`, {
1486
+ response = await fetch$1(`${provider.baseUrl}${path$1}`, {
1412
1487
  ...init,
1413
- headers: buildHeaders(provider, init, options, upstreamRequestId),
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 (attempt === maxFetchAttempts) {
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
- log.error(`${formatRequestId(options.requestId)}Copilot ${path$1} request failed; retrying (${attempt}/${maxFetchAttempts}) upstream_request_id=${upstreamRequestId}`, error);
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 is not available for model ${backendModel}.`, detail ? `Upstream response: ${detail}` : ""].filter(Boolean).join("\n"));
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);