From 720baaed3e2b51465125283d89bc8f294b432b5b Mon Sep 17 00:00:00 2001 From: shivammittal274 <56757235+shivammittal274@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:33:09 +0530 Subject: [PATCH] feat: add GitHub Copilot as OAuth LLM provider (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- .../browseros-agent/apps/agent/biome.json | 2 +- .../apps/agent/components/ui/sonner.tsx | 1 + .../app/ai-settings/AISettingsPage.tsx | 96 ++++++++++ .../app/ai-settings/NewProviderDialog.tsx | 18 +- .../entrypoints/app/ai-settings/models.ts | 27 +++ .../connect-mcp/useGetUserMCPIntegrations.tsx | 1 + .../entrypoints/app/skills/SkillsPage.tsx | 20 ++- .../sidepanel/index/useChatSession.ts | 1 + .../agent/lib/constants/analyticsEvents.ts | 12 ++ .../agent/lib/llm-providers/providerIcons.tsx | 3 +- .../lib/llm-providers/providerTemplates.ts | 11 ++ .../apps/agent/lib/llm-providers/types.ts | 1 + .../apps/eval/scripts/annotate-screenshots.ts | 2 + .../apps/eval/scripts/debug-long-run.ts | 23 ++- .../browseros-agent/apps/server/package.json | 3 +- .../apps/server/src/agent/provider-factory.ts | 16 ++ .../apps/server/src/api/routes/oauth.ts | 11 ++ .../apps/server/src/lib/clients/llm/config.ts | 31 +++- .../server/src/lib/clients/llm/provider.ts | 14 ++ .../src/lib/clients/oauth/copilot-fetch.ts | 135 ++++++++++++++ .../server/src/lib/clients/oauth/providers.ts | 11 ++ .../src/lib/clients/oauth/token-manager.ts | 170 +++++++++++++++++- packages/browseros-agent/biome.json | 2 +- packages/browseros-agent/bun.lock | 21 +-- packages/browseros-agent/package.json | 2 +- .../packages/shared/src/constants/timeouts.ts | 1 + .../packages/shared/src/constants/urls.ts | 3 + .../packages/shared/src/schemas/llm.ts | 3 + 28 files changed, 607 insertions(+), 34 deletions(-) create mode 100644 packages/browseros-agent/apps/server/src/lib/clients/oauth/copilot-fetch.ts diff --git a/packages/browseros-agent/apps/agent/biome.json b/packages/browseros-agent/apps/agent/biome.json index 31b6431f5..e3cc54ea6 100644 --- a/packages/browseros-agent/apps/agent/biome.json +++ b/packages/browseros-agent/apps/agent/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "root": false, "extends": "//", "vcs": { diff --git a/packages/browseros-agent/apps/agent/components/ui/sonner.tsx b/packages/browseros-agent/apps/agent/components/ui/sonner.tsx index 575496e64..0d2af1625 100644 --- a/packages/browseros-agent/apps/agent/components/ui/sonner.tsx +++ b/packages/browseros-agent/apps/agent/components/ui/sonner.tsx @@ -18,6 +18,7 @@ const Toaster = ({ ...props }: ToasterProps) => { , info: , diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx index 5e90e9059..30d875d42 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx @@ -17,6 +17,9 @@ 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' @@ -118,8 +121,16 @@ export const AISettingsPage: FC = () => { 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 @@ -162,6 +173,42 @@ export const AISettingsPage: FC = () => { } }, [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) @@ -173,6 +220,10 @@ export const AISettingsPage: FC = () => { handleStartChatGPTProOAuth() return } + if (template.id === 'github-copilot') { + handleStartGitHubCopilotOAuth() + return + } setTemplateValues({ type: template.id, @@ -207,6 +258,47 @@ export const AISettingsPage: FC = () => { }) } + 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) @@ -223,6 +315,10 @@ export const AISettingsPage: FC = () => { 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) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/NewProviderDialog.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/NewProviderDialog.tsx index 0de60657e..b3b46069f 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/NewProviderDialog.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/NewProviderDialog.tsx @@ -62,6 +62,7 @@ const providerTypeEnum = z.enum([ 'bedrock', 'browseros', 'chatgpt-pro', + 'github-copilot', ]) /** @@ -131,8 +132,8 @@ export const providerFormSchema = z }) } } - // ChatGPT Pro: no credentials needed (server-managed OAuth) - else if (data.type === 'chatgpt-pro') { + // OAuth providers: no credentials needed (server-managed) + else if (data.type === 'chatgpt-pro' || data.type === 'github-copilot') { // No validation needed — OAuth tokens are on the server } // Other providers: require baseUrl @@ -377,8 +378,9 @@ export const NewProviderDialog: FC = ({ const canTest = (): boolean => { if (!watchedModelId) return false - // ChatGPT Pro: always testable (server has the OAuth token) - if (watchedType === 'chatgpt-pro') return true + // OAuth providers: always testable (server has the OAuth token) + if (watchedType === 'chatgpt-pro' || watchedType === 'github-copilot') + return true if (watchedType === 'azure') { return !!(watchedResourceName || watchedBaseUrl) && !!watchedApiKey @@ -461,6 +463,14 @@ export const NewProviderDialog: FC = ({ } const renderProviderSpecificFields = () => { + // GitHub Copilot: OAuth credentials only + if (watchedType === 'github-copilot') { + return ( +
+ Credentials are managed via GitHub OAuth. No API key needed. +
+ ) + } // ChatGPT Pro: OAuth credentials + Codex reasoning settings if (watchedType === 'chatgpt-pro') { return ( diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/models.ts b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/models.ts index d344c3080..4674335df 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/models.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/models.ts @@ -24,6 +24,7 @@ export interface ModelsData { browseros: ModelInfo[] moonshot: ModelInfo[] 'chatgpt-pro': ModelInfo[] + 'github-copilot': ModelInfo[] } /** @@ -101,6 +102,32 @@ export const MODELS_DATA: ModelsData = { { modelId: 'gpt-5.1-codex-mini', contextLength: 400000 }, { modelId: 'gpt-5.1', contextLength: 200000 }, ], + 'github-copilot': [ + // Free tier (unlimited with Pro) + { modelId: 'gpt-5-mini', contextLength: 128000 }, + { modelId: 'claude-haiku-4.5', contextLength: 128000 }, + { modelId: 'gpt-4o', contextLength: 64000 }, + { modelId: 'gpt-4.1', contextLength: 64000 }, + // Premium models (Pro: 300/mo, Pro+: 1500/mo) + { modelId: 'claude-sonnet-4.6', contextLength: 128000 }, + { modelId: 'claude-sonnet-4.5', contextLength: 128000 }, + { modelId: 'claude-sonnet-4', contextLength: 128000 }, + { modelId: 'claude-opus-4.6', contextLength: 128000 }, + { modelId: 'claude-opus-4.5', contextLength: 128000 }, + { modelId: 'gemini-2.5-pro', contextLength: 128000 }, + { modelId: 'gemini-3-pro-preview', contextLength: 128000 }, + { modelId: 'gemini-3-flash-preview', contextLength: 128000 }, + { modelId: 'gemini-3.1-pro-preview', contextLength: 128000 }, + { modelId: 'gpt-5.4', contextLength: 272000 }, + { modelId: 'gpt-5.4-mini', contextLength: 128000 }, + { modelId: 'gpt-5.3-codex', contextLength: 272000 }, + { modelId: 'gpt-5.2-codex', contextLength: 272000 }, + { modelId: 'gpt-5.2', contextLength: 128000 }, + { modelId: 'gpt-5.1-codex', contextLength: 128000 }, + { modelId: 'gpt-5.1-codex-max', contextLength: 128000 }, + { modelId: 'gpt-5.1', contextLength: 128000 }, + { modelId: 'grok-code-fast-1', contextLength: 128000 }, + ], } /** diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/connect-mcp/useGetUserMCPIntegrations.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/connect-mcp/useGetUserMCPIntegrations.tsx index c420a4a08..64c5634a3 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/connect-mcp/useGetUserMCPIntegrations.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/connect-mcp/useGetUserMCPIntegrations.tsx @@ -24,6 +24,7 @@ export const useGetUserMCPIntegrations = () => { const query = useQuery({ queryKey: [INTEGRATIONS_QUERY_KEY, agentServerUrl], + // biome-ignore lint/style/noNonNullAssertion: guarded by enabled queryFn: () => getUserMCPIntegrations(agentServerUrl!), enabled: !!agentServerUrl, refetchOnWindowFocus: true, diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/skills/SkillsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/skills/SkillsPage.tsx index bf3f5bbba..eb68f585e 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/skills/SkillsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/skills/SkillsPage.tsx @@ -1,5 +1,6 @@ import { AlertCircle, Eye, Pencil, Plus, Trash2, Wand2 } from 'lucide-react' import { type FC, useEffect, useState } from 'react' +import Markdown from 'react-markdown' import { toast } from 'sonner' import { AlertDialog, @@ -26,7 +27,6 @@ import { Label } from '@/components/ui/label' import { MarkdownEditor } from '@/components/ui/MarkdownEditor' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' -import Markdown from 'react-markdown' import { type SkillDetail, type SkillMeta, useSkills } from './useSkills' const loadingSkillCards = [ @@ -330,9 +330,15 @@ const SkillCard: FC<{ className="-ml-2 h-7 px-2 text-muted-foreground hover:bg-transparent hover:text-foreground" > {skill.builtIn ? ( - <>View + <> + + View + ) : ( - <>Edit + <> + + Edit + )} {!skill.builtIn ? ( @@ -408,7 +414,11 @@ const SkillDialog: FC<{ - {readOnly ? 'View Skill' : editingSkill ? 'Edit Skill' : 'Create Skill'} + {readOnly + ? 'View Skill' + : editingSkill + ? 'Edit Skill' + : 'Create Skill'} {readOnly @@ -472,7 +482,7 @@ const SkillDialog: FC<{ {readOnly ? ( -
+
{content}
) : ( diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts index 68bdcee96..1e0e41885 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts @@ -502,6 +502,7 @@ export const useChatSession = (options?: ChatSessionOptions) => { if (pending.action) { setTextToAction((prev) => { const next = new Map(prev) + // biome-ignore lint/style/noNonNullAssertion: guarded by if (pending.action) above next.set(pending.text, pending.action!) return next }) diff --git a/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts b/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts index 2c41a9f71..7d80ab101 100644 --- a/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts +++ b/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts @@ -41,6 +41,18 @@ export const CHATGPT_PRO_OAUTH_COMPLETED_EVENT = export const CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT = 'settings.chatgpt_pro.oauth_disconnected' +/** @public */ +export const GITHUB_COPILOT_OAUTH_STARTED_EVENT = + 'settings.github_copilot.oauth_started' + +/** @public */ +export const GITHUB_COPILOT_OAUTH_COMPLETED_EVENT = + 'settings.github_copilot.oauth_completed' + +/** @public */ +export const GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT = + 'settings.github_copilot.oauth_disconnected' + /** @public */ export const HUB_PROVIDER_ADDED_EVENT = 'settings.hub_provider.added' diff --git a/packages/browseros-agent/apps/agent/lib/llm-providers/providerIcons.tsx b/packages/browseros-agent/apps/agent/lib/llm-providers/providerIcons.tsx index ed57330f0..3c1a742b8 100644 --- a/packages/browseros-agent/apps/agent/lib/llm-providers/providerIcons.tsx +++ b/packages/browseros-agent/apps/agent/lib/llm-providers/providerIcons.tsx @@ -9,7 +9,7 @@ import { OpenAI, OpenRouter, } from '@lobehub/icons' -import { Bot } from 'lucide-react' +import { Bot, Github } from 'lucide-react' import type { FC, SVGProps } from 'react' import ProductLogoSvg from '@/assets/product_logo.svg' import type { ProviderType } from './types' @@ -33,6 +33,7 @@ const providerIconMap: Record = { browseros: null, moonshot: Kimi, 'chatgpt-pro': OpenAI, + 'github-copilot': Github, } interface ProviderIconProps { diff --git a/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts b/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts index aa682ca3d..bc2c451ce 100644 --- a/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts +++ b/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts @@ -29,6 +29,15 @@ export const providerTemplates: ProviderTemplate[] = [ contextWindow: 400000, setupGuideUrl: 'https://docs.browseros.com/features/chatgpt-pro-oauth', }, + { + id: 'github-copilot', + name: 'GitHub Copilot', + defaultBaseUrl: 'https://api.githubcopilot.com', + defaultModelId: 'gpt-5-mini', + supportsImages: true, + contextWindow: 128000, + setupGuideUrl: 'https://docs.browseros.com/features/github-copilot-oauth', + }, { id: 'moonshot', name: 'Moonshot AI', @@ -139,6 +148,7 @@ export const providerTemplates: ProviderTemplate[] = [ */ export const providerTypeOptions: { value: ProviderType; label: string }[] = [ { value: 'chatgpt-pro', label: 'ChatGPT Plus/Pro' }, + { value: 'github-copilot', label: 'GitHub Copilot' }, { value: 'moonshot', label: 'Moonshot AI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, @@ -168,6 +178,7 @@ export const getProviderTemplate = ( */ export const DEFAULT_BASE_URLS: Record = { 'chatgpt-pro': 'https://chatgpt.com/backend-api', + 'github-copilot': 'https://api.githubcopilot.com', moonshot: 'https://api.moonshot.ai/v1', anthropic: 'https://api.anthropic.com/v1', openai: 'https://api.openai.com/v1', diff --git a/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts b/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts index 33ed42e85..63a7477e7 100644 --- a/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts +++ b/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts @@ -15,6 +15,7 @@ export type ProviderType = | 'browseros' | 'moonshot' | 'chatgpt-pro' + | 'github-copilot' /** * LLM Provider configuration diff --git a/packages/browseros-agent/apps/eval/scripts/annotate-screenshots.ts b/packages/browseros-agent/apps/eval/scripts/annotate-screenshots.ts index ee2a95e57..436806a23 100644 --- a/packages/browseros-agent/apps/eval/scripts/annotate-screenshots.ts +++ b/packages/browseros-agent/apps/eval/scripts/annotate-screenshots.ts @@ -173,7 +173,9 @@ async function annotateScreenshot( const image = sharp(inputPath) const metadata = await image.metadata() + // biome-ignore lint/style/noNonNullAssertion: sharp metadata always has dimensions for valid images const imgWidth = metadata.width! + // biome-ignore lint/style/noNonNullAssertion: sharp metadata always has dimensions for valid images const imgHeight = metadata.height! const sx = Math.round(action.cssX * dpr) diff --git a/packages/browseros-agent/apps/eval/scripts/debug-long-run.ts b/packages/browseros-agent/apps/eval/scripts/debug-long-run.ts index 078698f48..115c8e7bf 100644 --- a/packages/browseros-agent/apps/eval/scripts/debug-long-run.ts +++ b/packages/browseros-agent/apps/eval/scripts/debug-long-run.ts @@ -49,10 +49,13 @@ async function callMcpTool( const result = await Promise.race([toolPromise, timeoutPromise]) const duration = Date.now() - start - if ((result as any).isError) { + const res = result as Record + if (res.isError) { + const content = res.content as + | Array<{ type: string; text?: string }> + | undefined const errorText = - (result as any).content?.find((c: any) => c.type === 'text')?.text || - 'Unknown error' + content?.find((c) => c.type === 'text')?.text || 'Unknown error' return { success: false, error: errorText, duration } } @@ -96,13 +99,19 @@ async function main() { }) // Try structured content first - windowId = (result as any).structuredContent?.windowId - tabId = (result as any).structuredContent?.tabId + const createRes = result as Record + const structured = createRes.structuredContent as + | Record + | undefined + windowId = structured?.windowId ?? 0 + tabId = structured?.tabId ?? 0 // Fall back to parsing text if (!windowId || !tabId) { - const text = - (result as any).content?.find((c: any) => c.type === 'text')?.text || '' + const content = createRes.content as + | Array<{ type: string; text?: string }> + | undefined + const text = content?.find((c) => c.type === 'text')?.text || '' const windowMatch = text.match(/window\s+(\d+)/i) const tabMatch = text.match(/Tab ID:\s*(\d+)/i) || text.match(/tab\s+(\d+)/i) diff --git a/packages/browseros-agent/apps/server/package.json b/packages/browseros-agent/apps/server/package.json index 6c47a0df2..6b0b4143b 100644 --- a/packages/browseros-agent/apps/server/package.json +++ b/packages/browseros-agent/apps/server/package.json @@ -63,8 +63,8 @@ "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", - "@ai-sdk/google": "^3.0.30", "@ai-sdk/devtools": "^0.0.15", + "@ai-sdk/google": "^3.0.30", "@ai-sdk/mcp": "^1.0.21", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/openai-compatible": "^2.0.30", @@ -93,6 +93,7 @@ "pino": "^9.6.0", "posthog-node": "^4.17.0", "puppeteer-core": "24.23.0", + "sharp": "^0.34.5", "ws": "^8.18.0", "zod": "^3.24.2", "zod-from-json-schema": "^0.1.0" diff --git a/packages/browseros-agent/apps/server/src/agent/provider-factory.ts b/packages/browseros-agent/apps/server/src/agent/provider-factory.ts index c5ff30a80..b6005dc91 100644 --- a/packages/browseros-agent/apps/server/src/agent/provider-factory.ts +++ b/packages/browseros-agent/apps/server/src/agent/provider-factory.ts @@ -4,10 +4,12 @@ import { createAzure } from '@ai-sdk/azure' import { createGoogleGenerativeAI } from '@ai-sdk/google' import { createOpenAI } from '@ai-sdk/openai' import { createOpenAICompatible } from '@ai-sdk/openai-compatible' +import { EXTERNAL_URLS } from '@browseros/shared/constants/urls' import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm' import { createOpenRouter } from '@openrouter/ai-sdk-provider' import type { LanguageModel } from 'ai' import { createCodexFetch } from '../lib/clients/oauth/codex-fetch' +import { createCopilotFetch } from '../lib/clients/oauth/copilot-fetch' import { logger } from '../lib/logger' import { createOpenRouterCompatibleFetch } from '../lib/openrouter-fetch' import type { ResolvedAgentConfig } from './types' @@ -149,6 +151,19 @@ function createMoonshotFactory( }) } +function createGitHubCopilotFactory( + config: ResolvedAgentConfig, +): (modelId: string) => unknown { + if (!config.apiKey) + throw new Error('GitHub Copilot requires OAuth authentication') + return createOpenAICompatible({ + name: 'github-copilot', + baseURL: EXTERNAL_URLS.GITHUB_COPILOT_API, + apiKey: config.apiKey, + fetch: createCopilotFetch() as typeof globalThis.fetch, + }) +} + function createChatGPTProFactory( config: ResolvedAgentConfig, ): (modelId: string) => unknown { @@ -173,6 +188,7 @@ const PROVIDER_FACTORIES: Record = { [LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleFactory, [LLM_PROVIDERS.MOONSHOT]: createMoonshotFactory, [LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProFactory, + [LLM_PROVIDERS.GITHUB_COPILOT]: createGitHubCopilotFactory, } export function createLanguageModel( diff --git a/packages/browseros-agent/apps/server/src/api/routes/oauth.ts b/packages/browseros-agent/apps/server/src/api/routes/oauth.ts index 592e29074..afb319fad 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/oauth.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/oauth.ts @@ -29,6 +29,17 @@ export function createOAuthRoutes(deps: OAuthRouteDeps) { } try { + // Device Code flow: return JSON with user code for the extension to display + if (provider.authFlow === 'device-code') { + const result = await tokenManager.startDeviceCodeFlow(providerId) + return c.json({ + userCode: result.userCode, + verificationUri: result.verificationUri, + expiresIn: result.expiresIn, + }) + } + + // PKCE flow: redirect to auth server const authUrl = await tokenManager.generateAuthorizationUrl( providerId, redirectBackUrl, diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts index 747f2b87e..8f9cbb92e 100644 --- a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts +++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts @@ -17,10 +17,13 @@ export async function resolveLLMConfig( config: LLMConfig, browserosId?: string, ): Promise { - // ChatGPT Pro: resolve OAuth token from server-side storage + // OAuth providers: resolve token from server-side storage if (config.provider === LLM_PROVIDERS.CHATGPT_PRO) { return resolveChatGPTProConfig(config, browserosId) } + if (config.provider === LLM_PROVIDERS.GITHUB_COPILOT) { + return resolveGitHubCopilotConfig(config, browserosId) + } // BrowserOS gateway: fetch config from remote service if (config.provider === LLM_PROVIDERS.BROWSEROS) { @@ -61,6 +64,32 @@ async function resolveChatGPTProConfig( } } +async function resolveGitHubCopilotConfig( + config: LLMConfig, + browserosId?: string, +): Promise { + const tokenManager = getOAuthTokenManager() + if (!tokenManager || !browserosId) { + throw new Error( + 'Not authenticated with GitHub Copilot. Please login first.', + ) + } + + // GitHub tokens never expire — no refresh needed + const tokens = tokenManager.getTokens('github-copilot') + if (!tokens) { + throw new Error( + 'Not authenticated with GitHub Copilot. Please login first.', + ) + } + + return { + ...config, + model: config.model || 'gpt-5-mini', + apiKey: tokens.accessToken, + } +} + async function resolveBrowserOSConfig( config: LLMConfig, browserosId?: string, diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts index 984fd3321..f8beaa868 100644 --- a/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts +++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts @@ -12,12 +12,14 @@ import { createAzure } from '@ai-sdk/azure' import { createGoogleGenerativeAI } from '@ai-sdk/google' import { createOpenAI } from '@ai-sdk/openai' import { createOpenAICompatible } from '@ai-sdk/openai-compatible' +import { EXTERNAL_URLS } from '@browseros/shared/constants/urls' import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm' import { createOpenRouter } from '@openrouter/ai-sdk-provider' import type { LanguageModel } from 'ai' import { logger } from '../../logger' import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch' import { createCodexFetch } from '../oauth/codex-fetch' +import { createCopilotFetch } from '../oauth/copilot-fetch' import type { ResolvedLLMConfig } from './types' type ProviderFactory = (config: ResolvedLLMConfig) => LanguageModel @@ -135,6 +137,17 @@ function createMoonshotModel(config: ResolvedLLMConfig): LanguageModel { })(config.model) } +function createGitHubCopilotModel(config: ResolvedLLMConfig): LanguageModel { + if (!config.apiKey) + throw new Error('GitHub Copilot requires OAuth authentication') + return createOpenAICompatible({ + name: 'github-copilot', + baseURL: EXTERNAL_URLS.GITHUB_COPILOT_API, + apiKey: config.apiKey, + fetch: createCopilotFetch() as typeof globalThis.fetch, + })(config.model) +} + function createChatGPTProModel(config: ResolvedLLMConfig): LanguageModel { if (!config.apiKey) throw new Error('ChatGPT Plus/Pro requires OAuth authentication') @@ -157,6 +170,7 @@ const PROVIDER_FACTORIES: Record = { [LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleModel, [LLM_PROVIDERS.MOONSHOT]: createMoonshotModel, [LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProModel, + [LLM_PROVIDERS.GITHUB_COPILOT]: createGitHubCopilotModel, } export function createLLMProvider(config: ResolvedLLMConfig): LanguageModel { diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/copilot-fetch.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/copilot-fetch.ts new file mode 100644 index 000000000..fd391f7e3 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/copilot-fetch.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Custom fetch wrapper for GitHub Copilot API requests. + * Injects required Copilot headers and resizes images following + * VS Code's algorithm (max 2048px longest side, 768px shortest side). + */ + +import sharp from 'sharp' +import { logger } from '../../logger' + +const MAX_LONG_SIDE = 2048 +const MAX_SHORT_SIDE = 768 + +export function createCopilotFetch() { + return async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers as HeadersInit) + + headers.set('Openai-Intent', 'conversation-edits') + headers.set('x-initiator', 'user') + + let body = init?.body + if (body && typeof body === 'string') { + try { + const json = JSON.parse(body) + if (hasImageContent(json)) { + headers.set('Copilot-Vision-Request', 'true') + await shrinkImages(json) + body = JSON.stringify(json) + } + } catch { + // Not JSON or resize failed, send as-is + } + } + + return fetch(input, { ...init, headers, body }) + } +} + +function hasImageContent(body: Record): boolean { + if (!Array.isArray(body.messages)) return false + for (const msg of body.messages) { + if (!Array.isArray(msg?.content)) continue + for (const part of msg.content) { + if (part?.type === 'image_url') return true + } + } + return false +} + +// Resize images following VS Code's algorithm for OpenAI vision token optimization +async function shrinkImages(body: Record): Promise { + if (!Array.isArray(body.messages)) return + + for (const msg of body.messages) { + if (!Array.isArray(msg?.content)) continue + for (const part of msg.content) { + if (part?.type !== 'image_url' || !part.image_url) continue + + const url = part.image_url.url as string + if (!url?.startsWith('data:')) continue + + try { + const resized = await resizeDataUrl(url) + if (resized) part.image_url.url = resized + } catch (err) { + logger.warn('Failed to resize image for Copilot', { + error: err instanceof Error ? err.message : String(err), + }) + } + } + } +} + +async function resizeDataUrl(dataUrl: string): Promise { + const commaIdx = dataUrl.indexOf(',') + if (commaIdx === -1) return null + + const base64Data = dataUrl.substring(commaIdx + 1) + const buffer = Buffer.from(base64Data, 'base64') + + const image = sharp(buffer) + const metadata = await image.metadata() + if (!metadata.width || !metadata.height) return null + + let { width, height } = metadata + + // Skip if already within both limits (no resize step will fire) + if ( + Math.max(width, height) <= MAX_LONG_SIDE && + Math.min(width, height) <= MAX_SHORT_SIDE + ) { + return null + } + + // Step 1: scale longest side to 2048 + if (width > MAX_LONG_SIDE || height > MAX_LONG_SIDE) { + const scale = MAX_LONG_SIDE / Math.max(width, height) + width = Math.round(width * scale) + height = Math.round(height * scale) + } + + // Step 2: scale shortest side to 768 + const shortSide = Math.min(width, height) + if (shortSide > MAX_SHORT_SIDE) { + const scale = MAX_SHORT_SIDE / shortSide + width = Math.round(width * scale) + height = Math.round(height * scale) + } + + // Preserve PNG for images with alpha, use JPEG otherwise + const hasAlpha = metadata.channels === 4 || metadata.hasAlpha + const resizedBuffer = hasAlpha + ? await sharp(buffer) + .resize(width, height, { fit: 'inside' }) + .png() + .toBuffer() + : await sharp(buffer) + .resize(width, height, { fit: 'inside' }) + .jpeg({ quality: 75 }) + .toBuffer() + + const mime = hasAlpha ? 'image/png' : 'image/jpeg' + const originalKB = Math.round(base64Data.length / 1024) + const resizedB64 = resizedBuffer.toString('base64') + const resizedKB = Math.round(resizedB64.length / 1024) + logger.debug('Resized image for Copilot', { + original: `${metadata.width}x${metadata.height} (${originalKB}KB)`, + resized: `${width}x${height} (${resizedKB}KB)`, + }) + + return `data:${mime};base64,${resizedB64}` +} diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/providers.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/providers.ts index d74dfba0a..d3d4c30d9 100644 --- a/packages/browseros-agent/apps/server/src/lib/clients/oauth/providers.ts +++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/providers.ts @@ -15,6 +15,7 @@ export interface OAuthProviderConfig { scopes: string[] extraAuthParams?: Record upstreamLLMProvider: string + authFlow?: 'pkce' | 'device-code' } export const OAUTH_PROVIDERS: Record = { @@ -32,6 +33,16 @@ export const OAUTH_PROVIDERS: Record = { }, upstreamLLMProvider: 'openai', }, + 'github-copilot': { + id: 'github-copilot', + name: 'GitHub Copilot', + clientId: 'Ov23li8tweQw6odWQebz', + authEndpoint: EXTERNAL_URLS.GITHUB_DEVICE_CODE, + tokenEndpoint: EXTERNAL_URLS.GITHUB_OAUTH_TOKEN, + scopes: ['read:user'], + upstreamLLMProvider: 'github-copilot', + authFlow: 'device-code', + }, } export function getOAuthProvider( diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-manager.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-manager.ts index 803e65e17..704fa8ec9 100644 --- a/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-manager.ts +++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-manager.ts @@ -25,6 +25,26 @@ interface OAuthTokenResponse { id_token?: string } +export interface DeviceCodeResult { + userCode: string + verificationUri: string + expiresIn: number +} + +interface GitHubDeviceCodeResponse { + device_code: string + user_code: string + verification_uri: string + expires_in: number + interval: number +} + +interface GitHubTokenPollResponse { + access_token?: string + error?: string + interval?: number +} + export class OAuthTokenManager { private readonly pendingFlows = new Map() private readonly refreshLocks = new Map< @@ -37,6 +57,8 @@ export class OAuthTokenManager { private readonly browserosId: string, ) {} + // --- PKCE flow (ChatGPT Plus/Pro) --- + async generateAuthorizationUrl( providerId: string, redirectBackUrl?: string, @@ -138,16 +160,150 @@ export class OAuthTokenManager { return { tokens, redirectBackUrl: flow.redirectBackUrl } } - // Mutex-protected refresh: concurrent callers share one in-flight refresh + // --- Device Code flow (GitHub Copilot) --- + + private readonly activeDeviceFlows = new Set() + + async startDeviceCodeFlow(providerId: string): Promise { + const provider = getOAuthProvider(providerId) + if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`) + + // Cancel any existing flow — user may be retrying + this.activeDeviceFlows.delete(providerId) + + // Request a device code from GitHub + const response = await fetch(provider.authEndpoint, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: provider.clientId, + scope: provider.scopes.join(' '), + }), + }) + + if (!response.ok) { + throw new Error(`Failed to request device code: ${response.status}`) + } + + const data = (await response.json()) as GitHubDeviceCodeResponse + + // GitHub can return 200 with an error payload (e.g. invalid scope) + const dataObj = data as unknown as Record + if ('error' in dataObj) { + throw new Error(`GitHub device code error: ${dataObj.error}`) + } + if (!data.device_code || !data.user_code) { + throw new Error('Invalid device code response from GitHub') + } + + // Start background polling with error handling + this.activeDeviceFlows.add(providerId) + this.pollDeviceCode( + providerId, + provider, + data.device_code, + data.interval, + data.expires_in, + ).finally(() => this.activeDeviceFlows.delete(providerId)) + + return { + userCode: data.user_code, + verificationUri: data.verification_uri, + expiresIn: data.expires_in, + } + } + + private async pollDeviceCode( + providerId: string, + provider: ReturnType & {}, + deviceCode: string, + initialInterval: number, + expiresIn: number, + ): Promise { + let interval = initialInterval + const deadline = Date.now() + expiresIn * 1000 + + while (Date.now() < deadline) { + // Wait before polling (interval + safety margin per OpenCode pattern) + await sleep(interval * 1000 + TIMEOUTS.DEVICE_CODE_POLL_SAFETY_MARGIN) + + try { + const response = await fetch(provider.tokenEndpoint, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: provider.clientId, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + }) + + const data = (await response.json()) as GitHubTokenPollResponse + + // Token received — store it and return + if (data.access_token) { + const tokens: StoredOAuthTokens = { + accessToken: data.access_token, + refreshToken: '', + expiresAt: 0, + email: undefined, + accountId: undefined, + } + this.store.upsertTokens(this.browserosId, providerId, tokens) + logger.info('Device code OAuth successful', { provider: providerId }) + return + } + + // Handle polling errors per RFC 8628 + if (data.error === 'authorization_pending') continue + if (data.error === 'slow_down') { + interval = (data.interval ?? interval) + 5 + continue + } + if (data.error === 'expired_token' || data.error === 'access_denied') { + logger.warn('Device code flow ended', { + provider: providerId, + error: data.error, + }) + return + } + + logger.warn('Unexpected device code poll response', { + provider: providerId, + error: data.error, + }) + return + } catch (err) { + // Transient network error — loop continues to retry + logger.warn('Device code poll request failed, retrying', { + provider: providerId, + error: err instanceof Error ? err.message : String(err), + }) + } + } + + logger.warn('Device code flow timed out', { provider: providerId }) + } + + // --- Token refresh (PKCE providers only) --- + async refreshIfExpired(provider: string): Promise { const tokens = this.store.getTokens(this.browserosId, provider) if (!tokens) return null + // GitHub Copilot tokens never expire (expiresAt = 0) + if (tokens.expiresAt === 0) return tokens + if (Date.now() < tokens.expiresAt - TIMEOUTS.OAUTH_TOKEN_EXPIRY_BUFFER) { return tokens } - // If a refresh is already in progress, await it instead of starting another const existing = this.refreshLocks.get(provider) if (existing) return existing @@ -214,6 +370,12 @@ export class OAuthTokenManager { return refreshed } + // --- Shared --- + + getTokens(provider: string): StoredOAuthTokens | null { + return this.store.getTokens(this.browserosId, provider) + } + getStatus(provider: string) { return this.store.getStatus(this.browserosId, provider) } @@ -257,6 +419,10 @@ function base64UrlEncode(bytes: Uint8Array): string { return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + // Extracts claims without signature verification — safe because the token // comes directly from OpenAI's HTTPS token endpoint. Do not reuse for // caller-supplied or externally-sourced tokens. diff --git a/packages/browseros-agent/biome.json b/packages/browseros-agent/biome.json index d98bd072d..d414afc9b 100644 --- a/packages/browseros-agent/biome.json +++ b/packages/browseros-agent/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/packages/browseros-agent/bun.lock b/packages/browseros-agent/bun.lock index 522881c9d..0327d26e2 100644 --- a/packages/browseros-agent/bun.lock +++ b/packages/browseros-agent/bun.lock @@ -6,7 +6,7 @@ "name": "browseros-monorepo", "devDependencies": { "@aws-sdk/client-s3": "^3.933.0", - "@biomejs/biome": "2.4.5", + "@biomejs/biome": "2.4.8", "@sentry/cli": "^2.42.2", "@types/bun": "^1.3.5", "@types/node": "^24.3.3", @@ -207,6 +207,7 @@ "pino": "^9.6.0", "posthog-node": "^4.17.0", "puppeteer-core": "24.23.0", + "sharp": "^0.34.5", "ws": "^8.18.0", "zod": "^3.24.2", "zod-from-json-schema": "^0.1.0", @@ -450,23 +451,23 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], - "@biomejs/biome": ["@biomejs/biome@2.4.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.5", "@biomejs/cli-darwin-x64": "2.4.5", "@biomejs/cli-linux-arm64": "2.4.5", "@biomejs/cli-linux-arm64-musl": "2.4.5", "@biomejs/cli-linux-x64": "2.4.5", "@biomejs/cli-linux-x64-musl": "2.4.5", "@biomejs/cli-win32-arm64": "2.4.5", "@biomejs/cli-win32-x64": "2.4.5" }, "bin": { "biome": "bin/biome" } }, "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ=="], + "@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="], "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], diff --git a/packages/browseros-agent/package.json b/packages/browseros-agent/package.json index a5cda1b16..151342fe7 100644 --- a/packages/browseros-agent/package.json +++ b/packages/browseros-agent/package.json @@ -45,7 +45,7 @@ "homepage": "https://github.com/browseros-ai/BrowserOS#readme", "devDependencies": { "@aws-sdk/client-s3": "^3.933.0", - "@biomejs/biome": "2.4.5", + "@biomejs/biome": "2.4.8", "@sentry/cli": "^2.42.2", "@types/bun": "^1.3.5", "@types/node": "^24.3.3", diff --git a/packages/browseros-agent/packages/shared/src/constants/timeouts.ts b/packages/browseros-agent/packages/shared/src/constants/timeouts.ts index 4436bf58f..0d0480ec2 100644 --- a/packages/browseros-agent/packages/shared/src/constants/timeouts.ts +++ b/packages/browseros-agent/packages/shared/src/constants/timeouts.ts @@ -54,6 +54,7 @@ export const TIMEOUTS = { OAUTH_TOKEN_EXPIRY_BUFFER: 300_000, OAUTH_POLL_INTERVAL: 2_000, OAUTH_POLL_TIMEOUT: 300_000, + DEVICE_CODE_POLL_SAFETY_MARGIN: 3_000, } as const export type TimeoutKey = keyof typeof TIMEOUTS diff --git a/packages/browseros-agent/packages/shared/src/constants/urls.ts b/packages/browseros-agent/packages/shared/src/constants/urls.ts index 2995e0955..290b4b3eb 100644 --- a/packages/browseros-agent/packages/shared/src/constants/urls.ts +++ b/packages/browseros-agent/packages/shared/src/constants/urls.ts @@ -13,4 +13,7 @@ export const EXTERNAL_URLS = { OPENAI_AUTH: 'https://auth.openai.com/oauth/authorize', OPENAI_TOKEN: 'https://auth.openai.com/oauth/token', SKILLS_CATALOG: 'https://cdn.browseros.com/skills/v1/catalog.json', + GITHUB_DEVICE_CODE: 'https://github.com/login/device/code', + GITHUB_OAUTH_TOKEN: 'https://github.com/login/oauth/access_token', + GITHUB_COPILOT_API: 'https://api.githubcopilot.com', } as const diff --git a/packages/browseros-agent/packages/shared/src/schemas/llm.ts b/packages/browseros-agent/packages/shared/src/schemas/llm.ts index 01f2acb98..1b8bf5aea 100644 --- a/packages/browseros-agent/packages/shared/src/schemas/llm.ts +++ b/packages/browseros-agent/packages/shared/src/schemas/llm.ts @@ -25,6 +25,7 @@ export const LLM_PROVIDERS = { OPENAI_COMPATIBLE: 'openai-compatible', MOONSHOT: 'moonshot', CHATGPT_PRO: 'chatgpt-pro', + GITHUB_COPILOT: 'github-copilot', } as const /** @@ -44,6 +45,7 @@ export const LLMProviderSchema: z.ZodEnum< 'openai-compatible', 'moonshot', 'chatgpt-pro', + 'github-copilot', ] > = z.enum([ LLM_PROVIDERS.ANTHROPIC, @@ -58,6 +60,7 @@ export const LLMProviderSchema: z.ZodEnum< LLM_PROVIDERS.OPENAI_COMPATIBLE, LLM_PROVIDERS.MOONSHOT, LLM_PROVIDERS.CHATGPT_PRO, + LLM_PROVIDERS.GITHUB_COPILOT, ]) export type LLMProvider = z.infer