summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-01-14 15:42:51 -0800
committerGitHub <[email protected]>2026-01-14 17:42:51 -0600
commitd78d31430d6aa221272fee8da5b96089cc9af105 (patch)
tree5317f0dabf06724c22f013936db267701aa8d5a2
parent096e14d787adf10a9a8e0815d92ad3b19e274bfc (diff)
downloadopencode-d78d31430d6aa221272fee8da5b96089cc9af105.tar.gz
opencode-d78d31430d6aa221272fee8da5b96089cc9af105.zip
feat: official copilot plugin (#8393)
-rw-r--r--packages/opencode/src/plugin/copilot.ts249
-rw-r--r--packages/opencode/src/plugin/index.ts9
2 files changed, 252 insertions, 6 deletions
diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts
new file mode 100644
index 000000000..17ce9debc
--- /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<Hooks> {
+ 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<string, string> = {
+ ...(init?.headers as Record<string, string>),
+ "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 8ce6dfd3c..db8f53221 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 = [
- "@gitlab/[email protected]",
- ]
+ const BUILTIN = ["[email protected]", "@gitlab/[email protected]"]
// 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({