diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts new file mode 100644 index 00000000000..17ce9debc7d --- /dev/null +++ b/packages/opencode/src/plugin/copilot.ts @@ -0,0 +1,249 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { Installation } from "@/installation" +import { iife } from "@/util/iife" + +const CLIENT_ID = "Ov23li8tweQw6odWQebz" + +function normalizeDomain(url: string) { + return url.replace(/^https?:\/\//, "").replace(/\/$/, "") +} + +function getUrls(domain: string) { + return { + DEVICE_CODE_URL: `https://${domain}/login/device/code`, + ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`, + } +} + +export async function CopilotAuthPlugin(input: PluginInput): Promise { + return { + auth: { + provider: "github-copilot", + async loader(getAuth, provider) { + const info = await getAuth() + if (!info || info.type !== "oauth") return {} + + if (provider && provider.models) { + for (const model of Object.values(provider.models)) { + model.cost = { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + } + } + } + + const enterpriseUrl = info.enterpriseUrl + const baseURL = enterpriseUrl + ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` + : "https://api.githubcopilot.com" + + return { + baseURL, + apiKey: "", + async fetch(request: RequestInfo | URL, init?: RequestInit) { + const info = await getAuth() + if (info.type !== "oauth") return fetch(request, init) + + const { isVision, isAgent } = iife(() => { + try { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body + + // Completions API + if (body?.messages) { + const last = body.messages[body.messages.length - 1] + return { + isVision: body.messages.some( + (msg: any) => + Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), + ), + isAgent: last?.role !== "user", + } + } + + // Responses API + if (body?.input) { + const last = body.input[body.input.length - 1] + return { + isVision: body.input.some( + (item: any) => + Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), + ), + isAgent: last?.role !== "user", + } + } + } catch {} + return { isVision: false, isAgent: false } + }) + + const headers: Record = { + ...(init?.headers as Record), + "User-Agent": `opencode/${Installation.VERSION}`, + Authorization: `Bearer ${info.refresh}`, + "Openai-Intent": "conversation-edits", + "X-Initiator": isAgent ? "agent" : "user", + } + + if (isVision) { + headers["Copilot-Vision-Request"] = "true" + } + + delete headers["x-api-key"] + delete headers["authorization"] + + return fetch(request, { + ...init, + headers, + }) + }, + } + }, + methods: [ + { + type: "oauth", + label: "Login with GitHub Copilot", + prompts: [ + { + type: "select", + key: "deploymentType", + message: "Select GitHub deployment type", + options: [ + { + label: "GitHub.com", + value: "github.com", + hint: "Public", + }, + { + label: "GitHub Enterprise", + value: "enterprise", + hint: "Data residency or self-hosted", + }, + ], + }, + { + type: "text", + key: "enterpriseUrl", + message: "Enter your GitHub Enterprise URL or domain", + placeholder: "company.ghe.com or https://company.ghe.com", + condition: (inputs) => inputs.deploymentType === "enterprise", + validate: (value) => { + if (!value) return "URL or domain is required" + try { + const url = value.includes("://") ? new URL(value) : new URL(`https://${value}`) + if (!url.hostname) return "Please enter a valid URL or domain" + return undefined + } catch { + return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)" + } + }, + }, + ], + async authorize(inputs = {}) { + const deploymentType = inputs.deploymentType || "github.com" + + let domain = "github.com" + let actualProvider = "github-copilot" + + if (deploymentType === "enterprise") { + const enterpriseUrl = inputs.enterpriseUrl + domain = normalizeDomain(enterpriseUrl!) + actualProvider = "github-copilot-enterprise" + } + + const urls = getUrls(domain) + + const deviceResponse = await fetch(urls.DEVICE_CODE_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + scope: "read:user", + }), + }) + + if (!deviceResponse.ok) { + throw new Error("Failed to initiate device authorization") + } + + const deviceData = (await deviceResponse.json()) as { + verification_uri: string + user_code: string + device_code: string + interval: number + } + + return { + url: deviceData.verification_uri, + instructions: `Enter code: ${deviceData.user_code}`, + method: "auto" as const, + async callback() { + while (true) { + const response = await fetch(urls.ACCESS_TOKEN_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + device_code: deviceData.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }) + + if (!response.ok) return { type: "failed" as const } + + const data = (await response.json()) as { + access_token?: string + error?: string + } + + if (data.access_token) { + const result: { + type: "success" + refresh: string + access: string + expires: number + provider?: string + enterpriseUrl?: string + } = { + type: "success", + refresh: data.access_token, + access: data.access_token, + expires: 0, + } + + if (actualProvider === "github-copilot-enterprise") { + result.provider = "github-copilot-enterprise" + result.enterpriseUrl = domain + } + + return result + } + + if (data.error === "authorization_pending") { + await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000)) + continue + } + + if (data.error) return { type: "failed" as const } + + await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000)) + continue + } + }, + } + }, + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 8ce6dfd3c3d..db8f53221ea 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -10,18 +10,15 @@ import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" +import { CopilotAuthPlugin } from "./copilot" export namespace Plugin { const log = Log.create({ service: "plugin" }) - const BUILTIN = [ - "opencode-copilot-auth@0.0.12", - "opencode-anthropic-auth@0.0.8", - "@gitlab/opencode-gitlab-auth@1.3.0", - ] + const BUILTIN = ["opencode-anthropic-auth@0.0.8", "@gitlab/opencode-gitlab-auth@1.3.0"] // Built-in plugins that are directly imported (not installed from npm) - const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin] + const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin] const state = Instance.state(async () => { const client = createOpencodeClient({