Skip to content

Commit 6e6fe6e

Browse files
authored
Add Github Copilot OAuth authentication flow (anomalyco#305)
1 parent d05b602 commit 6e6fe6e

File tree

4 files changed

+228
-24
lines changed

4 files changed

+228
-24
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { z } from "zod"
2+
import { Auth } from "./index"
3+
import { NamedError } from "../util/error"
4+
5+
export namespace AuthGithubCopilot {
6+
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
7+
const DEVICE_CODE_URL = "https://github.com/login/device/code"
8+
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
9+
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
10+
11+
interface DeviceCodeResponse {
12+
device_code: string
13+
user_code: string
14+
verification_uri: string
15+
expires_in: number
16+
interval: number
17+
}
18+
19+
interface AccessTokenResponse {
20+
access_token?: string
21+
error?: string
22+
error_description?: string
23+
}
24+
25+
interface CopilotTokenResponse {
26+
token: string
27+
expires_at: number
28+
refresh_in: number
29+
endpoints: {
30+
api: string
31+
}
32+
}
33+
34+
export async function authorize() {
35+
const deviceResponse = await fetch(DEVICE_CODE_URL, {
36+
method: "POST",
37+
headers: {
38+
Accept: "application/json",
39+
"Content-Type": "application/json",
40+
"User-Agent": "GithubCopilot/1.155.0",
41+
},
42+
body: JSON.stringify({
43+
client_id: CLIENT_ID,
44+
scope: "read:user",
45+
}),
46+
})
47+
const deviceData: DeviceCodeResponse = await deviceResponse.json()
48+
return {
49+
device: deviceData.device_code,
50+
user: deviceData.user_code,
51+
verification: deviceData.verification_uri,
52+
interval: deviceData.interval || 5,
53+
expiry: deviceData.expires_in,
54+
}
55+
}
56+
57+
export async function poll(device_code: string) {
58+
const response = await fetch(ACCESS_TOKEN_URL, {
59+
method: "POST",
60+
headers: {
61+
Accept: "application/json",
62+
"Content-Type": "application/json",
63+
"User-Agent": "GithubCopilot/1.155.0",
64+
},
65+
body: JSON.stringify({
66+
client_id: CLIENT_ID,
67+
device_code,
68+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
69+
}),
70+
})
71+
72+
if (!response.ok) return "failed"
73+
74+
const data: AccessTokenResponse = await response.json()
75+
76+
if (data.access_token) {
77+
// Store the GitHub OAuth token
78+
await Auth.set("github-copilot", {
79+
type: "oauth",
80+
refresh: data.access_token,
81+
access: "",
82+
expires: 0,
83+
})
84+
return "complete"
85+
}
86+
87+
if (data.error === "authorization_pending") return "pending"
88+
89+
if (data.error) return "failed"
90+
91+
return "pending"
92+
}
93+
94+
export async function access() {
95+
const info = await Auth.get("github-copilot")
96+
if (!info || info.type !== "oauth") return
97+
if (info.access && info.expires > Date.now()) return info.access
98+
99+
// Get new Copilot API token
100+
const response = await fetch(COPILOT_API_KEY_URL, {
101+
headers: {
102+
Accept: "application/json",
103+
Authorization: `Bearer ${info.refresh}`,
104+
"User-Agent": "GithubCopilot/1.155.0",
105+
"Editor-Version": "vscode/1.85.1",
106+
"Editor-Plugin-Version": "copilot/1.155.0",
107+
},
108+
})
109+
110+
if (!response.ok) return
111+
112+
const tokenData: CopilotTokenResponse = await response.json()
113+
114+
// Store the Copilot API token
115+
await Auth.set("github-copilot", {
116+
type: "oauth",
117+
refresh: info.refresh,
118+
access: tokenData.token,
119+
expires: tokenData.expires_at * 1000,
120+
})
121+
122+
return tokenData.token
123+
}
124+
125+
export const DeviceCodeError = NamedError.create(
126+
"DeviceCodeError",
127+
z.object({}),
128+
)
129+
130+
export const TokenExchangeError = NamedError.create(
131+
"TokenExchangeError",
132+
z.object({
133+
message: z.string(),
134+
}),
135+
)
136+
137+
export const AuthenticationError = NamedError.create(
138+
"AuthenticationError",
139+
z.object({
140+
message: z.string(),
141+
}),
142+
)
143+
144+
export const CopilotTokenError = NamedError.create(
145+
"CopilotTokenError",
146+
z.object({
147+
message: z.string(),
148+
}),
149+
)
150+
}

packages/opencode/src/cli/cmd/auth.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AuthAnthropic } from "../../auth/anthropic"
2+
import { AuthGithubCopilot } from "../../auth/github-copilot"
23
import { Auth } from "../../auth"
34
import { cmd } from "./cmd"
45
import * as prompts from "@clack/prompts"
@@ -16,7 +17,7 @@ export const AuthCommand = cmd({
1617
.command(AuthLogoutCommand)
1718
.command(AuthListCommand)
1819
.demandCommand(),
19-
async handler() { },
20+
async handler() {},
2021
})
2122

2223
export const AuthListCommand = cmd({
@@ -47,8 +48,9 @@ export const AuthLoginCommand = cmd({
4748
const providers = await ModelsDev.get()
4849
const priority: Record<string, number> = {
4950
anthropic: 0,
50-
openai: 1,
51-
google: 2,
51+
"github-copilot": 1,
52+
openai: 2,
53+
google: 3,
5254
}
5355
let provider = await prompts.select({
5456
message: "Select provider",
@@ -67,6 +69,10 @@ export const AuthLoginCommand = cmd({
6769
hint: priority[x.id] === 0 ? "recommended" : undefined,
6870
})),
6971
),
72+
{
73+
value: "github-copilot",
74+
label: "GitHub Copilot",
75+
},
7076
{
7177
value: "other",
7278
label: "Other",
@@ -146,6 +152,37 @@ export const AuthLoginCommand = cmd({
146152
}
147153
}
148154

155+
if (provider === "github-copilot") {
156+
await new Promise((resolve) => setTimeout(resolve, 10))
157+
const deviceInfo = await AuthGithubCopilot.authorize()
158+
159+
prompts.note(
160+
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
161+
)
162+
163+
const spinner = prompts.spinner()
164+
spinner.start("Waiting for authorization...")
165+
166+
while (true) {
167+
await new Promise((resolve) =>
168+
setTimeout(resolve, deviceInfo.interval * 1000),
169+
)
170+
const status = await AuthGithubCopilot.poll(deviceInfo.device)
171+
if (status === "pending") continue
172+
if (status === "complete") {
173+
spinner.stop("Login successful")
174+
break
175+
}
176+
if (status === "failed") {
177+
spinner.stop("Failed to authorize", 1)
178+
break
179+
}
180+
}
181+
182+
prompts.outro("Done")
183+
return
184+
}
185+
149186
const key = await prompts.password({
150187
message: "Enter your API key",
151188
validate: (x) => (x.length > 0 ? undefined : "Required"),

packages/opencode/src/cli/cmd/login-anthropic.ts

Lines changed: 0 additions & 20 deletions
This file was deleted.

packages/opencode/src/provider/provider.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { Tool } from "../tool/tool"
1919
import { WriteTool } from "../tool/write"
2020
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
2121
import { AuthAnthropic } from "../auth/anthropic"
22+
import { AuthGithubCopilot } from "../auth/github-copilot"
2223
import { ModelsDev } from "./models"
2324
import { NamedError } from "../util/error"
2425
import { Auth } from "../auth"
@@ -66,6 +67,41 @@ export namespace Provider {
6667
},
6768
}
6869
},
70+
"github-copilot": async (provider) => {
71+
const info = await AuthGithubCopilot.access()
72+
if (!info) return false
73+
74+
if (provider && provider.models) {
75+
for (const model of Object.values(provider.models)) {
76+
model.cost = {
77+
input: 0,
78+
output: 0,
79+
}
80+
}
81+
}
82+
83+
return {
84+
options: {
85+
apiKey: "",
86+
async fetch(input: any, init: any) {
87+
const token = await AuthGithubCopilot.access()
88+
if (!token) throw new Error("GitHub Copilot authentication expired")
89+
const headers = {
90+
...init.headers,
91+
Authorization: `Bearer ${token}`,
92+
"User-Agent": "GithubCopilot/1.155.0",
93+
"Editor-Version": "vscode/1.85.1",
94+
"Editor-Plugin-Version": "copilot/1.155.0",
95+
}
96+
delete headers["x-api-key"]
97+
return fetch(input, {
98+
...init,
99+
headers,
100+
})
101+
},
102+
},
103+
}
104+
},
69105
openai: async () => {
70106
return {
71107
async getModel(sdk: any, modelID: string) {
@@ -208,8 +244,9 @@ export namespace Provider {
208244
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
209245
if (disabled.has(providerID)) continue
210246
const result = await fn(database[providerID])
211-
if (result)
247+
if (result) {
212248
mergeProvider(providerID, result.options, "custom", result.getModel)
249+
}
213250
}
214251

215252
// load config

0 commit comments

Comments
 (0)