From 392cd888a87db9de8656614f978be821aedf5d8e Mon Sep 17 00:00:00 2001 From: Nelson Pires Date: Tue, 3 Feb 2026 08:03:27 -0300 Subject: [PATCH] Add support for GitHub Copilot quota provider (#271) * feat(quota): add github-copilot to QuotaProviderId type * feat(quota): add GitHub Copilot provider metadata * feat(quota): add premium_interactions window label mapping * feat(quota): implement GitHub Copilot quota provider for web * feat(quota): implement GitHub Copilot quota provider for VS Code * feat(quota): implement GitHub Copilot quota provider for desktop --- .../desktop/src-tauri/src/quota_providers.rs | 144 ++++++++++++++++++ packages/ui/src/lib/quota/providers/index.ts | 3 +- packages/ui/src/lib/quota/utils.ts | 1 + packages/ui/src/types/quota.ts | 2 +- packages/vscode/src/quotaProviders.ts | 124 +++++++++++++++ packages/web/server/lib/quota-providers.js | 108 +++++++++++++ 6 files changed, 380 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src-tauri/src/quota_providers.rs b/packages/desktop/src-tauri/src/quota_providers.rs index 7c01d398..a6118fb1 100644 --- a/packages/desktop/src-tauri/src/quota_providers.rs +++ b/packages/desktop/src-tauri/src/quota_providers.rs @@ -258,6 +258,14 @@ pub async fn list_configured_quota_providers() -> Result> { } } + let github_copilot_auth = + normalize_auth_entry(get_auth_entry(&auth, &["github-copilot"])); + if let Some(entry) = github_copilot_auth { + if entry.access.is_some() || entry.token.is_some() { + configured.insert("github-copilot".to_string()); + } + } + if has_antigravity_accounts().await { configured.insert("google".to_string()); } @@ -769,11 +777,147 @@ async fn fetch_zai_quota(client: &Client) -> Result { )) } +async fn fetch_github_copilot_quota(client: &Client) -> Result { + let auth = load_auth_map().await?; + let entry = normalize_auth_entry(get_auth_entry(&auth, &["github-copilot"])); + let access_token = entry + .as_ref() + .and_then(|entry| entry.access.clone().or(entry.token.clone())); + + let Some(access_token) = access_token else { + return Ok(build_result( + "github-copilot", + "GitHub Copilot", + false, + false, + None, + Some("Not configured".to_string()), + )); + }; + + let response = client + .get("https://api.github.com/copilot_internal/user") + .bearer_auth(access_token) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "OpenChamber") + .send() + .await; + + let response = match response { + Ok(resp) => resp, + Err(err) => { + return Ok(build_result( + "github-copilot", + "GitHub Copilot", + false, + true, + None, + Some(err.to_string()), + )) + } + }; + + if !response.status().is_success() { + return Ok(build_result( + "github-copilot", + "GitHub Copilot", + false, + true, + None, + Some(format!("API error: {}", response.status().as_u16())), + )); + } + + let payload: Value = match response.json().await { + Ok(value) => value, + Err(err) => { + return Ok(build_result( + "github-copilot", + "GitHub Copilot", + false, + true, + None, + Some(err.to_string()), + )) + } + }; + + // Parse reset date + let mut reset_at: Option = None; + let reset_date_utc = payload + .get("quota_reset_date_utc") + .and_then(|v| v.as_str()); + let reset_date = payload + .get("quota_reset_date") + .and_then(|v| v.as_str()); + + if let Some(date_str) = reset_date_utc { + if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) { + reset_at = Some(dt.timestamp_millis()); + } + } else if let Some(date_str) = reset_date { + // Use the date as UTC midnight + let full_date = format!("{}T00:00:00Z", date_str); + if let Ok(dt) = DateTime::parse_from_rfc3339(&full_date) { + reset_at = Some(dt.timestamp_millis()); + } + } + + let mut windows: HashMap = HashMap::new(); + + // Get premium_interactions snapshot + if let Some(snapshots) = payload.get("quota_snapshots") { + if let Some(premium) = snapshots.get("premium_interactions") { + let mut used_percent: Option = None; + + let unlimited = premium + .get("unlimited") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !unlimited { + if let Some(percent_remaining) = premium.get("percent_remaining").and_then(|v| v.as_f64()) { + used_percent = Some(100.0 - percent_remaining); + } else if let Some(entitlement) = premium.get("entitlement").and_then(|v| v.as_f64()) { + if entitlement > 0.0 { + let remaining = premium + .get("remaining") + .and_then(|v| v.as_f64()) + .or_else(|| premium.get("quota_remaining").and_then(|v| v.as_f64())); + + if let Some(rem) = remaining { + used_percent = Some(((entitlement - rem) / entitlement) * 100.0); + } + } + } + } + + windows.insert( + "premium_interactions".to_string(), + to_usage_window(used_percent, None, reset_at), + ); + } + } + + Ok(build_result( + "github-copilot", + "GitHub Copilot", + true, + true, + Some(ProviderUsage { + windows, + models: None, + }), + None, + )) +} + pub async fn fetch_quota_for_provider(client: &Client, provider_id: &str) -> Result { match provider_id { "openai" => fetch_openai_quota(client).await, "google" => fetch_google_quota(client).await, "zai-coding-plan" => fetch_zai_quota(client).await, + "github-copilot" => fetch_github_copilot_quota(client).await, _ => Ok(build_result( provider_id, provider_id, diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 9094f6f2..f649c9c1 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -8,7 +8,8 @@ export interface QuotaProviderMeta { export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'openai', name: 'OpenAI' }, { id: 'google', name: 'Google' }, - { id: 'zai-coding-plan', name: 'z.ai' } + { id: 'zai-coding-plan', name: 'z.ai' }, + { id: 'github-copilot', name: 'GitHub Copilot' } ]; export const QUOTA_PROVIDER_MAP = QUOTA_PROVIDERS.reduce>( diff --git a/packages/ui/src/lib/quota/utils.ts b/packages/ui/src/lib/quota/utils.ts index abf4b0d7..52e04a32 100644 --- a/packages/ui/src/lib/quota/utils.ts +++ b/packages/ui/src/lib/quota/utils.ts @@ -29,5 +29,6 @@ export const resolveUsageTone = (percent: number | null): 'safe' | 'warn' | 'cri export const formatWindowLabel = (label: string): string => { if (label === '5h') return '5-Hour Limit'; if (label === 'weekly') return 'Weekly Limit'; + if (label === 'premium_interactions') return 'Premium interactions'; return label; }; diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 616912df..b58ebfcf 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -1,4 +1,4 @@ -export type QuotaProviderId = 'openai' | 'google' | 'zai-coding-plan'; +export type QuotaProviderId = 'openai' | 'google' | 'zai-coding-plan' | 'github-copilot'; export interface UsageWindow { usedPercent: number | null; diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index fe525917..000b84ff 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -235,6 +235,11 @@ export const listConfiguredQuotaProviders = () => { configured.add('zai-coding-plan'); } + const githubCopilotAuth = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot'])); + if (githubCopilotAuth && ((githubCopilotAuth as Record).access || (githubCopilotAuth as Record).token)) { + configured.add('github-copilot'); + } + for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) { const data = readJsonFile(filePath); const accounts = data?.accounts; @@ -590,6 +595,123 @@ export const fetchZaiQuota = async (): Promise => { } }; +type CopilotSnapshot = { + unlimited?: boolean; + percent_remaining?: number; + entitlement?: number; + remaining?: number; + quota_remaining?: number; +}; + +type CopilotPayload = { + quota_snapshots?: { + premium_interactions?: CopilotSnapshot; + }; + quota_reset_date_utc?: string; + quota_reset_date?: string; +}; + +export const fetchGitHubCopilotQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot'])) as Record | null; + const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined); + + if (!accessToken) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + try { + const response = await fetch('https://api.github.com/copilot_internal/user', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'OpenChamber', + }, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: true, + error: `API error: ${response.status}`, + }); + } + + const payload = await response.json() as CopilotPayload; + const snapshots = payload?.quota_snapshots ?? {}; + const premiumInteractions = snapshots?.premium_interactions ?? null; + + // Parse reset date + let resetAt: number | null = null; + const resetDateUtc = payload?.quota_reset_date_utc; + const resetDate = payload?.quota_reset_date; + + if (resetDateUtc) { + resetAt = new Date(resetDateUtc).getTime(); + } else if (resetDate) { + // Use the date as UTC midnight + resetAt = new Date(`${resetDate}T00:00:00Z`).getTime(); + } + + const windows: Record = {}; + + if (premiumInteractions) { + let usedPercent: number | null = null; + + if (premiumInteractions.unlimited === true) { + usedPercent = null; + } else if (typeof premiumInteractions.percent_remaining === 'number') { + usedPercent = 100 - premiumInteractions.percent_remaining; + } else if ( + typeof premiumInteractions.entitlement === 'number' && + premiumInteractions.entitlement > 0 + ) { + const remaining = + typeof premiumInteractions.remaining === 'number' + ? premiumInteractions.remaining + : typeof premiumInteractions.quota_remaining === 'number' + ? premiumInteractions.quota_remaining + : null; + + if (remaining !== null) { + usedPercent = ((premiumInteractions.entitlement - remaining) / premiumInteractions.entitlement) * 100; + } + } + + windows['premium_interactions'] = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt, + }); + } + + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: true, + configured: true, + usage: { windows }, + }); + } catch (error) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed', + }); + } +}; + export const fetchQuotaForProvider = async (providerId: string): Promise => { switch (providerId) { case 'openai': @@ -598,6 +720,8 @@ export const fetchQuotaForProvider = async (providerId: string): Promise { configured.add('zai-coding-plan'); } + const githubCopilotAuth = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot'])); + if (githubCopilotAuth?.access || githubCopilotAuth?.token) { + configured.add('github-copilot'); + } + for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) { const data = readJsonFile(filePath); if (Array.isArray(data?.accounts) && data.accounts.length > 0) { @@ -487,6 +492,107 @@ export const fetchZaiQuota = async () => { } }; +export const fetchGitHubCopilotQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot'])); + const accessToken = entry?.access ?? entry?.token; + + if (!accessToken) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://api.github.com/copilot_internal/user', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'OpenChamber' + } + }); + + if (!response.ok) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const snapshots = payload?.quota_snapshots ?? {}; + const premiumInteractions = snapshots?.premium_interactions ?? null; + + // Parse reset date + let resetAt = null; + const resetDateUtc = payload?.quota_reset_date_utc; + const resetDate = payload?.quota_reset_date; + + if (resetDateUtc) { + resetAt = new Date(resetDateUtc).getTime(); + } else if (resetDate) { + // Use the date as UTC midnight + resetAt = new Date(`${resetDate}T00:00:00Z`).getTime(); + } + + const windows = {}; + + if (premiumInteractions) { + let usedPercent = null; + + if (premiumInteractions.unlimited === true) { + usedPercent = null; + } else if (typeof premiumInteractions.percent_remaining === 'number') { + usedPercent = 100 - premiumInteractions.percent_remaining; + } else if ( + typeof premiumInteractions.entitlement === 'number' && + premiumInteractions.entitlement > 0 + ) { + const remaining = + typeof premiumInteractions.remaining === 'number' + ? premiumInteractions.remaining + : typeof premiumInteractions.quota_remaining === 'number' + ? premiumInteractions.quota_remaining + : null; + + if (remaining !== null) { + usedPercent = ((premiumInteractions.entitlement - remaining) / premiumInteractions.entitlement) * 100; + } + } + + windows['premium_interactions'] = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt + }); + } + + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; + export const fetchQuotaForProvider = async (providerId) => { switch (providerId) { case 'openai': @@ -495,6 +601,8 @@ export const fetchQuotaForProvider = async (providerId) => { return fetchGoogleQuota(); case 'zai-coding-plan': return fetchZaiQuota(); + case 'github-copilot': + return fetchGitHubCopilotQuota(); default: return buildResult({ providerId,