Просмотр исходного кода

Add Github Copilot OAuth authentication flow (#305)

Martin Palma 8 месяцев назад
Родитель
Сommit
6e6fe6e013

+ 150 - 0
packages/opencode/src/auth/github-copilot.ts

@@ -0,0 +1,150 @@
+import { z } from "zod"
+import { Auth } from "./index"
+import { NamedError } from "../util/error"
+
+export namespace AuthGithubCopilot {
+  const CLIENT_ID = "Iv1.b507a08c87ecfe98"
+  const DEVICE_CODE_URL = "https://github.com/login/device/code"
+  const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
+  const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
+
+  interface DeviceCodeResponse {
+    device_code: string
+    user_code: string
+    verification_uri: string
+    expires_in: number
+    interval: number
+  }
+
+  interface AccessTokenResponse {
+    access_token?: string
+    error?: string
+    error_description?: string
+  }
+
+  interface CopilotTokenResponse {
+    token: string
+    expires_at: number
+    refresh_in: number
+    endpoints: {
+      api: string
+    }
+  }
+
+  export async function authorize() {
+    const deviceResponse = await fetch(DEVICE_CODE_URL, {
+      method: "POST",
+      headers: {
+        Accept: "application/json",
+        "Content-Type": "application/json",
+        "User-Agent": "GithubCopilot/1.155.0",
+      },
+      body: JSON.stringify({
+        client_id: CLIENT_ID,
+        scope: "read:user",
+      }),
+    })
+    const deviceData: DeviceCodeResponse = await deviceResponse.json()
+    return {
+      device: deviceData.device_code,
+      user: deviceData.user_code,
+      verification: deviceData.verification_uri,
+      interval: deviceData.interval || 5,
+      expiry: deviceData.expires_in,
+    }
+  }
+
+  export async function poll(device_code: string) {
+    const response = await fetch(ACCESS_TOKEN_URL, {
+      method: "POST",
+      headers: {
+        Accept: "application/json",
+        "Content-Type": "application/json",
+        "User-Agent": "GithubCopilot/1.155.0",
+      },
+      body: JSON.stringify({
+        client_id: CLIENT_ID,
+        device_code,
+        grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+      }),
+    })
+
+    if (!response.ok) return "failed"
+
+    const data: AccessTokenResponse = await response.json()
+
+    if (data.access_token) {
+      // Store the GitHub OAuth token
+      await Auth.set("github-copilot", {
+        type: "oauth",
+        refresh: data.access_token,
+        access: "",
+        expires: 0,
+      })
+      return "complete"
+    }
+
+    if (data.error === "authorization_pending") return "pending"
+
+    if (data.error) return "failed"
+
+    return "pending"
+  }
+
+  export async function access() {
+    const info = await Auth.get("github-copilot")
+    if (!info || info.type !== "oauth") return
+    if (info.access && info.expires > Date.now()) return info.access
+
+    // Get new Copilot API token
+    const response = await fetch(COPILOT_API_KEY_URL, {
+      headers: {
+        Accept: "application/json",
+        Authorization: `Bearer ${info.refresh}`,
+        "User-Agent": "GithubCopilot/1.155.0",
+        "Editor-Version": "vscode/1.85.1",
+        "Editor-Plugin-Version": "copilot/1.155.0",
+      },
+    })
+
+    if (!response.ok) return
+
+    const tokenData: CopilotTokenResponse = await response.json()
+
+    // Store the Copilot API token
+    await Auth.set("github-copilot", {
+      type: "oauth",
+      refresh: info.refresh,
+      access: tokenData.token,
+      expires: tokenData.expires_at * 1000,
+    })
+
+    return tokenData.token
+  }
+
+  export const DeviceCodeError = NamedError.create(
+    "DeviceCodeError",
+    z.object({}),
+  )
+
+  export const TokenExchangeError = NamedError.create(
+    "TokenExchangeError",
+    z.object({
+      message: z.string(),
+    }),
+  )
+
+  export const AuthenticationError = NamedError.create(
+    "AuthenticationError",
+    z.object({
+      message: z.string(),
+    }),
+  )
+
+  export const CopilotTokenError = NamedError.create(
+    "CopilotTokenError",
+    z.object({
+      message: z.string(),
+    }),
+  )
+}

+ 40 - 3
packages/opencode/src/cli/cmd/auth.ts

@@ -1,4 +1,5 @@
 import { AuthAnthropic } from "../../auth/anthropic"
+import { AuthGithubCopilot } from "../../auth/github-copilot"
 import { Auth } from "../../auth"
 import { cmd } from "./cmd"
 import * as prompts from "@clack/prompts"
@@ -16,7 +17,7 @@ export const AuthCommand = cmd({
       .command(AuthLogoutCommand)
       .command(AuthListCommand)
       .demandCommand(),
-  async handler() { },
+  async handler() {},
 })
 
 export const AuthListCommand = cmd({
@@ -47,8 +48,9 @@ export const AuthLoginCommand = cmd({
     const providers = await ModelsDev.get()
     const priority: Record<string, number> = {
       anthropic: 0,
-      openai: 1,
-      google: 2,
+      "github-copilot": 1,
+      openai: 2,
+      google: 3,
     }
     let provider = await prompts.select({
       message: "Select provider",
@@ -67,6 +69,10 @@ export const AuthLoginCommand = cmd({
             hint: priority[x.id] === 0 ? "recommended" : undefined,
           })),
         ),
+        {
+          value: "github-copilot",
+          label: "GitHub Copilot",
+        },
         {
           value: "other",
           label: "Other",
@@ -146,6 +152,37 @@ export const AuthLoginCommand = cmd({
       }
     }
 
+    if (provider === "github-copilot") {
+      await new Promise((resolve) => setTimeout(resolve, 10))
+      const deviceInfo = await AuthGithubCopilot.authorize()
+
+      prompts.note(
+        `Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
+      )
+
+      const spinner = prompts.spinner()
+      spinner.start("Waiting for authorization...")
+
+      while (true) {
+        await new Promise((resolve) =>
+          setTimeout(resolve, deviceInfo.interval * 1000),
+        )
+        const status = await AuthGithubCopilot.poll(deviceInfo.device)
+        if (status === "pending") continue
+        if (status === "complete") {
+          spinner.stop("Login successful")
+          break
+        }
+        if (status === "failed") {
+          spinner.stop("Failed to authorize", 1)
+          break
+        }
+      }
+
+      prompts.outro("Done")
+      return
+    }
+
     const key = await prompts.password({
       message: "Enter your API key",
       validate: (x) => (x.length > 0 ? undefined : "Required"),

+ 0 - 20
packages/opencode/src/cli/cmd/login-anthropic.ts

@@ -1,20 +0,0 @@
-import { AuthAnthropic } from "../../auth/anthropic"
-import { UI } from "../ui"
-
-// Example: https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=MdFtFgFap23AWDSN0oa3-eaKjQRFE4CaEhXx8M9fHZg&code_challenge_method=S256&state=rKLtaDzm88GSwekyEqdi0wXX-YqIr13tSzYymSzpvfs
-
-export const LoginAnthropicCommand = {
-  command: "anthropic",
-  describe: "Login to Anthropic",
-  handler: async () => {
-    const { url, verifier } = await AuthAnthropic.authorize()
-
-    UI.println("Login to Anthropic")
-    UI.println("Open the following URL in your browser:")
-    UI.println(url)
-    UI.println("")
-
-    const code = await UI.input("Paste the authorization code here: ")
-    await AuthAnthropic.exchange(code, verifier)
-  },
-}

+ 38 - 1
packages/opencode/src/provider/provider.ts

@@ -19,6 +19,7 @@ import type { Tool } from "../tool/tool"
 import { WriteTool } from "../tool/write"
 import { TodoReadTool, TodoWriteTool } from "../tool/todo"
 import { AuthAnthropic } from "../auth/anthropic"
+import { AuthGithubCopilot } from "../auth/github-copilot"
 import { ModelsDev } from "./models"
 import { NamedError } from "../util/error"
 import { Auth } from "../auth"
@@ -66,6 +67,41 @@ export namespace Provider {
         },
       }
     },
+    "github-copilot": async (provider) => {
+      const info = await AuthGithubCopilot.access()
+      if (!info) return false
+
+      if (provider && provider.models) {
+        for (const model of Object.values(provider.models)) {
+          model.cost = {
+            input: 0,
+            output: 0,
+          }
+        }
+      }
+
+      return {
+        options: {
+          apiKey: "",
+          async fetch(input: any, init: any) {
+            const token = await AuthGithubCopilot.access()
+            if (!token) throw new Error("GitHub Copilot authentication expired")
+            const headers = {
+              ...init.headers,
+              Authorization: `Bearer ${token}`,
+              "User-Agent": "GithubCopilot/1.155.0",
+              "Editor-Version": "vscode/1.85.1",
+              "Editor-Plugin-Version": "copilot/1.155.0",
+            }
+            delete headers["x-api-key"]
+            return fetch(input, {
+              ...init,
+              headers,
+            })
+          },
+        },
+      }
+    },
     openai: async () => {
       return {
         async getModel(sdk: any, modelID: string) {
@@ -208,8 +244,9 @@ export namespace Provider {
     for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
       if (disabled.has(providerID)) continue
       const result = await fn(database[providerID])
-      if (result)
+      if (result) {
         mergeProvider(providerID, result.options, "custom", result.getModel)
+      }
     }
 
     // load config