summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorMartin Palma <[email protected]>2025-06-23 01:11:37 +0200
committerGitHub <[email protected]>2025-06-22 19:11:37 -0400
commit6e6fe6e013d5ab7bc452ecb79dbb8374d43d4dfa (patch)
tree7de4c518502485e09fccce3981e62f7116e43ab2
parentd05b60291ee54a019876dbcef1cabf9a69df4ba8 (diff)
downloadopencode-6e6fe6e013d5ab7bc452ecb79dbb8374d43d4dfa.tar.gz
opencode-6e6fe6e013d5ab7bc452ecb79dbb8374d43d4dfa.zip
Add Github Copilot OAuth authentication flow (#305)
-rw-r--r--packages/opencode/src/auth/github-copilot.ts150
-rw-r--r--packages/opencode/src/cli/cmd/auth.ts43
-rw-r--r--packages/opencode/src/cli/cmd/login-anthropic.ts20
-rw-r--r--packages/opencode/src/provider/provider.ts39
4 files changed, 228 insertions, 24 deletions
diff --git a/packages/opencode/src/auth/github-copilot.ts b/packages/opencode/src/auth/github-copilot.ts
new file mode 100644
index 000000000..ebbf1c3de
--- /dev/null
+++ b/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(),
+ }),
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts
index 700df377d..0b8809ed9 100644
--- a/packages/opencode/src/cli/cmd/auth.ts
+++ b/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",
@@ -68,6 +70,10 @@ export const AuthLoginCommand = cmd({
})),
),
{
+ 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"),
diff --git a/packages/opencode/src/cli/cmd/login-anthropic.ts b/packages/opencode/src/cli/cmd/login-anthropic.ts
deleted file mode 100644
index 64df8bebf..000000000
--- a/packages/opencode/src/cli/cmd/login-anthropic.ts
+++ /dev/null
@@ -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)
- },
-}
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index c330b4dcd..dd7f35994 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/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