From ace0db7b24bd6980b0b0963b15c5b04fb084b813 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Mon, 26 Jan 2026 19:33:41 +0000 Subject: [PATCH 1/4] feat: add GitHub Copilot enterprise/organization metrics support Add enterprise and organization-level usage tracking via GitHub's public preview Copilot Usage Metrics API. Falls back to individual quota checking when enterprise metrics unavailable. - Add CopilotEnterpriseConfig to UsageConfig - Create providers/copilot/enterprise.ts for metrics API - Add loadCopilotEnterpriseConfig() with gh CLI token fallback - Update CopilotProvider to try enterprise path first Configuration via copilotEnterprise in usage-config.jsonc. Requires "Copilot usage metrics" policy enabled in enterprise settings. --- README.md | 39 ++++ src/providers/copilot/enterprise.ts | 310 ++++++++++++++++++++++++++++ src/providers/copilot/index.ts | 63 +++--- src/types.ts | 7 + src/usage/config.ts | 81 +++++++- 5 files changed, 471 insertions(+), 29 deletions(-) create mode 100644 src/providers/copilot/enterprise.ts 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/enterprise.ts b/src/providers/copilot/enterprise.ts new file mode 100644 index 0000000..1ea5447 --- /dev/null +++ b/src/providers/copilot/enterprise.ts @@ -0,0 +1,310 @@ +/** + * 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 EnterpriseMetricsResponse { + download_links: string[] + report_start_day: string + report_end_day: string +} + +interface OrganizationMetricsResponse { + download_links: string[] + report_start_day: string + report_end_day: string +} + +interface EnterpriseMetricsEntry { + /** Enterprise slug */ + enterprise_id: string + /** Date in YYYY-MM-DD format */ + date: string + /** Total number of active users */ + total_active_users: number + /** Total number of engaged users */ + total_engaged_users: number + /** Total lines of code suggested */ + total_lines_suggested: number + /** Total lines of code accepted */ + total_lines_accepted: number + /** Number of code suggestions */ + total_suggestions_count: number + /** Number of accepted suggestions */ + total_acceptances_count: number + /** Number of completions */ + completions_count: number + /** Number of chat conversations */ + chat_conversations_count: number + /** Number of chat acceptances */ + chat_acceptances_count: number + /** Number of premium interactions */ + premium_interactions_count?: number + /** Total number of premium requests */ + total_premium_requests?: number +} + +interface OrganizationMetricsEntry { + /** Organization ID */ + organization_id: string + /** Date in YYYY-MM-DD format */ + date: string + /** Total number of active users */ + total_active_users: number + /** Total number of engaged users */ + total_engaged_users: number + /** Total lines of code suggested */ + total_lines_suggested: number + /** Total lines of code accepted */ + total_lines_accepted: number + /** Number of code suggestions */ + total_suggestions_count: number + /** Number of accepted suggestions */ + total_acceptances_count: number + /** Number of completions */ + completions_count: number + /** Number of chat conversations */ + chat_conversations_count: number + /** Number of chat acceptances */ + chat_acceptances_count: number + /** Number of premium interactions */ + premium_interactions_count?: number + /** Total number of premium requests */ + total_premium_requests?: number +} + +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) + } +} + +/** + * Fetch and parse the latest 28-day enterprise metrics report. + * Aggregates data across all days to compute total usage. + */ +async function fetchEnterpriseMetrics( + enterprise: string, + authToken: string, +): Promise { + const url = `${GITHUB_API_BASE_URL}/enterprises/${enterprise}/copilot/metrics/reports/enterprise-28-day/latest` + + 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 EnterpriseMetricsResponse + + // Fetch the first report link (NDJSON format) + 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 EnterpriseMetricsEntry + } catch { + return null + } + }) + .filter((entry): entry is EnterpriseMetricsEntry => entry !== null) + } catch { + return null + } +} + +/** + * 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` + + 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 OrganizationMetricsResponse + + // Fetch the first report link (NDJSON format) + 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 OrganizationMetricsEntry + } catch { + return null + } + }) + .filter((entry): entry is OrganizationMetricsEntry => entry !== null) + } catch { + return null + } +} + +/** + * 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 + } + + // Aggregate total premium requests across all days + const totalPremiumRequests = entries.reduce((sum, entry) => { + return sum + (entry.total_premium_requests || entry.premium_interactions_count || 0) + }, 0) + + // Get the most recent date for reset time calculation + const mostRecentEntry = entries[entries.length - 1] + const lastDay = new Date(mostRecentEntry.date) + + // Estimate monthly reset (1st of next month) + const resetTime = new Date(Date.UTC(lastDay.getUTCFullYear(), lastDay.getUTCMonth() + 1, 1)).toISOString() + + // For enterprise, we don't have a clear "total quota" from the metrics API + // We use -1 to indicate unlimited/unknown quota + return { + used: totalPremiumRequests, + total: -1, // Enterprise quotas are managed at the org level + percentRemaining: 0, // Cannot determine without quota info + resetTime, + completionsUsed: entries.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..ce64bed 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,43 @@ 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 { } - } 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..3b97043 100644 --- a/src/usage/config.ts +++ b/src/usage/config.ts @@ -4,10 +4,45 @@ import { join } from "path" import { homedir } from "os" -import type { UsageConfig } from "../types" +import type { UsageConfig } from "../types.js" const CONFIG_PATH = join(homedir(), ".config", "opencode", "usage-config.jsonc") +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 + } +} + export async function loadUsageConfig(): Promise { const file = Bun.file(CONFIG_PATH) @@ -23,7 +58,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 +101,33 @@ export async function loadUsageConfig(): Promise { throw new Error(`Failed to parse config: ${message}`) } } + +export async function loadCopilotEnterpriseConfig(): Promise<{ + enterprise?: string + organization?: string + token: string +} | null> { + 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, + } +} From e459054236e7ca392722d5e99e912c0dc0e25033 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Mon, 26 Jan 2026 19:35:48 +0000 Subject: [PATCH 2/4] refactor: move readGitHubCliToken to copilot/auth.ts GitHub-specific token reading logic now lives alongside other Copilot auth helpers instead of in general config layer. --- src/providers/copilot/auth.ts | 37 +++++++++++++++++++++++++++++++++++ src/usage/config.ts | 36 +--------------------------------- 2 files changed, 38 insertions(+), 35 deletions(-) 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/usage/config.ts b/src/usage/config.ts index 3b97043..83e5fb1 100644 --- a/src/usage/config.ts +++ b/src/usage/config.ts @@ -5,44 +5,10 @@ import { join } from "path" import { homedir } from "os" import type { UsageConfig } from "../types.js" +import { readGitHubCliToken } from "../providers/copilot/auth.js" const CONFIG_PATH = join(homedir(), ".config", "opencode", "usage-config.jsonc") -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 - } -} - export async function loadUsageConfig(): Promise { const file = Bun.file(CONFIG_PATH) From 0c898ccb90866aba5c8c5ae6fcf53432511574d9 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Mon, 26 Jan 2026 19:57:54 +0000 Subject: [PATCH 3/4] refactor: address PR review feedback - Consolidated duplicate metrics interfaces and extracted common fetch helper - Added explicit sorting of metrics entries by date - Fixed nullish coalescing for zero totals in aggregation - Aligned enterprise quota semantics (tracking usage only for now) - Reused CopilotEnterpriseAuth type in config layer - Added explanatory comments for empty catch blocks and enterprise limits --- src/providers/copilot/enterprise.ts | 153 ++++++---------------------- src/providers/copilot/index.ts | 1 + src/usage/config.ts | 7 +- 3 files changed, 34 insertions(+), 127 deletions(-) diff --git a/src/providers/copilot/enterprise.ts b/src/providers/copilot/enterprise.ts index 1ea5447..d9b6864 100644 --- a/src/providers/copilot/enterprise.ts +++ b/src/providers/copilot/enterprise.ts @@ -14,74 +14,33 @@ import type { CopilotQuota } from "../../types.js" const GITHUB_API_BASE_URL = "https://api.github.com" const API_VERSION = "2022-11-28" -interface EnterpriseMetricsResponse { +interface MetricsReportResponse { download_links: string[] report_start_day: string report_end_day: string } -interface OrganizationMetricsResponse { - download_links: string[] - report_start_day: string - report_end_day: string -} - -interface EnterpriseMetricsEntry { - /** Enterprise slug */ - enterprise_id: string - /** Date in YYYY-MM-DD format */ +interface BaseMetricsEntry { date: string - /** Total number of active users */ total_active_users: number - /** Total number of engaged users */ total_engaged_users: number - /** Total lines of code suggested */ total_lines_suggested: number - /** Total lines of code accepted */ total_lines_accepted: number - /** Number of code suggestions */ total_suggestions_count: number - /** Number of accepted suggestions */ total_acceptances_count: number - /** Number of completions */ completions_count: number - /** Number of chat conversations */ chat_conversations_count: number - /** Number of chat acceptances */ chat_acceptances_count: number - /** Number of premium interactions */ premium_interactions_count?: number - /** Total number of premium requests */ total_premium_requests?: number } -interface OrganizationMetricsEntry { - /** Organization ID */ +interface EnterpriseMetricsEntry extends BaseMetricsEntry { + enterprise_id: string +} + +interface OrganizationMetricsEntry extends BaseMetricsEntry { organization_id: string - /** Date in YYYY-MM-DD format */ - date: string - /** Total number of active users */ - total_active_users: number - /** Total number of engaged users */ - total_engaged_users: number - /** Total lines of code suggested */ - total_lines_suggested: number - /** Total lines of code accepted */ - total_lines_accepted: number - /** Number of code suggestions */ - total_suggestions_count: number - /** Number of accepted suggestions */ - total_acceptances_count: number - /** Number of completions */ - completions_count: number - /** Number of chat conversations */ - chat_conversations_count: number - /** Number of chat acceptances */ - chat_acceptances_count: number - /** Number of premium interactions */ - premium_interactions_count?: number - /** Total number of premium requests */ - total_premium_requests?: number } const REQUEST_TIMEOUT_MS = 10000 @@ -104,16 +63,10 @@ async function fetchWithTimeout( } } -/** - * Fetch and parse the latest 28-day enterprise metrics report. - * Aggregates data across all days to compute total usage. - */ -async function fetchEnterpriseMetrics( - enterprise: string, +async function fetchMetricsReport( + url: string, authToken: string, -): Promise { - const url = `${GITHUB_API_BASE_URL}/enterprises/${enterprise}/copilot/metrics/reports/enterprise-28-day/latest` - +): Promise { try { const response = await fetchWithTimeout( url, @@ -131,9 +84,8 @@ async function fetchEnterpriseMetrics( return null } - const data = (await response.json()) as EnterpriseMetricsResponse + const data = (await response.json()) as MetricsReportResponse - // Fetch the first report link (NDJSON format) if (!data.download_links || data.download_links.length === 0) { return null } @@ -152,17 +104,25 @@ async function fetchEnterpriseMetrics( .filter((line) => line.trim().length > 0) .map((line) => { try { - return JSON.parse(line) as EnterpriseMetricsEntry + return JSON.parse(line) as T } catch { return null } }) - .filter((entry): entry is EnterpriseMetricsEntry => entry !== 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. @@ -172,54 +132,7 @@ async function fetchOrganizationMetrics( authToken: string, ): Promise { const url = `${GITHUB_API_BASE_URL}/orgs/${organization}/copilot/metrics/reports/organization-28-day/latest` - - 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 OrganizationMetricsResponse - - // Fetch the first report link (NDJSON format) - 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 OrganizationMetricsEntry - } catch { - return null - } - }) - .filter((entry): entry is OrganizationMetricsEntry => entry !== null) - } catch { - return null - } + return fetchMetricsReport(url, authToken) } /** @@ -236,26 +149,22 @@ function toCopilotQuotaFromMetrics( return null } - // Aggregate total premium requests across all days - const totalPremiumRequests = entries.reduce((sum, entry) => { - return sum + (entry.total_premium_requests || entry.premium_interactions_count || 0) + 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) - // Get the most recent date for reset time calculation - const mostRecentEntry = entries[entries.length - 1] + const mostRecentEntry = sortedEntries[sortedEntries.length - 1] const lastDay = new Date(mostRecentEntry.date) - - // Estimate monthly reset (1st of next month) const resetTime = new Date(Date.UTC(lastDay.getUTCFullYear(), lastDay.getUTCMonth() + 1, 1)).toISOString() - // For enterprise, we don't have a clear "total quota" from the metrics API - // We use -1 to indicate unlimited/unknown quota return { - used: totalPremiumRequests, - total: -1, // Enterprise quotas are managed at the org level - percentRemaining: 0, // Cannot determine without quota info + used: 0, + total: -1, // No known max limit; tracking usage only until enterprise validation + percentRemaining: 0, resetTime, - completionsUsed: entries.reduce((sum, e) => sum + (e.completions_count || 0), 0), + completionsUsed: sortedEntries.reduce((sum, e) => e.completions_count ?? 0, 0), completionsTotal: -1, } } diff --git a/src/providers/copilot/index.ts b/src/providers/copilot/index.ts index ce64bed..c8eb2e2 100644 --- a/src/providers/copilot/index.ts +++ b/src/providers/copilot/index.ts @@ -112,6 +112,7 @@ export const CopilotProvider: UsageProvider = { quota = toCopilotQuotaFromInternal(data) } } catch { + // Silent fallback if individual auth/internal API is unavailable } } } diff --git a/src/usage/config.ts b/src/usage/config.ts index 83e5fb1..cfe02c9 100644 --- a/src/usage/config.ts +++ b/src/usage/config.ts @@ -6,6 +6,7 @@ import { join } from "path" import { homedir } from "os" 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") @@ -68,11 +69,7 @@ export async function loadUsageConfig(): Promise { } } -export async function loadCopilotEnterpriseConfig(): Promise<{ - enterprise?: string - organization?: string - token: string -} | null> { +export async function loadCopilotEnterpriseConfig(): Promise { const config = await loadUsageConfig() if (!config.copilotEnterprise) { From a480ffe24511fa9aa7eb3aafd013de423928ab29 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Mon, 26 Jan 2026 20:03:14 +0000 Subject: [PATCH 4/4] fix: correctly aggregate completions usage in enterprise metrics The reducer was ignoring the accumulator and only returning the last entry's count. Fixed to sum all counts over the 28-day period. --- src/providers/copilot/enterprise.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/copilot/enterprise.ts b/src/providers/copilot/enterprise.ts index d9b6864..80810f6 100644 --- a/src/providers/copilot/enterprise.ts +++ b/src/providers/copilot/enterprise.ts @@ -164,7 +164,7 @@ function toCopilotQuotaFromMetrics( total: -1, // No known max limit; tracking usage only until enterprise validation percentRemaining: 0, resetTime, - completionsUsed: sortedEntries.reduce((sum, e) => e.completions_count ?? 0, 0), + completionsUsed: sortedEntries.reduce((sum, e) => sum + (e.completions_count ?? 0), 0), completionsTotal: -1, } }