Aiden Cline 1 месяц назад
Родитель
Сommit
d78d31430d
2 измененных файлов с 252 добавлено и 6 удалено
  1. 249 0
      packages/opencode/src/plugin/copilot.ts
  2. 3 6
      packages/opencode/src/plugin/index.ts

+ 249 - 0
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
+                }
+              },
+            }
+          },
+        },
+      ],
+    },
+  }
+}

+ 3 - 6
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 = [
-    "[email protected]",
-    "[email protected]",
-    "@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({