Skip to content

Commit cdfb2f8

Browse files
committed
feat: add GitHub Copilot integration
This commit introduces comprehensive support for GitHub Copilot, including: - **UI Integration**: - Adds a new `copilot.png` icon for GitHub Copilot. - Updates `ProvidersPage` to display GitHub Copilot as an available provider. - Implements the device code flow for Copilot authentication, showing the user code and verification URL. - Adds Copilot-specific fields to `ProviderState` for managing the device flow. - Updates `getProviderIconPath` to correctly display the Copilot icon. - **Quota Management**: - Extends `QuotaPage` to recognize and display GitHub Copilot quota information. - Adds `copilot` to `getProviderType` and `ProviderSection` for proper categorization. - Implements `fetchCopilot` in `quotaApi` to retrieve Copilot entitlement details. - Adds `COPILOT_ENTITLEMENT_URL` and `COPILOT_HEADERS` for API requests. - Introduces `parseCopilotQuota` to interpret the Copilot entitlement response, including plan type and usage snapshots. - **API Enhancements**: - Adds `copilot` to `OAuthProvider` type. - Updates `oauthApi.startAuth` to use a `AUTH_URL_PROVIDER_MAP` for mapping `copilot` to `github` endpoint. - Extends `OAuthStartResponse` with `user_code` and `verification_uri` for device flow. - **General**: - Bumps package version to `1.0.8`. - Adds `copilot` to the `PROVIDERS` list in `src/types/index.ts`.
1 parent 3a232c3 commit cdfb2f8

8 files changed

Lines changed: 252 additions & 4 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "zero-limit",
3-
"version": "1.0.7",
3+
"version": "1.0.8",
44
"private": true,
55
"type": "module",
66
"scripts": {

public/copilot/copilot.png

3.08 KB
Loading

src/pages/ProvidersPage.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ interface ProviderState {
4747
url?: string;
4848
state?: string;
4949
error?: string;
50+
// Copilot device flow specific
51+
userCode?: string;
52+
deviceCode?: string;
53+
expiresIn?: number;
54+
interval?: number;
5055
}
5156

5257
// --- Helpers ---
@@ -79,6 +84,7 @@ function getProviderIconPath(providerId: string): string {
7984
if (id.includes('gemini')) return '/gemini/gemini.png';
8085
if (id.includes('codex') || id.includes('openai')) return '/openai/openai.png';
8186
if (id.includes('kiro')) return '/kiro/kiro.png';
87+
if (id.includes('copilot') || id.includes('github')) return '/copilot/copilot.png';
8288
return '';
8389
}
8490

@@ -231,6 +237,69 @@ export function ProvidersPage() {
231237
return;
232238
}
233239

240+
// Copilot uses device code flow via backend
241+
if (providerId === 'copilot') {
242+
try {
243+
const response = await oauthApi.startAuth('copilot');
244+
const url = response.url || response.verification_uri;
245+
const state = response.state;
246+
const userCode = response.user_code;
247+
248+
if (!url) {
249+
throw new Error('No verification URL returned from server');
250+
}
251+
252+
updateProviderState(providerId, {
253+
status: 'polling',
254+
url,
255+
state,
256+
userCode
257+
});
258+
259+
// Open verification URL
260+
await openInBrowser(url);
261+
262+
// Poll for auth status if we have a state
263+
if (state) {
264+
startPolling(providerId, state);
265+
} else {
266+
// Fallback: poll for new auth files
267+
const initialFiles = files.filter(f =>
268+
f.provider?.toLowerCase().includes('github') ||
269+
f.filename?.toLowerCase().includes('github')
270+
);
271+
const initialCount = initialFiles.length;
272+
273+
const pollTimer = window.setInterval(async () => {
274+
try {
275+
const listResponse = await authFilesApi.list();
276+
const currentFiles = listResponse?.files ?? [];
277+
const currentGithubFiles = currentFiles.filter(f =>
278+
f.provider?.toLowerCase().includes('github') ||
279+
f.filename?.toLowerCase().includes('github')
280+
);
281+
282+
if (currentGithubFiles.length > initialCount) {
283+
updateProviderState(providerId, { status: 'success' });
284+
stopPolling(providerId);
285+
setSelectedProvider(null);
286+
setFiles(currentFiles);
287+
}
288+
} catch {
289+
// Ignore polling errors
290+
}
291+
}, 3000);
292+
pollingTimers.current[providerId] = pollTimer;
293+
}
294+
} catch (err) {
295+
updateProviderState(providerId, {
296+
status: 'error',
297+
error: (err as Error).message
298+
});
299+
}
300+
return;
301+
}
302+
234303
const response = await oauthApi.startAuth(providerId, options);
235304
const url = response.url || response.auth_url;
236305
const state = response.state;
@@ -506,6 +575,28 @@ export function ProvidersPage() {
506575
{/* Normal Auth Flow (Polling/Waiting) - Only if NOT Error and NOT Idle */}
507576
{state.status !== 'idle' && state.status !== 'error' && (
508577
<>
578+
{/* Copilot Device Code Display */}
579+
{provider.id === 'copilot' && state.userCode && (
580+
<div className="space-y-3">
581+
<p className="text-sm text-muted-foreground">
582+
{t('providers.copilotInstructions', 'Enter the code below at GitHub:')}
583+
</p>
584+
<div className="flex items-center justify-center gap-3">
585+
<code className="text-3xl font-mono font-bold tracking-widest bg-muted px-4 py-2 rounded-lg">
586+
{state.userCode}
587+
</code>
588+
<Button
589+
variant="outline"
590+
size="sm"
591+
onClick={() => copyToClipboard(state.userCode!)}
592+
title={t('common.copy')}
593+
>
594+
<ClipboardCopy className="h-4 w-4" />
595+
</Button>
596+
</div>
597+
</div>
598+
)}
599+
509600
<div className="flex items-center gap-2 text-sm text-muted-foreground">
510601
<Loader2 className="h-4 w-4 animate-spin" />
511602
<span>{t('providers.completeLogin')}</span>

src/pages/QuotaPage.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,21 +50,23 @@ interface ProviderSection {
5050
}
5151

5252
// Identify provider from filename - with null safety
53-
function getProviderType(file: AuthFile): 'antigravity' | 'codex' | 'gemini-cli' | 'kiro' | 'unknown' {
53+
function getProviderType(file: AuthFile): 'antigravity' | 'codex' | 'gemini-cli' | 'kiro' | 'copilot' | 'unknown' {
5454
// Handle missing filename
5555
const filename = (file?.filename || file?.id || '').toLowerCase();
5656

5757
if (filename.startsWith('antigravity-') || filename.includes('antigravity')) return 'antigravity';
5858
if (filename.startsWith('codex-') || filename.includes('codex')) return 'codex';
5959
if (filename.startsWith('gemini-cli-') || filename.includes('gemini')) return 'gemini-cli';
6060
if (filename.startsWith('kiro-') || filename.includes('kiro')) return 'kiro';
61+
if (filename.startsWith('github-copilot-') || filename.includes('copilot')) return 'copilot';
6162

6263
// Fallback to provider field
6364
const provider = (file?.provider || '').toLowerCase();
6465
if (provider.includes('antigravity')) return 'antigravity';
6566
if (provider.includes('codex')) return 'codex';
6667
if (provider.includes('gemini')) return 'gemini-cli';
6768
if (provider.includes('kiro')) return 'kiro';
69+
if (provider.includes('copilot') || provider.includes('github')) return 'copilot';
6870

6971
return 'unknown';
7072
}
@@ -101,6 +103,7 @@ export function QuotaPage() {
101103
codex: [],
102104
'gemini-cli': [],
103105
'kiro': [],
106+
'copilot': [],
104107
};
105108

106109
files.forEach((file) => {
@@ -123,6 +126,7 @@ export function QuotaPage() {
123126
{ provider: 'codex', displayName: 'Codex (OpenAI)', files: grouped.codex },
124127
{ provider: 'gemini-cli', displayName: 'Gemini CLI', files: grouped['gemini-cli'] },
125128
{ provider: 'kiro', displayName: 'Kiro (CodeWhisperer)', files: grouped['kiro'] },
129+
{ provider: 'copilot', displayName: 'GitHub Copilot', files: grouped['copilot'] },
126130
]);
127131

128132
// Auto-fetch quota for all files
@@ -256,6 +260,19 @@ export function QuotaPage() {
256260
error: result.error
257261
} : f)
258262
} : s));
263+
} else if (targetProvider === 'copilot') {
264+
const result = await quotaApi.fetchCopilot(authIndex);
265+
266+
setSections((prev) => prev.map(s => s.provider === 'copilot' ? {
267+
...s,
268+
files: s.files.map(f => f.fileId === fileId ? {
269+
...f,
270+
loading: false,
271+
plan: result.plan,
272+
models: result.models,
273+
error: result.error
274+
} : f)
275+
} : s));
259276
}
260277
} catch (err) {
261278
const msg = (err as Error).message;
@@ -278,6 +295,7 @@ export function QuotaPage() {
278295
if (key === 'codex') return '/openai/openai.png'; // Assuming Codex uses OpenAI icon
279296
if (key === 'gemini-cli') return '/gemini/gemini.png';
280297
if (key === 'kiro') return '/kiro/kiro.png';
298+
if (key === 'copilot') return '/copilot/copilot.png';
281299
return undefined;
282300
};
283301

src/services/api/oauth.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', '
99
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
1010
'gemini-cli': 'gemini'
1111
};
12+
const AUTH_URL_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
13+
'copilot': 'github'
14+
};
1215

1316
export const oauthApi = {
1417
startAuth: (provider: OAuthProvider, options?: { projectId?: string }) => {
@@ -19,7 +22,8 @@ export const oauthApi = {
1922
if (provider === 'gemini-cli' && options?.projectId) {
2023
params.project_id = options.projectId;
2124
}
22-
return apiClient.get<OAuthStartResponse>(`/${provider}-auth-url`, {
25+
const endpointProvider = AUTH_URL_PROVIDER_MAP[provider] ?? provider;
26+
return apiClient.get<OAuthStartResponse>(`/${endpointProvider}-auth-url`, {
2327
params: Object.keys(params).length ? params : undefined
2428
});
2529
},

src/services/api/quota.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const ANTIGRAVITY_QUOTA_URLS = [
1515
const GEMINI_CLI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
1616
const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
1717
const KIRO_USAGE_URL = 'https://codewhisperer.us-east-1.amazonaws.com/getUsageLimits?isEmailRequired=true&origin=AI_EDITOR&resourceType=AGENTIC_REQUEST';
18+
const COPILOT_ENTITLEMENT_URL = 'https://api.github.com/copilot_internal/user';
1819

1920
// Headers
2021
const ANTIGRAVITY_HEADERS = {
@@ -41,6 +42,12 @@ const KIRO_HEADERS = {
4142
'x-amz-user-agent': 'aws-sdk-js/3.0.0'
4243
};
4344

45+
const COPILOT_HEADERS = {
46+
Authorization: 'Bearer $TOKEN$',
47+
Accept: 'application/vnd.github+json',
48+
'X-GitHub-Api-Version': '2022-11-28'
49+
};
50+
4451
import {
4552
parseCodexUsagePayload,
4653
formatCodexResetLabel,
@@ -90,6 +97,13 @@ export interface KiroQuotaResult {
9097
error?: string;
9198
}
9299

100+
export interface CopilotQuotaResult {
101+
models: QuotaModel[];
102+
plan?: string;
103+
username?: string;
104+
error?: string;
105+
}
106+
93107
// Parse Antigravity models response
94108
function parseAntigravityModels(body: unknown): QuotaModel[] {
95109
const models: QuotaModel[] = [];
@@ -398,6 +412,96 @@ function parseKiroQuota(body: unknown): KiroQuotaResult {
398412
return { models, plan, email };
399413
}
400414

415+
// Parse Copilot entitlement response
416+
function parseCopilotQuota(body: unknown): CopilotQuotaResult {
417+
const payload = body as Record<string, unknown> | null;
418+
if (!payload) return { models: [] };
419+
420+
const models: QuotaModel[] = [];
421+
422+
// Extract plan type
423+
const accessTypeSku = (payload.access_type_sku as string) || '';
424+
const copilotPlan = (payload.copilot_plan as string) || '';
425+
let plan = 'Unknown';
426+
427+
const sku = accessTypeSku.toLowerCase();
428+
const planLower = copilotPlan.toLowerCase();
429+
430+
if (sku.includes('enterprise') || planLower === 'enterprise') {
431+
plan = 'Enterprise';
432+
} else if (sku.includes('business') || planLower === 'business') {
433+
plan = 'Business';
434+
} else if (sku.includes('educational') || sku.includes('pro') || planLower.includes('pro')) {
435+
plan = 'Pro';
436+
} else if (planLower === 'individual' && !sku.includes('free_limited')) {
437+
plan = 'Pro';
438+
} else if (sku.includes('free_limited') || sku === 'free' || planLower.includes('free')) {
439+
plan = 'Free';
440+
} else if (copilotPlan) {
441+
plan = copilotPlan.charAt(0).toUpperCase() + copilotPlan.slice(1);
442+
}
443+
444+
// Parse reset date
445+
const resetDateStr = (payload.quota_reset_date_utc as string) || (payload.quota_reset_date as string) || (payload.limited_user_reset_date as string);
446+
let resetTime: string | undefined;
447+
if (resetDateStr) {
448+
resetTime = formatResetTime(resetDateStr);
449+
}
450+
451+
// Method 1: Parse quota_snapshots (used by paid plans)
452+
const quotaSnapshots = payload.quota_snapshots as Record<string, unknown> | undefined;
453+
if (quotaSnapshots) {
454+
const parseSnapshot = (name: string, snapshot: unknown, defaultTotal: number) => {
455+
const snap = snapshot as Record<string, unknown> | undefined;
456+
if (!snap || snap.unlimited === true) return;
457+
458+
let percentage = 100;
459+
if (typeof snap.percent_remaining === 'number') {
460+
percentage = Math.min(100, Math.max(0, snap.percent_remaining));
461+
} else {
462+
const remaining = (snap.remaining as number) ?? 0;
463+
const total = (snap.entitlement as number) ?? defaultTotal;
464+
if (total > 0) {
465+
percentage = Math.min(100, Math.max(0, (remaining / total) * 100));
466+
}
467+
}
468+
469+
models.push({ name, percentage: Math.round(percentage), resetTime });
470+
};
471+
472+
parseSnapshot('Chat', quotaSnapshots.chat, 50);
473+
parseSnapshot('Completions', quotaSnapshots.completions, 2000);
474+
parseSnapshot('Premium', quotaSnapshots.premium_interactions, 50);
475+
}
476+
477+
// Method 2: Parse limited_user_quotas + monthly_quotas (for free/individual plans)
478+
if (models.length === 0) {
479+
const limitedQuotas = payload.limited_user_quotas as Record<string, unknown> | undefined;
480+
const monthlyQuotas = payload.monthly_quotas as Record<string, unknown> | undefined;
481+
482+
if (limitedQuotas && monthlyQuotas) {
483+
const parseLimit = (name: string, remainingKey: string, totalKey: string) => {
484+
const remaining = (limitedQuotas[remainingKey] as number) ?? 0;
485+
const total = (monthlyQuotas[totalKey] as number) ?? 0;
486+
if (total > 0) {
487+
const percentage = Math.min(100, Math.max(0, (remaining / total) * 100));
488+
models.push({ name, percentage: Math.round(percentage), resetTime });
489+
}
490+
};
491+
492+
parseLimit('Chat', 'chat', 'chat');
493+
parseLimit('Completions', 'completions', 'completions');
494+
}
495+
}
496+
497+
// Fallback if no quota info found
498+
if (models.length === 0) {
499+
models.push({ name: 'Copilot', percentage: 100, resetTime: undefined });
500+
}
501+
502+
return { models, plan };
503+
}
504+
401505
export const quotaApi = {
402506
/**
403507
* Fetch Antigravity quota for an auth file
@@ -508,6 +612,33 @@ export const quotaApi = {
508612
};
509613
}
510614

615+
return { models: [], error: formatQuotaError(result) };
616+
} catch (err) {
617+
return { models: [], error: (err as Error).message };
618+
}
619+
},
620+
621+
/**
622+
* Fetch GitHub Copilot quota for an auth file
623+
*/
624+
async fetchCopilot(authIndex: string): Promise<CopilotQuotaResult> {
625+
try {
626+
const result = await apiCallApi.request({
627+
authIndex,
628+
method: 'GET',
629+
url: COPILOT_ENTITLEMENT_URL,
630+
header: { ...COPILOT_HEADERS }
631+
});
632+
633+
if (result.statusCode >= 200 && result.statusCode < 300) {
634+
return parseCopilotQuota(result.body);
635+
}
636+
637+
// Handle 401/403 - token expired or no subscription
638+
if (result.statusCode === 401 || result.statusCode === 403) {
639+
return { models: [], error: 'Token invalid or no Copilot subscription' };
640+
}
641+
511642
return { models: [], error: formatQuotaError(result) };
512643
} catch (err) {
513644
return { models: [], error: (err as Error).message };

src/types/api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ export interface Config {
99
}
1010

1111
// OAuth types
12-
export type OAuthProvider = 'codex' | 'anthropic' | 'antigravity' | 'gemini-cli' | 'kiro';
12+
export type OAuthProvider = 'codex' | 'anthropic' | 'antigravity' | 'gemini-cli' | 'kiro' | 'copilot';
1313

1414
export interface OAuthStartResponse {
1515
url?: string;
1616
auth_url?: string;
1717
state?: string;
18+
// Device flow fields (for GitHub Copilot)
19+
user_code?: string;
20+
verification_uri?: string;
1821
}
1922

2023
export interface OAuthCallbackResponse {

0 commit comments

Comments
 (0)