Skip to content

Commit d78d314

Browse files
authored
feat: official copilot plugin (#8393)
1 parent 096e14d commit d78d314

File tree

2 files changed

+252
-6
lines changed

2 files changed

+252
-6
lines changed
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
2+
import { Installation } from "@/installation"
3+
import { iife } from "@/util/iife"
4+
5+
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
6+
7+
function normalizeDomain(url: string) {
8+
return url.replace(/^https?:\/\//, "").replace(/\/$/, "")
9+
}
10+
11+
function getUrls(domain: string) {
12+
return {
13+
DEVICE_CODE_URL: `https://${domain}/login/device/code`,
14+
ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
15+
}
16+
}
17+
18+
export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
19+
return {
20+
auth: {
21+
provider: "github-copilot",
22+
async loader(getAuth, provider) {
23+
const info = await getAuth()
24+
if (!info || info.type !== "oauth") return {}
25+
26+
if (provider && provider.models) {
27+
for (const model of Object.values(provider.models)) {
28+
model.cost = {
29+
input: 0,
30+
output: 0,
31+
cache: {
32+
read: 0,
33+
write: 0,
34+
},
35+
}
36+
}
37+
}
38+
39+
const enterpriseUrl = info.enterpriseUrl
40+
const baseURL = enterpriseUrl
41+
? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
42+
: "https://api.githubcopilot.com"
43+
44+
return {
45+
baseURL,
46+
apiKey: "",
47+
async fetch(request: RequestInfo | URL, init?: RequestInit) {
48+
const info = await getAuth()
49+
if (info.type !== "oauth") return fetch(request, init)
50+
51+
const { isVision, isAgent } = iife(() => {
52+
try {
53+
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body
54+
55+
// Completions API
56+
if (body?.messages) {
57+
const last = body.messages[body.messages.length - 1]
58+
return {
59+
isVision: body.messages.some(
60+
(msg: any) =>
61+
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
62+
),
63+
isAgent: last?.role !== "user",
64+
}
65+
}
66+
67+
// Responses API
68+
if (body?.input) {
69+
const last = body.input[body.input.length - 1]
70+
return {
71+
isVision: body.input.some(
72+
(item: any) =>
73+
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
74+
),
75+
isAgent: last?.role !== "user",
76+
}
77+
}
78+
} catch {}
79+
return { isVision: false, isAgent: false }
80+
})
81+
82+
const headers: Record<string, string> = {
83+
...(init?.headers as Record<string, string>),
84+
"User-Agent": `opencode/${Installation.VERSION}`,
85+
Authorization: `Bearer ${info.refresh}`,
86+
"Openai-Intent": "conversation-edits",
87+
"X-Initiator": isAgent ? "agent" : "user",
88+
}
89+
90+
if (isVision) {
91+
headers["Copilot-Vision-Request"] = "true"
92+
}
93+
94+
delete headers["x-api-key"]
95+
delete headers["authorization"]
96+
97+
return fetch(request, {
98+
...init,
99+
headers,
100+
})
101+
},
102+
}
103+
},
104+
methods: [
105+
{
106+
type: "oauth",
107+
label: "Login with GitHub Copilot",
108+
prompts: [
109+
{
110+
type: "select",
111+
key: "deploymentType",
112+
message: "Select GitHub deployment type",
113+
options: [
114+
{
115+
label: "GitHub.com",
116+
value: "github.com",
117+
hint: "Public",
118+
},
119+
{
120+
label: "GitHub Enterprise",
121+
value: "enterprise",
122+
hint: "Data residency or self-hosted",
123+
},
124+
],
125+
},
126+
{
127+
type: "text",
128+
key: "enterpriseUrl",
129+
message: "Enter your GitHub Enterprise URL or domain",
130+
placeholder: "company.ghe.com or https://company.ghe.com",
131+
condition: (inputs) => inputs.deploymentType === "enterprise",
132+
validate: (value) => {
133+
if (!value) return "URL or domain is required"
134+
try {
135+
const url = value.includes("://") ? new URL(value) : new URL(`https://${value}`)
136+
if (!url.hostname) return "Please enter a valid URL or domain"
137+
return undefined
138+
} catch {
139+
return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)"
140+
}
141+
},
142+
},
143+
],
144+
async authorize(inputs = {}) {
145+
const deploymentType = inputs.deploymentType || "github.com"
146+
147+
let domain = "github.com"
148+
let actualProvider = "github-copilot"
149+
150+
if (deploymentType === "enterprise") {
151+
const enterpriseUrl = inputs.enterpriseUrl
152+
domain = normalizeDomain(enterpriseUrl!)
153+
actualProvider = "github-copilot-enterprise"
154+
}
155+
156+
const urls = getUrls(domain)
157+
158+
const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
159+
method: "POST",
160+
headers: {
161+
Accept: "application/json",
162+
"Content-Type": "application/json",
163+
"User-Agent": `opencode/${Installation.VERSION}`,
164+
},
165+
body: JSON.stringify({
166+
client_id: CLIENT_ID,
167+
scope: "read:user",
168+
}),
169+
})
170+
171+
if (!deviceResponse.ok) {
172+
throw new Error("Failed to initiate device authorization")
173+
}
174+
175+
const deviceData = (await deviceResponse.json()) as {
176+
verification_uri: string
177+
user_code: string
178+
device_code: string
179+
interval: number
180+
}
181+
182+
return {
183+
url: deviceData.verification_uri,
184+
instructions: `Enter code: ${deviceData.user_code}`,
185+
method: "auto" as const,
186+
async callback() {
187+
while (true) {
188+
const response = await fetch(urls.ACCESS_TOKEN_URL, {
189+
method: "POST",
190+
headers: {
191+
Accept: "application/json",
192+
"Content-Type": "application/json",
193+
"User-Agent": `opencode/${Installation.VERSION}`,
194+
},
195+
body: JSON.stringify({
196+
client_id: CLIENT_ID,
197+
device_code: deviceData.device_code,
198+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
199+
}),
200+
})
201+
202+
if (!response.ok) return { type: "failed" as const }
203+
204+
const data = (await response.json()) as {
205+
access_token?: string
206+
error?: string
207+
}
208+
209+
if (data.access_token) {
210+
const result: {
211+
type: "success"
212+
refresh: string
213+
access: string
214+
expires: number
215+
provider?: string
216+
enterpriseUrl?: string
217+
} = {
218+
type: "success",
219+
refresh: data.access_token,
220+
access: data.access_token,
221+
expires: 0,
222+
}
223+
224+
if (actualProvider === "github-copilot-enterprise") {
225+
result.provider = "github-copilot-enterprise"
226+
result.enterpriseUrl = domain
227+
}
228+
229+
return result
230+
}
231+
232+
if (data.error === "authorization_pending") {
233+
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
234+
continue
235+
}
236+
237+
if (data.error) return { type: "failed" as const }
238+
239+
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
240+
continue
241+
}
242+
},
243+
}
244+
},
245+
},
246+
],
247+
},
248+
}
249+
}

packages/opencode/src/plugin/index.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,15 @@ import { Flag } from "../flag/flag"
1010
import { CodexAuthPlugin } from "./codex"
1111
import { Session } from "../session"
1212
import { NamedError } from "@opencode-ai/util/error"
13+
import { CopilotAuthPlugin } from "./copilot"
1314

1415
export namespace Plugin {
1516
const log = Log.create({ service: "plugin" })
1617

17-
const BUILTIN = [
18-
"opencode-copilot-auth@0.0.12",
19-
"opencode-anthropic-auth@0.0.8",
20-
"@gitlab/opencode-gitlab-auth@1.3.0",
21-
]
18+
const BUILTIN = ["opencode-anthropic-auth@0.0.8", "@gitlab/opencode-gitlab-auth@1.3.0"]
2219

2320
// Built-in plugins that are directly imported (not installed from npm)
24-
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
21+
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin]
2522

2623
const state = Instance.state(async () => {
2724
const client = createOpencodeClient({

0 commit comments

Comments
 (0)