mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
170 lines
5.4 KiB
TypeScript
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,
|
|
};
|
|
}
|