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
This commit is contained in:
@@ -258,6 +258,14 @@ pub async fn list_configured_quota_providers() -> Result<Vec<String>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
if has_antigravity_accounts().await {
|
||||||
configured.insert("google".to_string());
|
configured.insert("google".to_string());
|
||||||
}
|
}
|
||||||
@@ -769,11 +777,147 @@ async fn fetch_zai_quota(client: &Client) -> Result<ProviderResult> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn fetch_github_copilot_quota(client: &Client) -> Result<ProviderResult> {
|
||||||
|
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<i64> = 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<String, UsageWindow> = 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<f64> = 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<ProviderResult> {
|
pub async fn fetch_quota_for_provider(client: &Client, provider_id: &str) -> Result<ProviderResult> {
|
||||||
match provider_id {
|
match provider_id {
|
||||||
"openai" => fetch_openai_quota(client).await,
|
"openai" => fetch_openai_quota(client).await,
|
||||||
"google" => fetch_google_quota(client).await,
|
"google" => fetch_google_quota(client).await,
|
||||||
"zai-coding-plan" => fetch_zai_quota(client).await,
|
"zai-coding-plan" => fetch_zai_quota(client).await,
|
||||||
|
"github-copilot" => fetch_github_copilot_quota(client).await,
|
||||||
_ => Ok(build_result(
|
_ => Ok(build_result(
|
||||||
provider_id,
|
provider_id,
|
||||||
provider_id,
|
provider_id,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export interface QuotaProviderMeta {
|
|||||||
export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
|
export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
|
||||||
{ id: 'openai', name: 'OpenAI' },
|
{ id: 'openai', name: 'OpenAI' },
|
||||||
{ id: 'google', name: 'Google' },
|
{ 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<Record<string, QuotaProviderMeta>>(
|
export const QUOTA_PROVIDER_MAP = QUOTA_PROVIDERS.reduce<Record<string, QuotaProviderMeta>>(
|
||||||
|
|||||||
@@ -29,5 +29,6 @@ export const resolveUsageTone = (percent: number | null): 'safe' | 'warn' | 'cri
|
|||||||
export const formatWindowLabel = (label: string): string => {
|
export const formatWindowLabel = (label: string): string => {
|
||||||
if (label === '5h') return '5-Hour Limit';
|
if (label === '5h') return '5-Hour Limit';
|
||||||
if (label === 'weekly') return 'Weekly Limit';
|
if (label === 'weekly') return 'Weekly Limit';
|
||||||
|
if (label === 'premium_interactions') return 'Premium interactions';
|
||||||
return label;
|
return label;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
export interface UsageWindow {
|
||||||
usedPercent: number | null;
|
usedPercent: number | null;
|
||||||
|
|||||||
@@ -235,6 +235,11 @@ export const listConfiguredQuotaProviders = () => {
|
|||||||
configured.add('zai-coding-plan');
|
configured.add('zai-coding-plan');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const githubCopilotAuth = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot']));
|
||||||
|
if (githubCopilotAuth && ((githubCopilotAuth as Record<string, unknown>).access || (githubCopilotAuth as Record<string, unknown>).token)) {
|
||||||
|
configured.add('github-copilot');
|
||||||
|
}
|
||||||
|
|
||||||
for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) {
|
for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) {
|
||||||
const data = readJsonFile(filePath);
|
const data = readJsonFile(filePath);
|
||||||
const accounts = data?.accounts;
|
const accounts = data?.accounts;
|
||||||
@@ -590,6 +595,123 @@ export const fetchZaiQuota = async (): Promise<ProviderResult> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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<ProviderResult> => {
|
||||||
|
const auth = readAuthFile();
|
||||||
|
const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot'])) as Record<string, unknown> | 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<string, UsageWindow> = {};
|
||||||
|
|
||||||
|
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<ProviderResult> => {
|
export const fetchQuotaForProvider = async (providerId: string): Promise<ProviderResult> => {
|
||||||
switch (providerId) {
|
switch (providerId) {
|
||||||
case 'openai':
|
case 'openai':
|
||||||
@@ -598,6 +720,8 @@ export const fetchQuotaForProvider = async (providerId: string): Promise<Provide
|
|||||||
return fetchGoogleQuota();
|
return fetchGoogleQuota();
|
||||||
case 'zai-coding-plan':
|
case 'zai-coding-plan':
|
||||||
return fetchZaiQuota();
|
return fetchZaiQuota();
|
||||||
|
case 'github-copilot':
|
||||||
|
return fetchGitHubCopilotQuota();
|
||||||
default:
|
default:
|
||||||
return buildResult({
|
return buildResult({
|
||||||
providerId,
|
providerId,
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ export const listConfiguredQuotaProviders = () => {
|
|||||||
configured.add('zai-coding-plan');
|
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) {
|
for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) {
|
||||||
const data = readJsonFile(filePath);
|
const data = readJsonFile(filePath);
|
||||||
if (Array.isArray(data?.accounts) && data.accounts.length > 0) {
|
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) => {
|
export const fetchQuotaForProvider = async (providerId) => {
|
||||||
switch (providerId) {
|
switch (providerId) {
|
||||||
case 'openai':
|
case 'openai':
|
||||||
@@ -495,6 +601,8 @@ export const fetchQuotaForProvider = async (providerId) => {
|
|||||||
return fetchGoogleQuota();
|
return fetchGoogleQuota();
|
||||||
case 'zai-coding-plan':
|
case 'zai-coding-plan':
|
||||||
return fetchZaiQuota();
|
return fetchZaiQuota();
|
||||||
|
case 'github-copilot':
|
||||||
|
return fetchGitHubCopilotQuota();
|
||||||
default:
|
default:
|
||||||
return buildResult({
|
return buildResult({
|
||||||
providerId,
|
providerId,
|
||||||
|
|||||||
Reference in New Issue
Block a user