Feat: Complete GitHub Copilot Student Integration via Device Flow and internal API exchange

This commit is contained in:
Rowboat Developer 2026-04-17 11:51:23 -05:00
parent 97853c4b4c
commit 61af7f3c58
6 changed files with 160 additions and 26 deletions

View file

@ -29,7 +29,7 @@ import { isSignedIn } from '@x/core/dist/account/account.js';
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { startGitHubCopilotAuthentication, getGitHubCopilotAccessToken, isGitHubCopilotAuthenticated, disconnectGitHubCopilot } from '@x/core/dist/auth/github-copilot-auth.js';
import { startGitHubCopilotAuthentication, isGitHubCopilotAuthenticated, disconnectGitHubCopilot } from '@x/core/dist/auth/github-copilot-auth.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';

View file

@ -214,6 +214,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [configLoading, setConfigLoading] = useState(true)
const [showMoreProviders, setShowMoreProviders] = useState(false)
const [githubCopilotCode, setGithubCopilotCode] = useState<{ userCode: string; verificationUri: string } | null>(null)
const [githubCopilotAuthenticated, setGithubCopilotAuthenticated] = useState<boolean>(false)
const activeConfig = providerConfigs[provider]
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
@ -223,7 +224,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const isLocalProvider = provider === "ollama" || provider === "openai-compatible"
const isGitHubCopilot = provider === "github-copilot"
const modelsForProvider = modelsCatalog[provider] || []
const showModelInput = isLocalProvider || modelsForProvider.length === 0 || isGitHubCopilot
const showModelInput = isLocalProvider || modelsForProvider.length === 0
const isMoreProvider = moreProviders.some(p => p.id === provider)
const primaryModel = activeConfig.models[0] || ""
@ -337,6 +338,23 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
loadCurrentConfig()
}, [dialogOpen])
// Check GitHub Copilot auth status
useEffect(() => {
if (!dialogOpen || provider !== "github-copilot") return;
async function checkCopilotAuth() {
try {
const result = await window.ipc.invoke("github-copilot:isAuthenticated", null);
setGithubCopilotAuthenticated(!!result.authenticated);
} catch (error) {
console.error("Error checking Copilot auth:", error);
setGithubCopilotAuthenticated(false);
}
}
checkCopilotAuth();
}, [dialogOpen, provider]);
// Load models catalog
useEffect(() => {
if (!dialogOpen) return
@ -693,7 +711,44 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="space-y-3 border rounded-lg p-3 bg-muted/30">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Authentication</span>
{!githubCopilotCode ? (
{githubCopilotAuthenticated ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between p-2 border rounded bg-background/50">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="size-4 text-green-500" />
<span className="font-medium">Connected to GitHub</span>
</div>
<Button
variant="outline"
size="sm"
className="h-8 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={async () => {
try {
setTestState({ status: "testing" });
const result = await window.ipc.invoke("github-copilot:disconnect", null);
if (result.success) {
setGithubCopilotAuthenticated(false);
setGithubCopilotCode(null);
setTestState({ status: "idle" });
toast.success("Disconnected successfully");
} else {
toast.error(result.error || "Failed to disconnect");
setTestState({ status: "error", error: result.error });
}
} catch (error) {
toast.error("Error disconnecting");
setTestState({ status: "idle" });
}
}}
>
Disconnect
</Button>
</div>
<p className="text-xs text-muted-foreground">
Your device is authenticated. You can select any model from the dropdown above to start using GitHub Copilot.
</p>
</div>
) : !githubCopilotCode ? (
<>
<p className="text-xs text-muted-foreground">
GitHub Copilot uses Device Flow OAuth. Click below to get a device code.
@ -786,6 +841,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
if (authCheck.authenticated) {
setTestState({ status: "success" });
setGithubCopilotCode(null);
setGithubCopilotAuthenticated(true);
toast.success("GitHub Copilot authenticated!");
// Reload models

View file

@ -180,9 +180,45 @@ export async function getGitHubCopilotAuthStatus(): Promise<{
/**
* Disconnect GitHub Copilot (remove stored tokens)
*/
/**
* Get GitHub Copilot API Token (exchange OAuth token for Copilot JWT)
*/
let cachedCopilotToken: { token: string; expiresAt: number } | null = null;
export async function getGitHubCopilotApiToken(): Promise<string> {
// Return cached token if valid (with 5 min buffer)
if (cachedCopilotToken && cachedCopilotToken.expiresAt > Date.now() / 1000 + 300) {
return cachedCopilotToken.token;
}
const oauthToken = await getGitHubCopilotAccessToken();
const response = await fetch('https://api.github.com/copilot_internal/v2/token', {
headers: {
'Authorization': `Bearer ${oauthToken}`,
'Accept': 'application/json',
'User-Agent': 'GithubCopilot/1.155.0'
}
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to get Copilot token: ${response.status} ${err}`);
}
const data = await response.json() as { token: string; expires_at: number };
cachedCopilotToken = {
token: data.token,
expiresAt: data.expires_at
};
return data.token;
}
export async function disconnectGitHubCopilot(): Promise<void> {
console.log('[GitHub Copilot] Disconnecting...');
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
await oauthRepo.delete(PROVIDER_NAME);
cachedCopilotToken = null;
console.log('[GitHub Copilot] Disconnected successfully');
}

View file

@ -4,23 +4,31 @@
* Handles GitHub Copilot model discovery and LLM provider initialization
*/
import { getGitHubCopilotAccessToken, isGitHubCopilotAuthenticated } from './github-copilot-auth.js';
import { getGitHubCopilotApiToken, isGitHubCopilotAuthenticated } from './github-copilot-auth.js';
import { ProviderV2 } from '@ai-sdk/provider';
import { createOpenAI } from '@ai-sdk/openai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import z from 'zod';
import { LlmProvider } from '@x/shared/dist/models.js';
// GitHub Copilot API endpoint
const GITHUB_COPILOT_API_BASE = 'https://models.github.com/api/openai/';
const GITHUB_COPILOT_API_BASE = 'https://api.githubcopilot.com/';
// List of models available through GitHub Copilot
// Based on GitHub Copilot API documentation
// https://docs.github.com/en/copilot/using-github-copilot/asking-github-copilot-questions
export const GITHUB_COPILOT_MODELS = [
'gpt-4o', // GPT-4 Optimized (recommended)
'gpt-4-turbo', // GPT-4 Turbo
'gpt-3.5-turbo', // GPT-3.5 Turbo (fastest)
'claude-3.5-sonnet', // Claude 3.5 Sonnet (if available in plan)
'gpt-5.4-mini',
'gpt-5-mini',
'grok-code-fast-1',
'claude-haiku-4.5',
'gemini-3-flash-preview',
'gpt-5.2',
'gpt-4.1',
'gpt-4o',
'gemini-3.1-pro-preview',
'gpt-5.2-codex',
'gpt-5.3-codex',
'gemini-2.5-pro'
] as const;
export type GitHubCopilotModel = typeof GITHUB_COPILOT_MODELS[number];
@ -57,16 +65,20 @@ export async function createGitHubCopilotProvider(
);
}
// Get access token (will handle refresh if needed)
const accessToken = await getGitHubCopilotAccessToken();
// Get Copilot API token (handles refresh if needed)
const accessToken = await getGitHubCopilotApiToken();
// Create OpenAI-compatible provider with GitHub Copilot endpoint
return createOpenAI({
return createOpenAICompatible({
name: "github-copilot",
apiKey: accessToken,
baseURL: config.baseURL || GITHUB_COPILOT_API_BASE,
headers: {
...config.headers,
'user-agent': 'Rowboat/1.0',
'Editor-Version': 'vscode/1.88.0',
'Editor-Plugin-Version': 'copilot-chat/0.14.0',
'User-Agent': 'GitHubCopilotChat/0.14.0',
'Accept': '*/*',
},
});
}
@ -88,7 +100,7 @@ export async function testGitHubCopilotConnection(): Promise<{ success: boolean;
}
// Try to get access token
await getGitHubCopilotAccessToken();
await getGitHubCopilotApiToken();
return { success: true };
} catch (error) {

View file

@ -2,6 +2,7 @@ import { ProviderV2 } from '@ai-sdk/provider';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { getAccessToken } from '../auth/tokens.js';
import { API_URL } from '../config/env.js';
import { isGitHubCopilotAuthenticated } from '../auth/github-copilot-auth.js';
export async function getGatewayProvider(): Promise<ProviderV2> {
const accessToken = await getAccessToken();
@ -31,11 +32,32 @@ export async function listGatewayModels(): Promise<{ providers: ProviderSummary[
}
const body = await response.json() as { data: Array<{ id: string }> };
const models = body.data.map((m) => ({ id: m.id }));
return {
providers: [{
id: 'rowboat',
name: 'Rowboat',
models,
}],
};
const providers: ProviderSummary[] = [{
id: 'rowboat',
name: 'Rowboat',
models,
}];
// Add GitHub Copilot models always so they appear in UI
providers.push({
id: "github-copilot",
name: "GitHub Copilot Student",
models: [
{ id: "gpt-5.4-mini", name: "GPT-5.4 mini" },
{ id: "gpt-5-mini", name: "GPT-5 mini" },
{ id: "grok-code-fast-1", name: "Grok Code Fast 1" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash (Preview)" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-4.1", name: "GPT-4.1" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro (Preview)" },
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3-Codex" },
{ id: "gemini-2.5-pro-preview", name: "Gemini 2.5 Pro (Preview)" }
],
});
return { providers };
}

View file

@ -227,10 +227,18 @@ export async function listOnboardingModels(): Promise<{ providers: ProviderSumma
id: "github-copilot",
name: "GitHub Copilot Student",
models: [
{ id: "gpt-4o", name: "GPT-4o (Recommended)" },
{ id: "gpt-4-turbo", name: "GPT-4 Turbo" },
{ id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo (Fastest)" },
{ id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 mini" },
{ id: "gpt-5-mini", name: "GPT-5 mini" },
{ id: "grok-code-fast-1", name: "Grok Code Fast 1" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash (Preview)" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-4.1", name: "GPT-4.1" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro (Preview)" },
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3-Codex" },
{ id: "gemini-2.5-pro-preview", name: "Gemini 2.5 Pro (Preview)" }
],
});