copilot.ts 9.4 KB

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