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