provider-github-copilot.test.ts 12 KB

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