provider-github-copilot.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. import { AISDK } from "@opencode-ai/core/aisdk"
  2. import { App } from "@opencode-ai/core/app"
  3. import { Agent } from "@opencode-ai/schema/agent"
  4. import { Session } from "@opencode-ai/schema/session"
  5. import { describe, expect, test } from "bun:test"
  6. import { Effect } from "effect"
  7. import { Catalog } from "@opencode-ai/core/catalog"
  8. import { Model } from "@opencode-ai/core/model"
  9. import { Plugin } from "@opencode-ai/core/plugin"
  10. import { PluginHost } from "@opencode-ai/core/plugin/host"
  11. import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
  12. import { copilotBaseURL, copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
  13. import { Provider } from "@opencode-ai/core/provider"
  14. import { Integration } from "@opencode-ai/core/integration"
  15. import type { LanguageModelV3 } from "@ai-sdk/provider"
  16. import { testEffect } from "../lib/effect"
  17. import { PluginTestLayer } from "./fixture"
  18. const it = testEffect(PluginTestLayer)
  19. const addPlugin = Effect.fn(function* () {
  20. const plugin = yield* Plugin.Service
  21. const aisdk = yield* AISDK.Service
  22. const host = yield* PluginHost.make(plugin)
  23. yield* GithubCopilotPlugin.effect(host)
  24. })
  25. function required<T>(value: T | undefined): T {
  26. if (value === undefined) throw new Error("Expected value")
  27. return value
  28. }
  29. function fakeSelectorSdk(calls: string[]) {
  30. const make = (method: string) => (id: string) => {
  31. calls.push(`${method}:${id}`)
  32. return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
  33. }
  34. return {
  35. responses: make("responses"),
  36. messages: make("messages"),
  37. chat: make("chat"),
  38. languageModel: make("languageModel"),
  39. }
  40. }
  41. describe("GithubCopilotPlugin", () => {
  42. test("prefers the account-specific Copilot API endpoint", () => {
  43. expect(
  44. copilotBaseURL({
  45. enterpriseUrl: "company.ghe.com",
  46. apiEndpoint: "https://api.business.githubcopilot.com",
  47. }),
  48. ).toBe("https://api.business.githubcopilot.com")
  49. })
  50. it.effect("registers GitHub Copilot device OAuth", () =>
  51. Effect.gen(function* () {
  52. yield* addPlugin()
  53. expect((yield* (yield* Integration.Service).get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({
  54. id: Integration.MethodID.make("device"),
  55. type: "oauth",
  56. label: "Login with GitHub Copilot",
  57. form: expect.any(Array),
  58. })
  59. }),
  60. )
  61. it.effect("removes the generic key method", () =>
  62. Effect.gen(function* () {
  63. const integrations = yield* Integration.Service
  64. yield* integrations.transform((draft) => {
  65. draft.method.update({
  66. integrationID: Integration.ID.make("github-copilot"),
  67. method: { type: "key" },
  68. })
  69. draft.method.update({
  70. integrationID: Integration.ID.make("github-copilot"),
  71. method: { type: "env", names: ["GITHUB_TOKEN"] },
  72. })
  73. })
  74. yield* addPlugin()
  75. expect((yield* integrations.get(Integration.ID.make("github-copilot")))?.methods).toEqual([
  76. { type: "env", names: ["GITHUB_TOKEN"] },
  77. {
  78. id: Integration.MethodID.make("device"),
  79. type: "oauth",
  80. label: "Login with GitHub Copilot",
  81. form: expect.any(Array),
  82. },
  83. ])
  84. }),
  85. )
  86. it.live("adds Copilot authentication and request metadata headers", () =>
  87. Effect.gen(function* () {
  88. const requests: Headers[] = []
  89. const send = copilotFetch(
  90. "token",
  91. async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
  92. requests.push(new Headers(init?.headers))
  93. return Response.json({ ok: true })
  94. },
  95. App.make({ name: "test", version: "1.2.3", channel: "beta" }),
  96. )
  97. yield* Effect.promise(() =>
  98. send("https://api.githubcopilot.com/chat/completions", {
  99. method: "POST",
  100. headers: { "x-api-key": "old" },
  101. body: JSON.stringify({
  102. messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png" } }] }],
  103. }),
  104. }),
  105. )
  106. expect(requests[0]?.get("authorization")).toBe("Bearer token")
  107. expect(requests[0]?.has("x-api-key")).toBe(false)
  108. expect(requests[0]?.get("x-initiator")).toBe("user")
  109. expect(requests[0]?.get("copilot-vision-request")).toBe("true")
  110. expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
  111. expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test")
  112. }),
  113. )
  114. it.effect("adds Copilot authentication to native Anthropic requests", () =>
  115. Effect.gen(function* () {
  116. yield* addPlugin()
  117. const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
  118. sessionID: Session.ID.make("ses_test"),
  119. agent: Agent.ID.make("build"),
  120. model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("claude-sonnet-4.5") }),
  121. request: new Request("https://api.githubcopilot.com/v1/messages", {
  122. method: "POST",
  123. headers: { "Content-Type": "application/json", "x-api-key": "token" },
  124. body: JSON.stringify({ messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }] }),
  125. }),
  126. })
  127. expect(event.request.headers.get("authorization")).toBe("Bearer token")
  128. expect(event.request.headers.has("x-api-key")).toBe(false)
  129. expect(event.request.headers.get("x-initiator")).toBe("user")
  130. expect(event.request.headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14")
  131. expect(event.request.headers.get("x-github-api-version")).toBe("2026-06-01")
  132. }),
  133. )
  134. it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
  135. Effect.gen(function* () {
  136. const plugin = yield* Plugin.Service
  137. const aisdk = yield* AISDK.Service
  138. yield* addPlugin()
  139. const ignored = yield* aisdk.runSDK({
  140. model: Model.Info.make({
  141. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5")),
  142. modelID: Model.ID.make("gpt-5"),
  143. package: "aisdk:test-provider",
  144. }),
  145. package: "@ai-sdk/openai-compatible",
  146. options: { name: "github-copilot" },
  147. })
  148. const result = yield* aisdk.runSDK({
  149. model: Model.Info.make({
  150. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5")),
  151. modelID: Model.ID.make("gpt-5"),
  152. package: "aisdk:test-provider",
  153. }),
  154. package: "@ai-sdk/github-copilot",
  155. options: { name: "github-copilot" },
  156. })
  157. expect(ignored.sdk).toBeUndefined()
  158. expect(result.sdk).toBeDefined()
  159. }),
  160. )
  161. it.effect("selects languageModel when responses and chat are absent", () =>
  162. Effect.gen(function* () {
  163. const plugin = yield* Plugin.Service
  164. const aisdk = yield* AISDK.Service
  165. const calls: string[] = []
  166. yield* addPlugin()
  167. yield* aisdk.runLanguage({
  168. model: Model.Info.make({
  169. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("claude-sonnet-4")),
  170. modelID: Model.ID.make("claude-sonnet-4"),
  171. package: "aisdk:test-provider",
  172. }),
  173. sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
  174. options: {},
  175. })
  176. expect(calls).toEqual(["languageModel:claude-sonnet-4"])
  177. }),
  178. )
  179. it.effect("selects languageModel with the API model ID when responses and chat are absent", () =>
  180. Effect.gen(function* () {
  181. const plugin = yield* Plugin.Service
  182. const aisdk = yield* AISDK.Service
  183. const calls: string[] = []
  184. yield* addPlugin()
  185. yield* aisdk.runLanguage({
  186. model: Model.Info.make({
  187. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("alias")),
  188. modelID: Model.ID.make("claude-sonnet-4"),
  189. package: "aisdk:test-provider",
  190. }),
  191. sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
  192. options: {},
  193. })
  194. expect(calls).toEqual(["languageModel:claude-sonnet-4"])
  195. }),
  196. )
  197. it.effect("uses responses for gpt-5 models except gpt-5-mini", () =>
  198. Effect.gen(function* () {
  199. const plugin = yield* Plugin.Service
  200. const aisdk = yield* AISDK.Service
  201. const calls: string[] = []
  202. yield* addPlugin()
  203. yield* aisdk.runLanguage({
  204. model: Model.Info.make({
  205. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5")),
  206. modelID: Model.ID.make("gpt-5"),
  207. package: "aisdk:test-provider",
  208. }),
  209. sdk: fakeSelectorSdk(calls),
  210. options: {},
  211. })
  212. yield* aisdk.runLanguage({
  213. model: Model.Info.make({
  214. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5.1-codex")),
  215. modelID: Model.ID.make("gpt-5.1-codex"),
  216. package: "aisdk:test-provider",
  217. }),
  218. sdk: fakeSelectorSdk(calls),
  219. options: {},
  220. })
  221. yield* aisdk.runLanguage({
  222. model: Model.Info.make({
  223. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-4o")),
  224. modelID: Model.ID.make("gpt-4o"),
  225. package: "aisdk:test-provider",
  226. }),
  227. sdk: fakeSelectorSdk(calls),
  228. options: {},
  229. })
  230. yield* aisdk.runLanguage({
  231. model: Model.Info.make({
  232. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5-mini")),
  233. modelID: Model.ID.make("gpt-5-mini"),
  234. package: "aisdk:test-provider",
  235. }),
  236. sdk: fakeSelectorSdk(calls),
  237. options: {},
  238. })
  239. yield* aisdk.runLanguage({
  240. model: Model.Info.make({
  241. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5-mini-2025-08-07")),
  242. modelID: Model.ID.make("gpt-5-mini-2025-08-07"),
  243. package: "aisdk:test-provider",
  244. }),
  245. sdk: fakeSelectorSdk(calls),
  246. options: {},
  247. })
  248. expect(calls).toEqual([
  249. "responses:gpt-5",
  250. "responses:gpt-5.1-codex",
  251. "chat:gpt-4o",
  252. "chat:gpt-5-mini",
  253. "chat:gpt-5-mini-2025-08-07",
  254. ])
  255. }),
  256. )
  257. it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
  258. Effect.gen(function* () {
  259. const plugin = yield* Plugin.Service
  260. const aisdk = yield* AISDK.Service
  261. const calls: string[] = []
  262. yield* addPlugin()
  263. yield* aisdk.runLanguage({
  264. model: Model.Info.make({
  265. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("mai-code-1-flash-picker")),
  266. modelID: Model.ID.make("mai-code-1-flash-picker"),
  267. package: "aisdk:test-provider",
  268. settings: { endpoint: "responses" },
  269. }),
  270. sdk: fakeSelectorSdk(calls),
  271. options: { endpoint: "responses" },
  272. })
  273. yield* aisdk.runLanguage({
  274. model: Model.Info.make({
  275. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5")),
  276. modelID: Model.ID.make("gpt-5"),
  277. package: "aisdk:test-provider",
  278. settings: { endpoint: "chat" },
  279. }),
  280. sdk: fakeSelectorSdk(calls),
  281. options: { endpoint: "chat" },
  282. })
  283. expect(calls).toEqual(["responses:mai-code-1-flash-picker", "chat:gpt-5"])
  284. }),
  285. )
  286. it.effect("uses the API model ID when selecting responses or chat", () =>
  287. Effect.gen(function* () {
  288. const plugin = yield* Plugin.Service
  289. const aisdk = yield* AISDK.Service
  290. const calls: string[] = []
  291. yield* addPlugin()
  292. yield* aisdk.runLanguage({
  293. model: Model.Info.make({
  294. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("default")),
  295. modelID: Model.ID.make("gpt-5"),
  296. package: "aisdk:test-provider",
  297. }),
  298. sdk: fakeSelectorSdk(calls),
  299. options: {},
  300. })
  301. yield* aisdk.runLanguage({
  302. model: Model.Info.make({
  303. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("small")),
  304. modelID: Model.ID.make("gpt-5-mini"),
  305. package: "aisdk:test-provider",
  306. }),
  307. sdk: fakeSelectorSdk(calls),
  308. options: {},
  309. })
  310. yield* aisdk.runLanguage({
  311. model: Model.Info.make({
  312. ...Model.Info.default(Provider.ID.make("github-copilot"), Model.ID.make("sonnet")),
  313. modelID: Model.ID.make("claude-sonnet-4"),
  314. package: "aisdk:test-provider",
  315. }),
  316. sdk: fakeSelectorSdk(calls),
  317. options: {},
  318. })
  319. expect(calls).toEqual(["responses:gpt-5", "chat:gpt-5-mini", "chat:claude-sonnet-4"])
  320. }),
  321. )
  322. it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
  323. Effect.gen(function* () {
  324. const catalog = yield* Catalog.Service
  325. yield* catalog.transform((catalog) => {
  326. catalog.provider.update(Provider.ID.make("github-copilot"), () => {})
  327. catalog.model.update(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5-chat-latest"), () => {})
  328. })
  329. yield* addPlugin()
  330. expect(
  331. required(yield* catalog.model.get(Provider.ID.make("github-copilot"), Model.ID.make("gpt-5-chat-latest")))
  332. .enabled,
  333. ).toBe(false)
  334. }),
  335. )
  336. it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
  337. Effect.gen(function* () {
  338. const catalog = yield* Catalog.Service
  339. yield* catalog.transform((catalog) => {
  340. catalog.provider.update(Provider.ID.make("custom-copilot"), () => {})
  341. catalog.model.update(Provider.ID.make("custom-copilot"), Model.ID.make("gpt-5-chat-latest"), () => {})
  342. })
  343. yield* addPlugin()
  344. expect(
  345. required(yield* catalog.model.get(Provider.ID.make("custom-copilot"), Model.ID.make("gpt-5-chat-latest")))
  346. .enabled,
  347. ).toBe(true)
  348. }),
  349. )
  350. it.effect("ignores non-Copilot providers", () =>
  351. Effect.gen(function* () {
  352. const plugin = yield* Plugin.Service
  353. const aisdk = yield* AISDK.Service
  354. const calls: string[] = []
  355. yield* addPlugin()
  356. const result = yield* aisdk.runLanguage({
  357. model: Model.Info.make({
  358. ...Model.Info.default(Provider.ID.make("openai"), Model.ID.make("gpt-5")),
  359. modelID: Model.ID.make("gpt-5"),
  360. package: "aisdk:test-provider",
  361. }),
  362. sdk: fakeSelectorSdk(calls),
  363. options: {},
  364. })
  365. expect(calls).toEqual([])
  366. expect(result.language).toBeUndefined()
  367. }),
  368. )
  369. })