mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
Feat: Complete GitHub Copilot Student Integration via Device Flow and internal API exchange
This commit is contained in:
parent
97853c4b4c
commit
61af7f3c58
6 changed files with 160 additions and 26 deletions
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)" }
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue