diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 049a3557..33d80dbd 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -29,6 +29,162 @@ interface ModelsDevModel { }; } +const COPILOT_STATIC_HEADERS = { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.105.1", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "copilot-developer-cli", + "Openai-Intent": "conversation-edits", + "X-Initiator": "agent", +} as const; + +function getGitHubTokenFromEnv(): string | null { + return process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null; +} + +function isDeprecatedCopilotModel(model: unknown): boolean { + if (!model || typeof model !== "object") return false; + const m = model as Record; + if (m.deprecated === true) return true; + if (m.is_deprecated === true) return true; + if (m.status === "deprecated") return true; + if (m.lifecycle === "deprecated") return true; + const id = typeof m.id === "string" ? m.id : ""; + return id.includes("deprecated"); +} + +function supportsToolsCopilotModel(model: unknown): boolean { + if (!model || typeof model !== "object") return true; + const m = model as Record; + const caps = m.capabilities; + if (!caps || typeof caps !== "object") return true; + const tools = (caps as Record).tools; + if (tools === undefined) return true; + return tools !== false; +} + +function supportsVisionCopilotModel(model: unknown): boolean { + if (!model || typeof model !== "object") return false; + const m = model as Record; + const caps = m.capabilities; + if (!caps || typeof caps !== "object") return false; + const vision = (caps as Record).vision; + if (vision === true) return true; + const modalities = (caps as Record).modalities; + if (Array.isArray(modalities)) return modalities.includes("vision") || modalities.includes("image"); + return false; +} + +function getNumberField(model: unknown, keys: string[], fallback: number): number { + if (!model || typeof model !== "object") return fallback; + const m = model as Record; + for (const key of keys) { + const value = m[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return fallback; +} + +async function fetchCopilotModels(githubToken: string): Promise[]> { + try { + console.log("Fetching models from GitHub Copilot API..."); + const response = await fetch("https://api.githubcopilot.com/models", { + headers: { + Accept: "application/json", + Authorization: `Bearer ${githubToken}`, + ...COPILOT_STATIC_HEADERS, + }, + }); + + if (!response.ok) { + const text = await response.text(); + console.warn(`Failed to fetch GitHub Copilot models: ${response.status} ${text}`); + return []; + } + + const data = (await response.json()) as unknown; + const list = + Array.isArray(data) ? data : Array.isArray((data as any)?.models) ? (data as any).models : (data as any)?.data; + if (!Array.isArray(list)) { + console.warn("Failed to parse GitHub Copilot models response"); + return []; + } + + const models: Model[] = []; + + for (const item of list) { + if (isDeprecatedCopilotModel(item)) continue; + if (!supportsToolsCopilotModel(item)) continue; + + const id = typeof (item as any)?.id === "string" ? (item as any).id : typeof item === "string" ? item : null; + if (!id) continue; + + const name = typeof (item as any)?.name === "string" ? (item as any).name : id; + const contextWindow = getNumberField(item, ["context_window", "contextWindow", "max_context_tokens"], 128000); + const maxTokens = getNumberField(item, ["max_output_tokens", "maxTokens", "max_tokens"], 8192); + const input: ("text" | "image")[] = supportsVisionCopilotModel(item) ? ["text", "image"] : ["text"]; + + models.push({ + id, + name, + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.githubcopilot.com", + reasoning: false, + input, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow, + maxTokens, + headers: { ...COPILOT_STATIC_HEADERS }, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + }, + }); + } + + console.log(`Fetched ${models.length} models from GitHub Copilot`); + return models; + } catch (error) { + console.warn("Failed to fetch GitHub Copilot models:", error); + return []; + } +} + +function getFallbackCopilotModels(): Model[] { + const fallbackModelIds = ["gpt-4o", "gpt-4o-mini", "claude-3.5-sonnet", "o1", "o1-mini"]; + + return fallbackModelIds.map((id) => ({ + id, + name: id, + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.githubcopilot.com", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + headers: { ...COPILOT_STATIC_HEADERS }, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + }, + })); +} + async function fetchOpenRouterModels(): Promise[]> { try { console.log("Fetching models from OpenRouter API..."); @@ -321,6 +477,20 @@ async function generateModels() { // Combine models (models.dev has priority) const allModels = [...modelsDevModels, ...openRouterModels]; + const githubToken = getGitHubTokenFromEnv(); + let copilotModels: Model[] = []; + if (!githubToken) { + console.warn("No GitHub token found for GitHub Copilot model discovery (set COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN). Using fallback list."); + copilotModels = getFallbackCopilotModels(); + } else { + copilotModels = await fetchCopilotModels(githubToken); + if (copilotModels.length === 0) { + console.warn("No GitHub Copilot models fetched. Using fallback list."); + copilotModels = getFallbackCopilotModels(); + } + } + allModels.push(...copilotModels); + // Fix incorrect cache pricing for Claude Opus 4.5 from models.dev // models.dev has 3x the correct pricing (1.5/18.75 instead of 0.5/6.25) const opus45 = allModels.find(m => m.provider === "anthropic" && m.id === "claude-opus-4-5"); @@ -458,7 +628,7 @@ export const MODELS = { // Generate provider sections for (const [providerId, models] of Object.entries(providers)) { - output += `\t${providerId}: {\n`; + output += `\t${JSON.stringify(providerId)}: {\n`; for (const model of Object.values(models)) { output += `\t\t"${model.id}": {\n`; @@ -469,6 +639,12 @@ export const MODELS = { if (model.baseUrl) { output += `\t\t\tbaseUrl: "${model.baseUrl}",\n`; } + if (model.headers) { + output += `\t\t\theaders: ${JSON.stringify(model.headers)},\n`; + } + if ((model as any).compat) { + output += `\t\t\tcompat: ${JSON.stringify((model as any).compat)},\n`; + } output += `\t\t\treasoning: ${model.reasoning},\n`; output += `\t\t\tinput: [${model.input.map(i => `"${i}"`).join(", ")}],\n`; output += `\t\t\tcost: {\n`; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 59bd405a..7043763a 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5733,23 +5733,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "cohere/command-r-08-2024": { - id: "cohere/command-r-08-2024", - name: "Cohere: Command R (08-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4000, - } satisfies Model<"openai-completions">, "cohere/command-r-plus-08-2024": { id: "cohere/command-r-plus-08-2024", name: "Cohere: Command R+ (08-2024)", @@ -5767,6 +5750,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4000, } satisfies Model<"openai-completions">, + "cohere/command-r-08-2024": { + id: "cohere/command-r-08-2024", + name: "Cohere: Command R (08-2024)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"openai-completions">, "sao10k/l3.1-euryale-70b": { id: "sao10k/l3.1-euryale-70b", name: "Sao10K: Llama 3.1 Euryale 70B v2.2", @@ -5835,23 +5835,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-405b-instruct": { - id: "meta-llama/llama-3.1-405b-instruct", - name: "Meta: Llama 3.1 405B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3.5, - output: 3.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 130815, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-70b-instruct": { id: "meta-llama/llama-3.1-70b-instruct", name: "Meta: Llama 3.1 70B Instruct", @@ -5869,6 +5852,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-405b-instruct": { + id: "meta-llama/llama-3.1-405b-instruct", + name: "Meta: Llama 3.1 405B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3.5, + output: 3.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 130815, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -5886,9 +5886,9 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini-2024-07-18": { - id: "openai/gpt-4o-mini-2024-07-18", - name: "OpenAI: GPT-4o-mini (2024-07-18)", + "openai/gpt-4o-mini": { + id: "openai/gpt-4o-mini", + name: "OpenAI: GPT-4o-mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5903,9 +5903,9 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini": { - id: "openai/gpt-4o-mini", - name: "OpenAI: GPT-4o-mini", + "openai/gpt-4o-mini-2024-07-18": { + id: "openai/gpt-4o-mini-2024-07-18", + name: "OpenAI: GPT-4o-mini (2024-07-18)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6056,23 +6056,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "meta-llama/llama-3-70b-instruct": { - id: "meta-llama/llama-3-70b-instruct", - name: "Meta: Llama 3 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "meta-llama/llama-3-8b-instruct": { id: "meta-llama/llama-3-8b-instruct", name: "Meta: Llama 3 8B Instruct", @@ -6090,6 +6073,23 @@ export const MODELS = { contextWindow: 8192, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3-70b-instruct": { + id: "meta-llama/llama-3-70b-instruct", + name: "Meta: Llama 3 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { id: "mistralai/mixtral-8x22b-instruct", name: "Mistral: Mixtral 8x22B Instruct", @@ -6175,23 +6175,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo-0613": { - id: "openai/gpt-3.5-turbo-0613", - name: "OpenAI: GPT-3.5 Turbo (older v0613)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4095, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4-turbo-preview": { id: "openai/gpt-4-turbo-preview", name: "OpenAI: GPT-4 Turbo Preview", @@ -6209,6 +6192,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo-0613": { + id: "openai/gpt-3.5-turbo-0613", + name: "OpenAI: GPT-3.5 Turbo (older v0613)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 4095, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-tiny": { id: "mistralai/mistral-tiny", name: "Mistral Tiny", @@ -6277,9 +6277,9 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4-0314": { - id: "openai/gpt-4-0314", - name: "OpenAI: GPT-4 (older v0314)", + "openai/gpt-4": { + id: "openai/gpt-4", + name: "OpenAI: GPT-4", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6294,9 +6294,9 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4": { - id: "openai/gpt-4", - name: "OpenAI: GPT-4", + "openai/gpt-4-0314": { + id: "openai/gpt-4-0314", + name: "OpenAI: GPT-4 (older v0314)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6346,4 +6346,136 @@ export const MODELS = { maxTokens: 30000, } satisfies Model<"openai-completions">, }, + "github-copilot": { + "gpt-4o": { + id: "gpt-4o", + name: "gpt-4o", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.githubcopilot.com", + headers: { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.105.1", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "copilot-developer-cli", + "Openai-Intent": "conversation-edits", + "X-Initiator": "agent", + }, + compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false }, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "gpt-4o-mini": { + id: "gpt-4o-mini", + name: "gpt-4o-mini", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.githubcopilot.com", + headers: { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.105.1", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "copilot-developer-cli", + "Openai-Intent": "conversation-edits", + "X-Initiator": "agent", + }, + compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false }, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "claude-3.5-sonnet": { + id: "claude-3.5-sonnet", + name: "claude-3.5-sonnet", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.githubcopilot.com", + headers: { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.105.1", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "copilot-developer-cli", + "Openai-Intent": "conversation-edits", + "X-Initiator": "agent", + }, + compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false }, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + o1: { + id: "o1", + name: "o1", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.githubcopilot.com", + headers: { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.105.1", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "copilot-developer-cli", + "Openai-Intent": "conversation-edits", + "X-Initiator": "agent", + }, + compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false }, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "o1-mini": { + id: "o1-mini", + name: "o1-mini", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.githubcopilot.com", + headers: { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.105.1", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "copilot-developer-cli", + "Openai-Intent": "conversation-edits", + "X-Initiator": "agent", + }, + compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false }, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + }, } as const; diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 35246879..adf37357 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -31,6 +31,10 @@ export function getApiKey(provider: any): string | undefined { if (key) return key; // Fall back to environment variables + if (provider === "github-copilot") { + return process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + } + const envMap: Record = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 45dd396d..83d1d892 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -30,6 +30,7 @@ export type KnownProvider = | "anthropic" | "google" | "openai" + | "github-copilot" | "xai" | "groq" | "cerebras" diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index bf65c9d3..d971f2d4 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -3,8 +3,9 @@ import { type Static, Type } from "@sinclair/typebox"; import AjvModule from "ajv"; import { existsSync, readFileSync } from "fs"; import { getModelsPath } from "../config.js"; +import { getGitHubCopilotBaseUrl, normalizeDomain, refreshGitHubCopilotToken } from "./oauth/github-copilot.js"; import { getOAuthToken, type SupportedOAuthProvider } from "./oauth/index.js"; -import { loadOAuthCredentials } from "./oauth/storage.js"; +import { loadOAuthCredentials, saveOAuthCredentials } from "./oauth/storage.js"; // Handle both default and named exports const Ajv = (AjvModule as any).default || AjvModule; @@ -239,8 +240,23 @@ export function loadAndMergeModels(): { models: Model[]; error: string | nu return { models: [], error }; } - // Merge: custom models come after built-in - return { models: [...builtInModels, ...customModels], error: null }; + const combined = [...builtInModels, ...customModels]; + + const copilotCreds = loadOAuthCredentials("github-copilot"); + if (copilotCreds?.enterpriseUrl) { + const domain = normalizeDomain(copilotCreds.enterpriseUrl); + if (domain) { + const baseUrl = getGitHubCopilotBaseUrl(domain); + return { + models: combined.map((m) => + m.provider === "github-copilot" && m.baseUrl === "https://api.githubcopilot.com" ? { ...m, baseUrl } : m, + ), + error: null, + }; + } + } + + return { models: combined, error: null }; } /** @@ -271,6 +287,26 @@ export async function getApiKeyForModel(model: Model): Promise[]; erro } const availableModels: Model[] = []; + const copilotCreds = loadOAuthCredentials("github-copilot"); + const hasCopilotEnv = !!(process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN); + const hasCopilot = !!copilotCreds || hasCopilotEnv; + for (const model of allModels) { + if (model.provider === "github-copilot") { + if (hasCopilot) { + availableModels.push(model); + } + continue; + } + const apiKey = await getApiKeyForModel(model); if (apiKey) { availableModels.push(model); @@ -318,7 +365,7 @@ export function findModel(provider: string, modelId: string): { model: Model = { anthropic: "anthropic", - // Add more mappings as OAuth support is added for other providers + "github-copilot": "github-copilot", }; // Cache for OAuth status per provider (avoids file reads on every render) diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 220138e6..be03520f 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -14,6 +14,7 @@ export const defaultModelPerProvider: Record = { anthropic: "claude-sonnet-4-5", openai: "gpt-5.1-codex", google: "gemini-2.5-pro", + "github-copilot": "gpt-4o", openrouter: "openai/gpt-5.1-codex", xai: "grok-4-fast-non-reasoning", groq: "openai/gpt-oss-120b", diff --git a/packages/coding-agent/src/core/oauth/github-copilot.ts b/packages/coding-agent/src/core/oauth/github-copilot.ts new file mode 100644 index 00000000..52fce3eb --- /dev/null +++ b/packages/coding-agent/src/core/oauth/github-copilot.ts @@ -0,0 +1,235 @@ +import type { OAuthCredentials } from "./storage.js"; + +const CLIENT_ID = "Iv1.b507a08c87ecfe98"; + +const COPILOT_HEADERS = { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.105.1", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "copilot-developer-cli", + "Openai-Intent": "conversation-edits", + "X-Initiator": "agent", +} as const; + +type DeviceCodeResponse = { + device_code: string; + user_code: string; + verification_uri: string; + interval: number; + expires_in: number; +}; + +type DeviceTokenSuccessResponse = { + access_token: string; + token_type?: string; + scope?: string; +}; + +type DeviceTokenErrorResponse = { + error: string; + error_description?: string; + interval?: number; +}; + +export function normalizeDomain(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + try { + const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`); + return url.hostname; + } catch { + return null; + } +} + +function getUrls(domain: string): { + deviceCodeUrl: string; + accessTokenUrl: string; + copilotTokenUrl: string; +} { + return { + deviceCodeUrl: `https://${domain}/login/device/code`, + accessTokenUrl: `https://${domain}/login/oauth/access_token`, + copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`, + }; +} + +export function getGitHubCopilotBaseUrl(enterpriseDomain?: string): string { + if (!enterpriseDomain) return "https://api.githubcopilot.com"; + return `https://copilot-api.${enterpriseDomain}`; +} + +async function fetchJson(url: string, init: RequestInit): Promise { + const response = await fetch(url, init); + if (!response.ok) { + const text = await response.text(); + throw new Error(`${response.status} ${response.statusText}: ${text}`); + } + return response.json(); +} + +async function startDeviceFlow(domain: string): Promise { + const urls = getUrls(domain); + const data = await fetchJson(urls.deviceCodeUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": "GitHubCopilotChat/0.35.0", + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + scope: "read:user", + }), + }); + + if (!data || typeof data !== "object") { + throw new Error("Invalid device code response"); + } + + const deviceCode = (data as Record).device_code; + const userCode = (data as Record).user_code; + const verificationUri = (data as Record).verification_uri; + const interval = (data as Record).interval; + const expiresIn = (data as Record).expires_in; + + if ( + typeof deviceCode !== "string" || + typeof userCode !== "string" || + typeof verificationUri !== "string" || + typeof interval !== "number" || + typeof expiresIn !== "number" + ) { + throw new Error("Invalid device code response fields"); + } + + return { + device_code: deviceCode, + user_code: userCode, + verification_uri: verificationUri, + interval, + expires_in: expiresIn, + }; +} + +async function pollForGitHubAccessToken( + domain: string, + deviceCode: string, + intervalSeconds: number, + expiresIn: number, +) { + const urls = getUrls(domain); + const deadline = Date.now() + expiresIn * 1000; + let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000)); + + while (Date.now() < deadline) { + const raw = await fetchJson(urls.accessTokenUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": "GitHubCopilotChat/0.35.0", + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") { + return (raw as DeviceTokenSuccessResponse).access_token; + } + + if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { + const err = (raw as DeviceTokenErrorResponse).error; + if (err === "authorization_pending") { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + continue; + } + + if (err === "slow_down") { + intervalMs += 5000; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + continue; + } + + throw new Error(`Device flow failed: ${err}`); + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error("Device flow timed out"); +} + +export async function refreshGitHubCopilotToken( + refreshToken: string, + enterpriseDomain?: string, +): Promise { + const domain = enterpriseDomain || "github.com"; + const urls = getUrls(domain); + const raw = await fetchJson(urls.copilotTokenUrl, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${refreshToken}`, + ...COPILOT_HEADERS, + }, + }); + + if (!raw || typeof raw !== "object") { + throw new Error("Invalid Copilot token response"); + } + + const token = (raw as Record).token; + const expiresAt = (raw as Record).expires_at; + + if (typeof token !== "string" || typeof expiresAt !== "number") { + throw new Error("Invalid Copilot token response fields"); + } + + const expires = expiresAt * 1000 - 5 * 60 * 1000; + return { + type: "oauth", + refresh: refreshToken, + access: token, + expires, + enterpriseUrl: enterpriseDomain, + } satisfies OAuthCredentials; +} + +export async function loginGitHubCopilot(options: { + onAuth: (url: string, instructions?: string) => void; + onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise; +}): Promise { + const input = await options.onPrompt({ + message: "GitHub Enterprise URL/domain (blank for github.com)", + placeholder: "company.ghe.com", + allowEmpty: true, + }); + + const trimmed = input.trim(); + const enterpriseDomain = normalizeDomain(input); + if (trimmed && !enterpriseDomain) { + throw new Error("Invalid GitHub Enterprise URL/domain"); + } + const domain = enterpriseDomain || "github.com"; + + const device = await startDeviceFlow(domain); + options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`); + + const githubAccessToken = await pollForGitHubAccessToken( + domain, + device.device_code, + device.interval, + device.expires_in, + ); + return await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined); +} + +export async function exchangeGitHubTokenForCopilotCredentials(options: { + githubToken: string; + enterpriseDomain?: string; +}): Promise { + return refreshGitHubCopilotToken(options.githubToken, options.enterpriseDomain); +} diff --git a/packages/coding-agent/src/core/oauth/index.ts b/packages/coding-agent/src/core/oauth/index.ts index 7785e4d9..2069b00a 100644 --- a/packages/coding-agent/src/core/oauth/index.ts +++ b/packages/coding-agent/src/core/oauth/index.ts @@ -1,4 +1,5 @@ import { loginAnthropic, refreshAnthropicToken } from "./anthropic.js"; +import { loginGitHubCopilot, refreshGitHubCopilotToken } from "./github-copilot.js"; import { listOAuthProviders as listOAuthProvidersFromStorage, loadOAuthCredentials, @@ -18,6 +19,17 @@ export interface OAuthProviderInfo { available: boolean; } +export type OAuthPrompt = { + message: string; + placeholder?: string; + allowEmpty?: boolean; +}; + +export type OAuthAuthInfo = { + url: string; + instructions?: string; +}; + /** * Get list of OAuth providers */ @@ -30,8 +42,8 @@ export function getOAuthProviders(): OAuthProviderInfo[] { }, { id: "github-copilot", - name: "GitHub Copilot (coming soon)", - available: false, + name: "GitHub Copilot", + available: true, }, ]; } @@ -41,15 +53,24 @@ export function getOAuthProviders(): OAuthProviderInfo[] { */ export async function login( provider: SupportedOAuthProvider, - onAuthUrl: (url: string) => void, - onPromptCode: () => Promise, + onAuth: (info: OAuthAuthInfo) => void, + onPrompt: (prompt: OAuthPrompt) => Promise, ): Promise { switch (provider) { case "anthropic": - await loginAnthropic(onAuthUrl, onPromptCode); + await loginAnthropic( + (url) => onAuth({ url }), + async () => onPrompt({ message: "Paste the authorization code below:" }), + ); break; - case "github-copilot": - throw new Error("GitHub Copilot OAuth is not yet implemented"); + case "github-copilot": { + const creds = await loginGitHubCopilot({ + onAuth: (url, instructions) => onAuth({ url, instructions }), + onPrompt, + }); + saveOAuthCredentials("github-copilot", creds); + break; + } default: throw new Error(`Unknown OAuth provider: ${provider}`); } @@ -78,7 +99,8 @@ export async function refreshToken(provider: SupportedOAuthProvider): Promise { + (info) => { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("accent", "Opening browser to:"), 1, 0)); - this.chatContainer.addChild(new Text(theme.fg("accent", url), 1, 0)); - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild( - new Text(theme.fg("warning", "Paste the authorization code below:"), 1, 0), - ); + this.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0)); + if (info.instructions) { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0)); + } this.ui.requestRender(); const openCmd = @@ -1394,9 +1394,16 @@ export class InteractiveMode { : process.platform === "win32" ? "start" : "xdg-open"; - exec(`${openCmd} "${url}"`); + exec(`${openCmd} "${info.url}"`); }, - async () => { + async (prompt) => { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0)); + if (prompt.placeholder) { + this.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0)); + } + this.ui.requestRender(); + return new Promise((resolve) => { const codeInput = new Input(); codeInput.onSubmit = () => {