Files
openclaw/extensions/github-copilot/token.ts
2026-04-28 05:21:57 +01:00

170 lines
5.4 KiB
TypeScript

import path from "node:path";
import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store";
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-model-shared";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
const COPILOT_EDITOR_VERSION = "vscode/1.96.2";
const COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7";
const COPILOT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0";
const COPILOT_GITHUB_API_VERSION = "2025-04-01";
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
export type CachedCopilotToken = {
token: string;
expiresAt: number;
updatedAt: number;
};
function buildCopilotIdeHeaders(
params: {
includeApiVersion?: boolean;
} = {},
): Record<string, string> {
return {
"Editor-Version": COPILOT_EDITOR_VERSION,
"Editor-Plugin-Version": COPILOT_EDITOR_PLUGIN_VERSION,
"User-Agent": COPILOT_USER_AGENT,
...(params.includeApiVersion ? { "X-Github-Api-Version": COPILOT_GITHUB_API_VERSION } : {}),
};
}
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 {
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");
}
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 };
}
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;
}
const match = trimmed.match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i);
const proxyEp = match?.[1]?.trim();
if (!proxyEp) {
return null;
}
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,
};
}