import path from
"node:path";
import { resolveStateDir } from
"../config/paths.js";
import { loadJsonFile, saveJsonFile } from
"../infra/json-file.js";
import { normalizeLowercaseStringOrEmpty } from
"../shared/string-coerce.js";
import { buildCopilotIdeHeaders } from
"./copilot-dynamic-headers.js";
import { resolveProviderEndpoint } from
"./provider-attribution.js";
const COPILOT_TOKEN_URL =
"https://api.github.com/copilot_internal/v2/token";
export type CachedCopilotToken = {
token: string;
/** milliseconds since epoch */
expiresAt: number;
/** milliseconds since epoch */
updatedAt: number;
};
function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) {
return path.join(resolveStateDir(env),
"credentials",
"github-copilot.token.json");
}
function isTokenUsable(cache: CachedCopilotToken, now = Date.now()):
boolean {
// Keep a small safety margin when checking expiry.
return cache.expiresAt - now >
5 *
60 *
1000;
}
function parseCopilotTokenResponse(value: unknown): {
token: string;
expiresAt: number;
} {
if (!value ||
typeof value !==
"object") {
throw new Error(
"Unexpected response from GitHub Copilot token endpoint");
}
const asRecord = value as Record<string, unknown>;
const token = asRecord.token;
const expiresAt = asRecord.expires_at;
if (
typeof token !==
"string" || token.trim().length ===
0) {
throw new Error(
"Copilot token response missing token");
}
// GitHub returns a unix timestamp (seconds), but we defensively accept ms too.
// Use a 1e11 threshold so large seconds-epoch values are not misread as ms.
let expiresAtMs: number;
if (
typeof expiresAt ===
"number" && Number.isFinite(expiresAt)) {
expiresAtMs = expiresAt <
100_
000_
000_
000 ? expiresAt *
1000 : expiresAt;
}
else if (
typeof expiresAt ===
"string" && expiresAt.trim().length >
0) {
const parsed = Number.parseInt(expiresAt,
10);
if (!Number.isFinite(parsed)) {
throw new Error("Copilot token response has invalid expires_at");
}
expiresAtMs = parsed <
100_
000_
000_
000 ? parsed *
1000 : parsed;
}
else {
throw new Error(
"Copilot token response missing expires_at");
}
return { token, expiresAt: expiresAtMs };
}
export
const DEFAULT_COPILOT_API_BASE_URL =
"https://api.individual.githubcopilot.com";
function resolveCopilotProxyHost(proxyEp: string): string |
null {
const trimmed = proxyEp.trim();
if (!trimmed) {
return null;
}
const urlText = /^https?:\/\
//i.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const url =
new URL(urlText);
if (url.protocol !==
"http:" && url.protocol !==
"https:") {
return null;
}
return normalizeLowercaseStringOrEmpty(url.hostname);
}
catch {
return null;
}
}
export
function deriveCopilotApiBaseUrlFromToken(token: string): string |
null {
const trimmed = token.trim();
if (!trimmed) {
return null;
}
// The token returned from the Copilot token endpoint is a semicolon-delimited
// set of key/value pairs. One of them is `proxy-ep=...`.
const match = trimmed.match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i);
const proxyEp = match?.[
1]?.trim();
if (!proxyEp) {
return null;
}
// pi-ai expects converting proxy.* -> api.*
// (see upstream getGitHubCopilotBaseUrl).
const proxyHost = resolveCopilotProxyHost(proxyEp);
if (!proxyHost) {
return null;
}
const host = proxyHost.replace(/^proxy\./i,
"api.");
const baseUrl = `https:
//${host}`;
return resolveProviderEndpoint(baseUrl).endpointClass ===
"invalid" ?
null : baseUrl;
}
export async
function resolveCopilotApiToken(params: {
githubToken: string;
env?: NodeJS.ProcessEnv;
fetchImpl?:
typeof fetch;
cachePath?: string;
loadJsonFileImpl?: (path: string) => unknown;
saveJsonFileImpl?: (path: string, value: CachedCopilotToken) =>
void;
}): Promise<{
token: string;
expiresAt: number;
source: string;
baseUrl: string;
}> {
const env = params.env ?? process.env;
const cachePath = params.cachePath?.trim() || resolveCopilotTokenCachePath(env);
const loadJsonFileFn = params.loadJsonFileImpl ?? loadJsonFile;
const saveJsonFileFn = params.saveJsonFileImpl ?? saveJsonFile;
const cached = loadJsonFileFn(cachePath) as CachedCopilotToken | undefined;
if (cached &&
typeof cached.token ===
"string" &&
typeof cached.expiresAt ===
"number") {
if (isTokenUsable(cached)) {
return {
token: cached.token,
expiresAt: cached.expiresAt,
source: `cache:${cachePath}`,
baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? DEFAULT_COPILOT_API_BASE
_URL,
};
}
}
const fetchImpl = params.fetchImpl ?? fetch;
const res = await fetchImpl(COPILOT_TOKEN_URL, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.githubToken}`,
...buildCopilotIdeHeaders({ includeApiVersion: true }),
},
});
if (!res.ok) {
throw new Error(`Copilot token exchange failed: HTTP ${res.status}`);
}
const json = parseCopilotTokenResponse(await res.json());
const payload: CachedCopilotToken = {
token: json.token,
expiresAt: json.expiresAt,
updatedAt: Date.now(),
};
saveJsonFileFn(cachePath, payload);
return {
token: payload.token,
expiresAt: payload.expiresAt,
source: `fetched:${COPILOT_TOKEN_URL}`,
baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL,
};
}