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 { 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; 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, }; }