Files
BrowserOS/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx
shivammittal274 720baaed3e feat: add GitHub Copilot as OAuth LLM provider (#500)
* feat: add GitHub Copilot as OAuth-based LLM provider

Add GitHub Copilot as a second OAuth provider using the Device Code flow
(RFC 8628). Users authenticate via github.com/login/device, and the server
polls for token completion. Supports 25+ models through a single Copilot
subscription.

Key changes:
- Device Code OAuth flow in token manager (poll with safety margin)
- Custom fetch wrapper injecting Copilot headers + vision detection
- Provider factory using createOpenAICompatible for Chat Completions API
- Extension UI with template card, auto-create on auth, and disconnect

* fix: address PR review comments for GitHub Copilot OAuth

- Validate device code response for error fields (GitHub can return 200
  with error payload)
- Store empty refreshToken instead of access token for GitHub tokens
- Add closeButton to Toaster for dismissing device code toast

* fix: add github-copilot to agent provider factory

The chat route uses a separate provider-factory.ts (agent layer) from the
test-provider route (llm/provider.ts). Added createGitHubCopilotFactory
to the agent factory so chat works with GitHub Copilot.

* fix: add github-copilot to provider icons, models, and dialog

- Add Github icon from lucide-react to providerIcons map
- Add 8 Copilot models (GPT-4o, Claude, Gemini, Grok) to models.ts
- Add github-copilot to NewProviderDialog zod enum, validation skip,
  canTest check, and OAuth credential message

* fix: reorder copilot models with free-tier models first

Put models available on Copilot Free at the top (gpt-4o, gpt-4.1,
gpt-5-mini, claude-haiku-4.5, grok-code-fast-1), followed by
premium models that require paid Copilot subscription.

* fix: set correct 64K context window for Copilot models

Copilot API enforces a 64K input token limit regardless of the
underlying model's native context window. Updated all model entries
and the default template to 64000 so compaction triggers correctly.

* fix: use actual per-model prompt limits from Copilot /models API

Queried api.githubcopilot.com/models for real max_prompt_tokens values.
GPT-4o/4.1 have 64K, Claude/gpt-5-mini have 128K, GPT-5.x have 272K.
Also updated model list to match what's actually available on the API
(e.g. claude-sonnet-4.6 instead of 4.5, added gpt-5.4/5.2-codex).

* feat: resize images for Copilot using VS Code's algorithm

Large screenshots cause 413 errors on Copilot's API. Resize images
following VS Code's approach: max 2048px longest side, 768px shortest
side, re-encode as JPEG at 75% quality. Uses sharp for server-side
image processing.

* fix: address all Greptile P1 review comments

- Add .catch() on fire-and-forget pollDeviceCode to prevent unhandled
  rejection crashes (Node 15+)
- Add deduplication guard (activeDeviceFlows Set) to prevent concurrent
  device code flows for the same provider
- Add runtime validation of server response in frontend before calling
  window.open() and showing toast
- Remove dead GITHUB_DEVICE_VERIFICATION constant from urls.ts

* fix: upgrade biome to 2.4.8, fix all lint errors, and address review bugs

- Upgrade biome from 2.4.5 to 2.4.8 (matches CI) and migrate configs
- Fix image resize: only re-encode when dimensions actually change
- Fix device code polling: retry on transient network errors instead of aborting
- Allow restarting device code flow (clear old flow instead of throwing 500)
- Fix pre-existing noNonNullAssertion and noExplicitAny lint errors globally

* fix: address Greptile P2 review — image resize and config guard

- Fix early-return guard: check max/min sides against their respective
  limits (MAX_LONG_SIDE/MAX_SHORT_SIDE) instead of both against SHORT
- Preserve PNG alpha: detect hasAlpha and keep PNG format instead of
  unconditionally converting to lossy JPEG
- Keep browserosId guard in resolveGitHubCopilotConfig consistent with
  ChatGPT Pro pattern (safety check that caller context is valid)

* feat: update Copilot models to full list from pricing page, default to gpt-5-mini

Added all 23 models from GitHub Copilot pricing page. Ordered with
free-tier models first (gpt-5-mini, claude-haiku-4.5), then premium.
Changed default from gpt-4o to gpt-5-mini since it's unlimited on
Pro plan and has 128K context (vs gpt-4o's 64K limit).
2026-03-20 02:33:09 +05:30

505 lines
16 KiB
TypeScript

import { useQueryClient } from '@tanstack/react-query'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useSessionInfo } from '@/lib/auth/sessionStorage'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import {
CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
CHATGPT_PRO_OAUTH_STARTED_EVENT,
GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
GITHUB_COPILOT_OAUTH_STARTED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import {
getProviderTemplate,
type ProviderTemplate,
} from '@/lib/llm-providers/providerTemplates'
import { testProvider } from '@/lib/llm-providers/testProvider'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { useOAuthStatus } from '@/lib/llm-providers/useOAuthStatus'
import { track } from '@/lib/metrics/track'
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
import {
DeleteRemoteLlmProviderDocument,
GetRemoteLlmProvidersDocument,
} from './graphql/aiSettingsDocument'
import type { IncompleteProvider } from './IncompleteProviderCard'
import { IncompleteProvidersList } from './IncompleteProvidersList'
import { LlmProvidersHeader } from './LlmProvidersHeader'
import { NewProviderDialog } from './NewProviderDialog'
import { ProviderTemplatesSection } from './ProviderTemplatesSection'
/**
* AI Settings page for managing LLM providers
* @public
*/
export const AISettingsPage: FC = () => {
const {
providers,
defaultProviderId,
saveProvider,
setDefaultProvider,
deleteProvider,
} = useLlmProviders()
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const { sessionInfo } = useSessionInfo()
const queryClient = useQueryClient()
const userId = sessionInfo.user?.id
const { data: profileData } = useGraphqlQuery(
GetProfileIdByUserIdDocument,
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled
{ userId: userId! },
{ enabled: !!userId },
)
const profileId = profileData?.profileByUserId?.rowId
const { data: remoteProvidersData } = useGraphqlQuery(
GetRemoteLlmProvidersDocument,
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled
{ profileId: profileId! },
{ enabled: !!profileId },
)
const deleteRemoteProviderMutation = useGraphqlMutation(
DeleteRemoteLlmProviderDocument,
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [getQueryKeyFromDocument(GetRemoteLlmProvidersDocument)],
})
},
},
)
const incompleteProviders = useMemo<IncompleteProvider[]>(() => {
if (!remoteProvidersData?.llmProviders?.nodes) return []
const localProviderIds = new Set(providers.map((p) => p.id))
return remoteProvidersData.llmProviders.nodes
.filter((node): node is NonNullable<typeof node> => node !== null)
.filter((node) => !localProviderIds.has(node.rowId))
}, [remoteProvidersData, providers])
const [isNewDialogOpen, setIsNewDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [templateValues, setTemplateValues] = useState<
Partial<LlmProviderConfig> | undefined
>()
const [editingProvider, setEditingProvider] =
useState<LlmProviderConfig | null>(null)
const [providerToDelete, setProviderToDelete] =
useState<LlmProviderConfig | null>(null)
const [incompleteProviderToDelete, setIncompleteProviderToDelete] =
useState<IncompleteProvider | null>(null)
const [testingProviderId, setTestingProviderId] = useState<string | null>(
null,
)
// OAuth status for ChatGPT Plus/Pro
const {
status: chatgptProStatus,
startPolling: startChatGPTProPolling,
disconnect: disconnectChatGPTPro,
} = useOAuthStatus('chatgpt-pro')
// OAuth status for GitHub Copilot
const {
status: copilotStatus,
startPolling: startCopilotPolling,
disconnect: disconnectCopilot,
} = useOAuthStatus('github-copilot')
// Track whether user explicitly started an OAuth flow this session
const oauthFlowStartedRef = useRef(false)
const copilotOAuthStartedRef = useRef(false)
// Auto-create provider only when user actively completed OAuth,
// not on passive page load when server has old tokens
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
useEffect(() => {
if (!chatgptProStatus?.authenticated) return
if (!oauthFlowStartedRef.current) return
const exists = providers.some((p) => p.type === 'chatgpt-pro')
if (exists) return
const now = Date.now()
try {
const template = getProviderTemplate('chatgpt-pro')
saveProvider({
id: `chatgpt-pro-${now}`,
type: 'chatgpt-pro',
name: `ChatGPT Plus/Pro${chatgptProStatus.email ? ` (${chatgptProStatus.email})` : ''}`,
modelId: template?.defaultModelId ?? 'gpt-5.3-codex',
supportsImages: template?.supportsImages ?? true,
contextWindow: template?.contextWindow ?? 400000,
temperature: 0.2,
createdAt: now,
updatedAt: now,
})
track(CHATGPT_PRO_OAUTH_COMPLETED_EVENT, {
email: chatgptProStatus.email,
})
toast.success('ChatGPT Plus/Pro Connected', {
description: chatgptProStatus.email
? `Authenticated as ${chatgptProStatus.email}`
: 'Successfully authenticated with ChatGPT Plus/Pro',
})
} catch (err) {
toast.error('Failed to create ChatGPT Plus/Pro provider', {
description: err instanceof Error ? err.message : 'Unknown error',
})
} finally {
oauthFlowStartedRef.current = false
}
}, [chatgptProStatus?.authenticated])
// Auto-create GitHub Copilot provider on successful OAuth
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
useEffect(() => {
if (!copilotStatus?.authenticated) return
if (!copilotOAuthStartedRef.current) return
const exists = providers.some((p) => p.type === 'github-copilot')
if (exists) return
const now = Date.now()
try {
const template = getProviderTemplate('github-copilot')
saveProvider({
id: `github-copilot-${now}`,
type: 'github-copilot',
name: 'GitHub Copilot',
modelId: template?.defaultModelId ?? 'gpt-4o',
supportsImages: template?.supportsImages ?? true,
contextWindow: template?.contextWindow ?? 128000,
temperature: 0.2,
createdAt: now,
updatedAt: now,
})
track(GITHUB_COPILOT_OAUTH_COMPLETED_EVENT)
toast.success('GitHub Copilot Connected', {
description: 'Successfully authenticated with GitHub Copilot',
})
} catch (err) {
toast.error('Failed to create GitHub Copilot provider', {
description: err instanceof Error ? err.message : 'Unknown error',
})
} finally {
copilotOAuthStartedRef.current = false
}
}, [copilotStatus?.authenticated])
const handleAddProvider = () => {
setTemplateValues(undefined)
setIsNewDialogOpen(true)
}
const handleUseTemplate = (template: ProviderTemplate) => {
// OAuth providers: trigger OAuth flow instead of opening form dialog
if (template.id === 'chatgpt-pro') {
handleStartChatGPTProOAuth()
return
}
if (template.id === 'github-copilot') {
handleStartGitHubCopilotOAuth()
return
}
setTemplateValues({
type: template.id,
name: template.name,
baseUrl: template.defaultBaseUrl,
modelId: template.defaultModelId,
supportsImages: template.supportsImages,
contextWindow: template.contextWindow,
temperature: 0.2,
})
setIsNewDialogOpen(true)
}
const handleStartChatGPTProOAuth = () => {
if (!agentServerUrl) {
toast.error('Server not available', {
description: 'Cannot start OAuth flow without server connection.',
})
return
}
oauthFlowStartedRef.current = true
const extensionSettingsUrl = chrome.runtime.getURL('app.html#/ai-settings')
const startUrl = `${agentServerUrl}/oauth/chatgpt-pro/start?redirect=${encodeURIComponent(extensionSettingsUrl)}`
window.open(startUrl, '_blank')
// Start polling for OAuth completion
startChatGPTProPolling()
track(CHATGPT_PRO_OAUTH_STARTED_EVENT)
toast.info('Authenticating with ChatGPT Plus/Pro', {
description: 'Complete the login in the opened tab.',
})
}
const handleStartGitHubCopilotOAuth = async () => {
if (!agentServerUrl) {
toast.error('Server not available', {
description: 'Cannot start OAuth flow without server connection.',
})
return
}
copilotOAuthStartedRef.current = true
try {
// Device Code flow: get user code from server, then open GitHub
const res = await fetch(`${agentServerUrl}/oauth/github-copilot/start`)
if (!res.ok) throw new Error(`Server returned ${res.status}`)
const data = (await res.json()) as {
userCode?: string
verificationUri?: string
}
if (!data.userCode || !data.verificationUri) {
throw new Error('Invalid response from server')
}
// Open GitHub device verification page
window.open(data.verificationUri, '_blank')
// Start polling for completion
startCopilotPolling()
track(GITHUB_COPILOT_OAUTH_STARTED_EVENT)
toast.info(`Enter code: ${data.userCode}`, {
description: 'Paste this code on the GitHub page that just opened.',
duration: 60_000,
})
} catch (err) {
copilotOAuthStartedRef.current = false
toast.error('Failed to start GitHub Copilot authentication', {
description: err instanceof Error ? err.message : 'Unknown error',
})
}
}
const handleEditProvider = (provider: LlmProviderConfig) => {
setEditingProvider(provider)
setIsEditDialogOpen(true)
}
const handleDeleteProvider = (provider: LlmProviderConfig) => {
setProviderToDelete(provider)
}
const confirmDeleteProvider = async () => {
if (providerToDelete) {
// Clear OAuth tokens on server for OAuth-based providers
if (providerToDelete.type === 'chatgpt-pro') {
await disconnectChatGPTPro()
track(CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT)
}
if (providerToDelete.type === 'github-copilot') {
await disconnectCopilot()
track(GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT)
}
await deleteProvider(providerToDelete.id)
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
setProviderToDelete(null)
}
}
const handleAddKeysToIncomplete = (provider: IncompleteProvider) => {
const timestamp = Date.now()
setTemplateValues({
id: provider.rowId,
type: provider.type as LlmProviderConfig['type'],
name: provider.name,
baseUrl: provider.baseUrl ?? undefined,
modelId: provider.modelId,
supportsImages: provider.supportsImages,
contextWindow: provider.contextWindow ?? 128000,
temperature: provider.temperature ?? 0.2,
resourceName: provider.resourceName ?? undefined,
region: provider.region ?? undefined,
createdAt: timestamp,
updatedAt: timestamp,
})
setIsNewDialogOpen(true)
}
const handleDeleteIncompleteProvider = (provider: IncompleteProvider) => {
setIncompleteProviderToDelete(provider)
}
const confirmDeleteIncompleteProvider = () => {
if (incompleteProviderToDelete) {
deleteRemoteProviderMutation.mutate({
rowId: incompleteProviderToDelete.rowId,
})
setIncompleteProviderToDelete(null)
}
}
const handleSaveProvider = async (provider: LlmProviderConfig) => {
await saveProvider(provider)
}
const handleSelectProvider = (providerId: string) => {
setDefaultProvider(providerId)
}
const handleTestProvider = async (provider: LlmProviderConfig) => {
if (!agentServerUrl) {
toast.error('Test Failed', {
description: (
<span className="text-red-600 text-sm dark:text-red-400">
Server URL not available
</span>
),
duration: 3000,
})
return
}
setTestingProviderId(provider.id)
try {
const result = await testProvider(provider, agentServerUrl)
if (result.success) {
toast.success('Test Successful', {
description: (
<span className="text-green-600 text-sm dark:text-green-400">
{result.message}
</span>
),
duration: 3000,
})
} else {
toast.error('Test Failed', {
description: (
<span className="text-red-600 text-sm dark:text-red-400">
{result.message}
</span>
),
duration: 3000,
})
}
} catch (error) {
toast.error('Test Failed', {
description: (
<span className="text-red-600 text-sm dark:text-red-400">
{error instanceof Error ? error.message : 'Unknown error'}
</span>
),
duration: 3000,
})
}
setTestingProviderId(null)
}
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<LlmProvidersHeader
providers={providers}
defaultProviderId={defaultProviderId}
onDefaultProviderChange={setDefaultProvider}
onAddProvider={handleAddProvider}
/>
<ProviderTemplatesSection onUseTemplate={handleUseTemplate} />
<ConfiguredProvidersList
providers={providers}
selectedProviderId={defaultProviderId}
testingProviderId={testingProviderId}
onSelectProvider={handleSelectProvider}
onTestProvider={handleTestProvider}
onEditProvider={handleEditProvider}
onDeleteProvider={handleDeleteProvider}
/>
<IncompleteProvidersList
providers={incompleteProviders}
onAddKeys={handleAddKeysToIncomplete}
onDelete={handleDeleteIncompleteProvider}
/>
<NewProviderDialog
open={isNewDialogOpen}
onOpenChange={setIsNewDialogOpen}
initialValues={templateValues}
onSave={handleSaveProvider}
/>
<NewProviderDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
initialValues={editingProvider ?? undefined}
onSave={handleSaveProvider}
/>
<AlertDialog
open={!!providerToDelete}
onOpenChange={(open) => !open && setProviderToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Provider</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{providerToDelete?.name}"? This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteProvider}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!incompleteProviderToDelete}
onOpenChange={(open) => !open && setIncompleteProviderToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Synced Provider</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "
{incompleteProviderToDelete?.name}
"? This will remove it from all your devices.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteIncompleteProvider}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}