provider-github-copilot.test.ts 12 KB

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