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 { listGatewayModels } from '@x/core/dist/models/gateway.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import type { IOAuthRepo } from '@x/core/dist/auth/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 { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.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 [configLoading, setConfigLoading] = useState(true)
const [showMoreProviders, setShowMoreProviders] = useState(false) const [showMoreProviders, setShowMoreProviders] = useState(false)
const [githubCopilotCode, setGithubCopilotCode] = useState<{ userCode: string; verificationUri: string } | null>(null) const [githubCopilotCode, setGithubCopilotCode] = useState<{ userCode: string; verificationUri: string } | null>(null)
const [githubCopilotAuthenticated, setGithubCopilotAuthenticated] = useState<boolean>(false)
const activeConfig = providerConfigs[provider] const activeConfig = providerConfigs[provider]
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible" 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 isLocalProvider = provider === "ollama" || provider === "openai-compatible"
const isGitHubCopilot = provider === "github-copilot" const isGitHubCopilot = provider === "github-copilot"
const modelsForProvider = modelsCatalog[provider] || [] 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 isMoreProvider = moreProviders.some(p => p.id === provider)
const primaryModel = activeConfig.models[0] || "" const primaryModel = activeConfig.models[0] || ""
@ -337,6 +338,23 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
loadCurrentConfig() loadCurrentConfig()
}, [dialogOpen]) }, [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 // Load models catalog
useEffect(() => { useEffect(() => {
if (!dialogOpen) return 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"> <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> <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"> <p className="text-xs text-muted-foreground">
GitHub Copilot uses Device Flow OAuth. Click below to get a device code. 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) { if (authCheck.authenticated) {
setTestState({ status: "success" }); setTestState({ status: "success" });
setGithubCopilotCode(null); setGithubCopilotCode(null);
setGithubCopilotAuthenticated(true);
toast.success("GitHub Copilot authenticated!"); toast.success("GitHub Copilot authenticated!");
// Reload models // Reload models

View file

@ -180,9 +180,45 @@ export async function getGitHubCopilotAuthStatus(): Promise<{
/** /**
* Disconnect GitHub Copilot (remove stored tokens) * 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> { export async function disconnectGitHubCopilot(): Promise<void> {
console.log('[GitHub Copilot] Disconnecting...'); console.log('[GitHub Copilot] Disconnecting...');
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
await oauthRepo.delete(PROVIDER_NAME); await oauthRepo.delete(PROVIDER_NAME);
cachedCopilotToken = null;
console.log('[GitHub Copilot] Disconnected successfully'); console.log('[GitHub Copilot] Disconnected successfully');
} }

View file

@ -4,23 +4,31 @@
* Handles GitHub Copilot model discovery and LLM provider initialization * 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 { ProviderV2 } from '@ai-sdk/provider';
import { createOpenAI } from '@ai-sdk/openai'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import z from 'zod'; import z from 'zod';
import { LlmProvider } from '@x/shared/dist/models.js'; import { LlmProvider } from '@x/shared/dist/models.js';
// GitHub Copilot API endpoint // 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 // List of models available through GitHub Copilot
// Based on GitHub Copilot API documentation // Based on GitHub Copilot API documentation
// https://docs.github.com/en/copilot/using-github-copilot/asking-github-copilot-questions // https://docs.github.com/en/copilot/using-github-copilot/asking-github-copilot-questions
export const GITHUB_COPILOT_MODELS = [ export const GITHUB_COPILOT_MODELS = [
'gpt-4o', // GPT-4 Optimized (recommended) 'gpt-5.4-mini',
'gpt-4-turbo', // GPT-4 Turbo 'gpt-5-mini',
'gpt-3.5-turbo', // GPT-3.5 Turbo (fastest) 'grok-code-fast-1',
'claude-3.5-sonnet', // Claude 3.5 Sonnet (if available in plan) '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; ] as const;
export type GitHubCopilotModel = typeof GITHUB_COPILOT_MODELS[number]; export type GitHubCopilotModel = typeof GITHUB_COPILOT_MODELS[number];
@ -57,16 +65,20 @@ export async function createGitHubCopilotProvider(
); );
} }
// Get access token (will handle refresh if needed) // Get Copilot API token (handles refresh if needed)
const accessToken = await getGitHubCopilotAccessToken(); const accessToken = await getGitHubCopilotApiToken();
// Create OpenAI-compatible provider with GitHub Copilot endpoint // Create OpenAI-compatible provider with GitHub Copilot endpoint
return createOpenAI({ return createOpenAICompatible({
name: "github-copilot",
apiKey: accessToken, apiKey: accessToken,
baseURL: config.baseURL || GITHUB_COPILOT_API_BASE, baseURL: config.baseURL || GITHUB_COPILOT_API_BASE,
headers: { headers: {
...config.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 // Try to get access token
await getGitHubCopilotAccessToken(); await getGitHubCopilotApiToken();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View file

@ -2,6 +2,7 @@ import { ProviderV2 } from '@ai-sdk/provider';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { getAccessToken } from '../auth/tokens.js'; import { getAccessToken } from '../auth/tokens.js';
import { API_URL } from '../config/env.js'; import { API_URL } from '../config/env.js';
import { isGitHubCopilotAuthenticated } from '../auth/github-copilot-auth.js';
export async function getGatewayProvider(): Promise<ProviderV2> { export async function getGatewayProvider(): Promise<ProviderV2> {
const accessToken = await getAccessToken(); 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 body = await response.json() as { data: Array<{ id: string }> };
const models = body.data.map((m) => ({ id: m.id })); const models = body.data.map((m) => ({ id: m.id }));
return {
providers: [{ const providers: ProviderSummary[] = [{
id: 'rowboat', id: 'rowboat',
name: 'Rowboat', name: 'Rowboat',
models, 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", id: "github-copilot",
name: "GitHub Copilot Student", name: "GitHub Copilot Student",
models: [ models: [
{ id: "gpt-4o", name: "GPT-4o (Recommended)" }, { id: "gpt-5.4-mini", name: "GPT-5.4 mini" },
{ id: "gpt-4-turbo", name: "GPT-4 Turbo" }, { id: "gpt-5-mini", name: "GPT-5 mini" },
{ id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo (Fastest)" }, { id: "grok-code-fast-1", name: "Grok Code Fast 1" },
{ id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet" }, { 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)" }
], ],
}); });