copilot.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import type { Hooks, PluginInput } from "@opencode-ai/plugin"
  2. import { Installation } from "@/installation"
  3. import { iife } from "@/util/iife"
  4. const CLIENT_ID = "Ov23li8tweQw6odWQebz"
  5. // Add a small safety buffer when polling to avoid hitting the server
  6. // slightly too early due to clock skew / timer drift.
  7. const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 // 3 seconds
  8. function normalizeDomain(url: string) {
  9. return url.replace(/^https?:\/\//, "").replace(/\/$/, "")
  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. export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
  18. const sdk = input.client
  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. const enterpriseUrl = info.enterpriseUrl
  26. const baseURL = enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : undefined
  27. if (provider && provider.models) {
  28. for (const model of Object.values(provider.models)) {
  29. model.cost = {
  30. input: 0,
  31. output: 0,
  32. cache: {
  33. read: 0,
  34. write: 0,
  35. },
  36. }
  37. // TODO: move some of this hacky-ness to models.dev presets once we have better grasp of things here...
  38. const base = baseURL ?? model.api.url
  39. const claude = model.id.includes("claude")
  40. const url = iife(() => {
  41. if (!claude) return base
  42. if (base.endsWith("/v1")) return base
  43. if (base.endsWith("/")) return `${base}v1`
  44. return `${base}/v1`
  45. })
  46. model.api.url = url
  47. model.api.npm = claude ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"
  48. }
  49. }
  50. return {
  51. apiKey: "",
  52. async fetch(request: RequestInfo | URL, init?: RequestInit) {
  53. const info = await getAuth()
  54. if (info.type !== "oauth") return fetch(request, init)
  55. const { isVision, isAgent } = iife(() => {
  56. try {
  57. const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body
  58. // Completions API
  59. if (body?.messages) {
  60. const last = body.messages[body.messages.length - 1]
  61. return {
  62. isVision: body.messages.some(
  63. (msg: any) =>
  64. Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
  65. ),
  66. isAgent: last?.role !== "user",
  67. }
  68. }
  69. // Responses API
  70. if (body?.input) {
  71. const last = body.input[body.input.length - 1]
  72. return {
  73. isVision: body.input.some(
  74. (item: any) =>
  75. Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
  76. ),
  77. isAgent: last?.role !== "user",
  78. }
  79. }
  80. } catch {}
  81. return { isVision: false, isAgent: false }
  82. })
  83. const headers: Record<string, string> = {
  84. "x-initiator": isAgent ? "agent" : "user",
  85. ...(init?.headers as Record<string, string>),
  86. "User-Agent": `opencode/${Installation.VERSION}`,
  87. Authorization: `Bearer ${info.refresh}`,
  88. "Openai-Intent": "conversation-edits",
  89. }
  90. if (isVision) {
  91. headers["Copilot-Vision-Request"] = "true"
  92. }
  93. delete headers["x-api-key"]
  94. delete headers["authorization"]
  95. return fetch(request, {
  96. ...init,
  97. headers,
  98. })
  99. },
  100. }
  101. },
  102. methods: [
  103. {
  104. type: "oauth",
  105. label: "Login with GitHub Copilot",
  106. prompts: [
  107. {
  108. type: "select",
  109. key: "deploymentType",
  110. message: "Select GitHub deployment type",
  111. options: [
  112. {
  113. label: "GitHub.com",
  114. value: "github.com",
  115. hint: "Public",
  116. },
  117. {
  118. label: "GitHub Enterprise",
  119. value: "enterprise",
  120. hint: "Data residency or self-hosted",
  121. },
  122. ],
  123. },
  124. {
  125. type: "text",
  126. key: "enterpriseUrl",
  127. message: "Enter your GitHub Enterprise URL or domain",
  128. placeholder: "company.ghe.com or https://company.ghe.com",
  129. condition: (inputs) => inputs.deploymentType === "enterprise",
  130. validate: (value) => {
  131. if (!value) return "URL or domain is required"
  132. try {
  133. const url = value.includes("://") ? new URL(value) : new URL(`https://${value}`)
  134. if (!url.hostname) return "Please enter a valid URL or domain"
  135. return undefined
  136. } catch {
  137. return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)"
  138. }
  139. },
  140. },
  141. ],
  142. async authorize(inputs = {}) {
  143. const deploymentType = inputs.deploymentType || "github.com"
  144. let domain = "github.com"
  145. let actualProvider = "github-copilot"
  146. if (deploymentType === "enterprise") {
  147. const enterpriseUrl = inputs.enterpriseUrl
  148. domain = normalizeDomain(enterpriseUrl!)
  149. actualProvider = "github-copilot-enterprise"
  150. }
  151. const urls = getUrls(domain)
  152. const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
  153. method: "POST",
  154. headers: {
  155. Accept: "application/json",
  156. "Content-Type": "application/json",
  157. "User-Agent": `opencode/${Installation.VERSION}`,
  158. },
  159. body: JSON.stringify({
  160. client_id: CLIENT_ID,
  161. scope: "read:user",
  162. }),
  163. })
  164. if (!deviceResponse.ok) {
  165. throw new Error("Failed to initiate device authorization")
  166. }
  167. const deviceData = (await deviceResponse.json()) as {
  168. verification_uri: string
  169. user_code: string
  170. device_code: string
  171. interval: number
  172. }
  173. return {
  174. url: deviceData.verification_uri,
  175. instructions: `Enter code: ${deviceData.user_code}`,
  176. method: "auto" as const,
  177. async callback() {
  178. while (true) {
  179. const response = await fetch(urls.ACCESS_TOKEN_URL, {
  180. method: "POST",
  181. headers: {
  182. Accept: "application/json",
  183. "Content-Type": "application/json",
  184. "User-Agent": `opencode/${Installation.VERSION}`,
  185. },
  186. body: JSON.stringify({
  187. client_id: CLIENT_ID,
  188. device_code: deviceData.device_code,
  189. grant_type: "urn:ietf:params:oauth:grant-type:device_code",
  190. }),
  191. })
  192. if (!response.ok) return { type: "failed" as const }
  193. const data = (await response.json()) as {
  194. access_token?: string
  195. error?: string
  196. interval?: number
  197. }
  198. if (data.access_token) {
  199. const result: {
  200. type: "success"
  201. refresh: string
  202. access: string
  203. expires: number
  204. provider?: string
  205. enterpriseUrl?: string
  206. } = {
  207. type: "success",
  208. refresh: data.access_token,
  209. access: data.access_token,
  210. expires: 0,
  211. }
  212. if (actualProvider === "github-copilot-enterprise") {
  213. result.provider = "github-copilot-enterprise"
  214. result.enterpriseUrl = domain
  215. }
  216. return result
  217. }
  218. if (data.error === "authorization_pending") {
  219. await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
  220. continue
  221. }
  222. if (data.error === "slow_down") {
  223. // Based on the RFC spec, we must add 5 seconds to our current polling interval.
  224. // (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5)
  225. let newInterval = (deviceData.interval + 5) * 1000
  226. // GitHub OAuth API may return the new interval in seconds in the response.
  227. // We should try to use that if provided with safety margin.
  228. const serverInterval = data.interval
  229. if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) {
  230. newInterval = serverInterval * 1000
  231. }
  232. await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
  233. continue
  234. }
  235. if (data.error) return { type: "failed" as const }
  236. await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
  237. continue
  238. }
  239. },
  240. }
  241. },
  242. },
  243. ],
  244. },
  245. "chat.headers": async (input, output) => {
  246. if (!input.model.providerID.includes("github-copilot")) return
  247. if (input.model.api.npm === "@ai-sdk/anthropic") {
  248. output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
  249. }
  250. const session = await sdk.session
  251. .get({
  252. path: {
  253. id: input.sessionID,
  254. },
  255. throwOnError: true,
  256. })
  257. .catch(() => undefined)
  258. if (!session || !session.data.parentID) return
  259. // mark subagent sessions as agent initiated matching standard that other copilot tools have
  260. output.headers["x-initiator"] = "agent"
  261. },
  262. }
  263. }