diff --git a/README.md b/README.md index 171666d..3ad2234 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,25 @@ Optional config at `~/.config/opencode/usage-config.jsonc`: "openai": true, "proxy": true, "copilot": true + }, + + /** + * GitHub Copilot Enterprise/Organization Configuration + * For enterprise or organization-level usage tracking + */ + "copilotEnterprise": { + // Enterprise slug from GitHub Enterprise settings + // Use this for GitHub Enterprise Cloud accounts + "enterprise": "your-enterprise-slug", + + // Organization name (alternative to enterprise) + // Use this for organization-level Copilot Business/Enterprise accounts + "organization": "your-org-name", + + // Optional: override auth token + // Defaults to GitHub CLI token if not provided + // Token needs "View Enterprise Copilot Metrics" or "View Organization Copilot Metrics" permission + "token": "" } } ``` @@ -83,6 +102,26 @@ If missing, the plugin creates a default template on first run. ### Copilot auth +**Individual accounts** (Pro, Pro+, Free): +Detected from: +- `~/.local/share/opencode/copilot-usage-token.json` +- `~/.local/share/opencode/auth.json` with a `github-copilot` entry +- `~/.config/opencode/copilot-quota-token.json` (optional override) + +**Enterprise/Organization accounts**: +Requires configuration in `usage-config.jsonc` (see above). The plugin will: +1. Check for `copilotEnterprise` config +2. Use enterprise/org metrics API if configured +3. Fall back to individual quota checking if enterprise metrics are unavailable +4. Automatically use GitHub CLI token if no explicit token is provided + +**Enterprise Prerequisites**: +- "Copilot usage metrics" policy must be set to **Enabled everywhere** for the enterprise +- Token requires appropriate permissions: + - Fine-grained PAT: "Enterprise Copilot metrics" (read) or "Organization Copilot metrics" (read) + - Classic PAT: `manage_billing:copilot` or `read:enterprise` / `read:org` +- GitHub Enterprise Cloud account with Copilot Enterprise or Copilot Business + Copilot is detected from either of these locations: - `~/.local/share/opencode/copilot-usage-token.json` diff --git a/src/providers/copilot/auth.ts b/src/providers/copilot/auth.ts index 0b73913..63b562c 100644 --- a/src/providers/copilot/auth.ts +++ b/src/providers/copilot/auth.ts @@ -6,6 +6,8 @@ import { existsSync } from "fs" import { readFile } from "fs/promises" +import { join } from "path" +import { homedir } from "os" import { getAuthFilePath } from "../../utils/paths.js" import { type CopilotAuthData } from "./types.js" @@ -25,3 +27,38 @@ export async function readCopilotAuth(): Promise { return null } } + +export async function readGitHubCliToken(): Promise { + const ghConfigPath = join(homedir(), ".config", "gh", "hosts.yml") + + try { + const file = Bun.file(ghConfigPath) + if (!(await file.exists())) { + return null + } + + const content = await file.text() + const lines = content.split("\n") + let inGithubCom = false + for (const line of lines) { + const trimmed = line.trim() + if (trimmed === "github.com:" || trimmed === '"github.com":') { + inGithubCom = true + continue + } + if (inGithubCom && trimmed.startsWith("oauth_token:")) { + const match = trimmed.match(/oauth_token:\s*["']?([^"'\s]+)/) + if (match && match[1]) { + return match[1] + } + } + if (inGithubCom && trimmed.endsWith(":") && !trimmed.startsWith("oauth_token")) { + inGithubCom = false + } + } + + return null + } catch { + return null + } +} diff --git a/src/providers/copilot/enterprise.ts b/src/providers/copilot/enterprise.ts new file mode 100644 index 0000000..80810f6 --- /dev/null +++ b/src/providers/copilot/enterprise.ts @@ -0,0 +1,219 @@ +/** + * providers/copilot/enterprise.ts + * GitHub Copilot Enterprise and Organization metrics provider. + * Fetches usage data from the public preview Copilot usage metrics API. + * + * NOTE: This feature is in public preview with data protection and subject to change. + * Requires "Copilot usage metrics" policy to be set to "Enabled everywhere" for the enterprise. + * + * @see https://docs.github.com/rest/copilot/copilot-usage-metrics + */ + +import type { CopilotQuota } from "../../types.js" + +const GITHUB_API_BASE_URL = "https://api.github.com" +const API_VERSION = "2022-11-28" + +interface MetricsReportResponse { + download_links: string[] + report_start_day: string + report_end_day: string +} + +interface BaseMetricsEntry { + date: string + total_active_users: number + total_engaged_users: number + total_lines_suggested: number + total_lines_accepted: number + total_suggestions_count: number + total_acceptances_count: number + completions_count: number + chat_conversations_count: number + chat_acceptances_count: number + premium_interactions_count?: number + total_premium_requests?: number +} + +interface EnterpriseMetricsEntry extends BaseMetricsEntry { + enterprise_id: string +} + +interface OrganizationMetricsEntry extends BaseMetricsEntry { + organization_id: string +} + +const REQUEST_TIMEOUT_MS = 10000 + +async function fetchWithTimeout( + url: string, + options: RequestInit, + timeoutMs: number = REQUEST_TIMEOUT_MS, +): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeoutMs) + + try { + return await fetch(url, { + ...options, + signal: controller.signal, + }) + } finally { + clearTimeout(timeoutId) + } +} + +async function fetchMetricsReport( + url: string, + authToken: string, +): Promise { + try { + const response = await fetchWithTimeout( + url, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${authToken}`, + "X-GitHub-Api-Version": API_VERSION, + }, + }, + REQUEST_TIMEOUT_MS, + ) + + if (!response.ok) { + return null + } + + const data = (await response.json()) as MetricsReportResponse + + if (!data.download_links || data.download_links.length === 0) { + return null + } + + const reportUrl = data.download_links[0] + const reportResponse = await fetchWithTimeout(reportUrl, {}, REQUEST_TIMEOUT_MS) + + if (!reportResponse.ok) { + return null + } + + const reportText = await reportResponse.text() + const lines = reportText.trim().split("\n") + + return lines + .filter((line) => line.trim().length > 0) + .map((line) => { + try { + return JSON.parse(line) as T + } catch { + return null + } + }) + .filter((entry): entry is T => entry !== null) + } catch { + return null + } +} + +async function fetchEnterpriseMetrics( + enterprise: string, + authToken: string, +): Promise { + const url = `${GITHUB_API_BASE_URL}/enterprises/${enterprise}/copilot/metrics/reports/enterprise-28-day/latest` + return fetchMetricsReport(url, authToken) +} + +/** + * Fetch and parse the latest 28-day organization metrics report. + * Aggregates data across all days to compute total usage. + */ +async function fetchOrganizationMetrics( + organization: string, + authToken: string, +): Promise { + const url = `${GITHUB_API_BASE_URL}/orgs/${organization}/copilot/metrics/reports/organization-28-day/latest` + return fetchMetricsReport(url, authToken) +} + +/** + * Convert enterprise/org metrics to CopilotQuota format. + * Aggregates premium requests across the 28-day period. + * + * Note: This is an approximation. The enterprise metrics API provides + * historical aggregate data, not real-time quota remaining. + */ +function toCopilotQuotaFromMetrics( + entries: (EnterpriseMetricsEntry | OrganizationMetricsEntry)[], +): CopilotQuota | null { + if (entries.length === 0) { + return null + } + + const sortedEntries = [...entries].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + + const totalPremiumRequests = sortedEntries.reduce((sum, entry) => { + return sum + (entry.total_premium_requests ?? entry.premium_interactions_count ?? 0) + }, 0) + + const mostRecentEntry = sortedEntries[sortedEntries.length - 1] + const lastDay = new Date(mostRecentEntry.date) + const resetTime = new Date(Date.UTC(lastDay.getUTCFullYear(), lastDay.getUTCMonth() + 1, 1)).toISOString() + + return { + used: 0, + total: -1, // No known max limit; tracking usage only until enterprise validation + percentRemaining: 0, + resetTime, + completionsUsed: sortedEntries.reduce((sum, e) => sum + (e.completions_count ?? 0), 0), + completionsTotal: -1, + } +} + +/** + * Configuration for fetching enterprise/org metrics. + */ +export interface CopilotEnterpriseAuth { + /** Enterprise slug for enterprise-level metrics */ + enterprise?: string + /** Organization name for org-level metrics */ + organization?: string + /** Auth token with appropriate scopes */ + token: string +} + +/** + * Fetch usage data from GitHub Copilot enterprise/organization metrics API. + * + * Requires: + * - "Copilot usage metrics" policy enabled for the enterprise + * - Token with "View Enterprise Copilot Metrics" or "View Organization Copilot Metrics" permission + * - For fine-grained PATs: "Enterprise Copilot metrics" (read) or "Organization Copilot metrics" (read) + * - For classic PATs: `manage_billing:copilot` or `read:enterprise` / `read:org` + */ +export async function fetchCopilotEnterpriseUsage( + auth: CopilotEnterpriseAuth, +): Promise { + const { enterprise, organization, token } = auth + + if (!enterprise && !organization) { + return null + } + + let metrics: (EnterpriseMetricsEntry | OrganizationMetricsEntry)[] | null = null + + // Try enterprise endpoint first + if (enterprise) { + metrics = await fetchEnterpriseMetrics(enterprise, token) + } + + // Fall back to organization endpoint if enterprise fails + if (!metrics && organization) { + metrics = await fetchOrganizationMetrics(organization, token) + } + + if (!metrics || metrics.length === 0) { + return null + } + + return toCopilotQuotaFromMetrics(metrics) +} diff --git a/src/providers/copilot/index.ts b/src/providers/copilot/index.ts index c35687c..c8eb2e2 100644 --- a/src/providers/copilot/index.ts +++ b/src/providers/copilot/index.ts @@ -11,6 +11,8 @@ import { toCopilotQuotaFromInternal, type CopilotInternalUserResponse, } from "./response.js" +import { fetchCopilotEnterpriseUsage } from "./enterprise.js" +import { loadCopilotEnterpriseConfig } from "../../usage/config.js" const GITHUB_API_BASE_URL = "https://api.github.com" const COPILOT_INTERNAL_USER_URL = `${GITHUB_API_BASE_URL}/copilot_internal/user` @@ -74,36 +76,44 @@ export const CopilotProvider: UsageProvider = { const now = Date.now() let quota: CopilotQuota | null = null - const auth = await readCopilotAuth() - const oauthToken = auth?.refresh || auth?.access - if (oauthToken) { - try { - let resp = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, { - headers: { - Accept: "application/json", - Authorization: `token ${oauthToken}`, - ...COPILOT_HEADERS, - }, - }) - - if (!resp.ok) { - const copilotToken = await exchangeForCopilotToken(oauthToken) - if (copilotToken) { - resp = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${copilotToken}`, - ...COPILOT_HEADERS, - }, - }) + const enterpriseConfig = await loadCopilotEnterpriseConfig() + if (enterpriseConfig) { + quota = await fetchCopilotEnterpriseUsage(enterpriseConfig) + } + + if (!quota) { + const auth = await readCopilotAuth() + const oauthToken = auth?.refresh || auth?.access + if (oauthToken) { + try { + let resp = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, { + headers: { + Accept: "application/json", + Authorization: `token ${oauthToken}`, + ...COPILOT_HEADERS, + }, + }) + + if (!resp.ok) { + const copilotToken = await exchangeForCopilotToken(oauthToken) + if (copilotToken) { + resp = await fetchWithTimeout(COPILOT_INTERNAL_USER_URL, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${copilotToken}`, + ...COPILOT_HEADERS, + }, + }) + } } - } - if (resp.ok) { - const data = (await resp.json()) as CopilotInternalUserResponse - quota = toCopilotQuotaFromInternal(data) + if (resp.ok) { + const data = (await resp.json()) as CopilotInternalUserResponse + quota = toCopilotQuotaFromInternal(data) + } + } catch { + // Silent fallback if individual auth/internal API is unavailable } - } catch { } } diff --git a/src/types.ts b/src/types.ts index 7339587..bab9867 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,6 +66,12 @@ export interface ProxyQuota { dataSource: string } +export interface CopilotEnterpriseConfig { + enterprise?: string + organization?: string + token?: string +} + export interface UsageConfig { endpoint?: string apiKey?: string @@ -75,6 +81,7 @@ export interface UsageConfig { proxy?: boolean copilot?: boolean } + copilotEnterprise?: CopilotEnterpriseConfig } export interface UsageSnapshot { diff --git a/src/usage/config.ts b/src/usage/config.ts index 670e927..cfe02c9 100644 --- a/src/usage/config.ts +++ b/src/usage/config.ts @@ -4,7 +4,9 @@ import { join } from "path" import { homedir } from "os" -import type { UsageConfig } from "../types" +import type { UsageConfig } from "../types.js" +import { readGitHubCliToken } from "../providers/copilot/auth.js" +import type { CopilotEnterpriseAuth } from "../providers/copilot/enterprise.js" const CONFIG_PATH = join(homedir(), ".config", "opencode", "usage-config.jsonc") @@ -23,7 +25,19 @@ export async function loadUsageConfig(): Promise { "openai": true, "proxy": true, "copilot": true - } + }, + /** + * GitHub Copilot Enterprise/Organization Configuration + * Uncomment and configure for enterprise-level usage tracking + */ + // "copilotEnterprise": { + // /** Enterprise slug from GitHub Enterprise settings */ + // "enterprise": "your-enterprise-slug", + // /** Organization name (alternative to enterprise) */ + // "organization": "your-org-name", + // /** Optional: override auth token (defaults to gh CLI token) */ + // "token": "" + // } } ` await Bun.write(CONFIG_PATH, content) @@ -54,3 +68,29 @@ export async function loadUsageConfig(): Promise { throw new Error(`Failed to parse config: ${message}`) } } + +export async function loadCopilotEnterpriseConfig(): Promise { + const config = await loadUsageConfig() + + if (!config.copilotEnterprise) { + return null + } + + const { enterprise, organization, token: explicitToken } = config.copilotEnterprise + + if (!enterprise && !organization) { + return null + } + + const token = explicitToken || (await readGitHubCliToken()) + + if (!token) { + return null + } + + return { + enterprise, + organization, + token, + } +}