Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": ""
}
}
```
Expand All @@ -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`
Expand Down
37 changes: 37 additions & 0 deletions src/providers/copilot/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -25,3 +27,38 @@ export async function readCopilotAuth(): Promise<CopilotAuthData | null> {
return null
}
}

export async function readGitHubCliToken(): Promise<string | null> {
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
}
}
Comment on lines +31 to +64
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The readGitHubCliToken function manually parses the hosts.yml file by splitting it into lines and using a state machine. This approach is fragile and can easily break if the YAML file's format changes (e.g., different indentation, comments, or key ordering). A more robust solution would be to use a dedicated YAML parsing library (like js-yaml or yaml) to handle the file. This would make the code simpler and less prone to errors. For example: const config = yaml.parse(fileContent); const token = config?.['github.com']?.oauth_token;

219 changes: 219 additions & 0 deletions src/providers/copilot/enterprise.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<T extends BaseMetricsEntry>(
url: string,
authToken: string,
): Promise<T[] | null> {
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<EnterpriseMetricsEntry[] | null> {
const url = `${GITHUB_API_BASE_URL}/enterprises/${enterprise}/copilot/metrics/reports/enterprise-28-day/latest`
return fetchMetricsReport<EnterpriseMetricsEntry>(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<OrganizationMetricsEntry[] | null> {
const url = `${GITHUB_API_BASE_URL}/orgs/${organization}/copilot/metrics/reports/organization-28-day/latest`
return fetchMetricsReport<OrganizationMetricsEntry>(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,
Comment on lines 166 to 168

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fix completions aggregation in enterprise metrics

The completionsUsed reducer ignores the accumulator and returns only the current entry’s completions_count, so the final value reflects just the last day in the report instead of the 28‑day total. This underreports usage whenever there is more than one day of data. If the intent is to show aggregate completions, this should sum with the accumulator (e.g., sum + (e.completions_count ?? 0)).

Useful? React with 👍 / 👎.

}
}

/**
* 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<CopilotQuota | null> {
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)
}
Loading