provider-gitlab.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import { describe, expect, mock } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { AccountV2 } from "@opencode-ai/core/account"
  4. import { Catalog } from "@opencode-ai/core/catalog"
  5. import { EventV2 } from "@opencode-ai/core/event"
  6. import { Location } from "@opencode-ai/core/location"
  7. import { PluginV2 } from "@opencode-ai/core/plugin"
  8. import { AccountPlugin } from "@opencode-ai/core/plugin/account"
  9. import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
  10. import { ProviderV2 } from "@opencode-ai/core/provider"
  11. import { testEffect } from "../lib/effect"
  12. import { it, model, npmLayer, withEnv } from "./provider-helper"
  13. const gitlabSDKOptions: Record<string, unknown>[] = []
  14. void mock.module("gitlab-ai-provider", () => ({
  15. VERSION: "test-version",
  16. createGitLab: (options: Record<string, unknown>) => {
  17. gitlabSDKOptions.push(options)
  18. return {
  19. agenticChat: (id: string, options: unknown) => ({ id, options, type: "agentic" }),
  20. workflowChat: (id: string, options: unknown) => ({ id, options, type: "workflow" }),
  21. }
  22. },
  23. discoverWorkflowModels: async () => ({ models: [], project: undefined }),
  24. isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact",
  25. }))
  26. const itWithAccount = testEffect(
  27. Catalog.layer.pipe(
  28. Layer.provideMerge(PluginV2.defaultLayer),
  29. Layer.provideMerge(AccountV2.defaultLayer),
  30. Layer.provideMerge(EventV2.defaultLayer),
  31. Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
  32. Layer.provideMerge(npmLayer),
  33. ),
  34. )
  35. describe("GitLabPlugin", () => {
  36. it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
  37. withEnv(
  38. {
  39. GITLAB_INSTANCE_URL: undefined,
  40. GITLAB_TOKEN: "env-token",
  41. },
  42. () =>
  43. Effect.gen(function* () {
  44. gitlabSDKOptions.length = 0
  45. const plugin = yield* PluginV2.Service
  46. yield* plugin.add(GitLabPlugin)
  47. yield* plugin.trigger(
  48. "aisdk.sdk",
  49. { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
  50. {},
  51. )
  52. expect(gitlabSDKOptions).toHaveLength(1)
  53. expect(gitlabSDKOptions[0].instanceUrl).toBe("https://gitlab.com")
  54. expect(gitlabSDKOptions[0].apiKey).toBe("env-token")
  55. expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({
  56. "anthropic-beta": "context-1m-2025-08-07",
  57. })
  58. expect(String((gitlabSDKOptions[0].aiGatewayHeaders as Record<string, string>)["User-Agent"])).toContain(
  59. "gitlab-ai-provider/test-version",
  60. )
  61. expect(gitlabSDKOptions[0].featureFlags).toEqual({
  62. duo_agent_platform_agentic_chat: true,
  63. duo_agent_platform: true,
  64. })
  65. }),
  66. ),
  67. )
  68. it.effect("uses GITLAB_INSTANCE_URL when instanceUrl is not configured", () =>
  69. withEnv(
  70. {
  71. GITLAB_INSTANCE_URL: "https://env.gitlab.example",
  72. GITLAB_TOKEN: undefined,
  73. },
  74. () =>
  75. Effect.gen(function* () {
  76. gitlabSDKOptions.length = 0
  77. const plugin = yield* PluginV2.Service
  78. yield* plugin.add(GitLabPlugin)
  79. yield* plugin.trigger(
  80. "aisdk.sdk",
  81. { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
  82. {},
  83. )
  84. expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example")
  85. }),
  86. ),
  87. )
  88. it.effect("keeps configured instance URL, apiKey, aiGatewayHeaders, and featureFlags over env/defaults", () =>
  89. withEnv(
  90. {
  91. GITLAB_INSTANCE_URL: "https://env.gitlab.example",
  92. GITLAB_TOKEN: "env-token",
  93. },
  94. () =>
  95. Effect.gen(function* () {
  96. gitlabSDKOptions.length = 0
  97. const plugin = yield* PluginV2.Service
  98. yield* plugin.add(GitLabPlugin)
  99. yield* plugin.trigger(
  100. "aisdk.sdk",
  101. {
  102. model: model("gitlab", "claude"),
  103. package: "gitlab-ai-provider",
  104. options: {
  105. name: "gitlab",
  106. instanceUrl: "https://configured.gitlab.example",
  107. apiKey: "configured-token",
  108. aiGatewayHeaders: {
  109. "anthropic-beta": "configured-beta",
  110. "x-gitlab-test": "1",
  111. },
  112. featureFlags: {
  113. duo_agent_platform: false,
  114. custom_flag: true,
  115. },
  116. },
  117. },
  118. {},
  119. )
  120. expect(gitlabSDKOptions[0].instanceUrl).toBe("https://configured.gitlab.example")
  121. expect(gitlabSDKOptions[0].apiKey).toBe("configured-token")
  122. expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({
  123. "anthropic-beta": "configured-beta",
  124. "x-gitlab-test": "1",
  125. })
  126. expect(gitlabSDKOptions[0].featureFlags).toEqual({
  127. duo_agent_platform_agentic_chat: true,
  128. duo_agent_platform: false,
  129. custom_flag: true,
  130. })
  131. }),
  132. ),
  133. )
  134. it.effect("ignores non-GitLab SDK packages", () =>
  135. Effect.gen(function* () {
  136. gitlabSDKOptions.length = 0
  137. const plugin = yield* PluginV2.Service
  138. yield* plugin.add(GitLabPlugin)
  139. const result = yield* plugin.trigger(
  140. "aisdk.sdk",
  141. { model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } },
  142. {},
  143. )
  144. expect(result.sdk).toBeUndefined()
  145. expect(gitlabSDKOptions).toHaveLength(0)
  146. }),
  147. )
  148. itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () =>
  149. withEnv(
  150. {
  151. GITLAB_TOKEN: "env-token",
  152. },
  153. () =>
  154. Effect.gen(function* () {
  155. gitlabSDKOptions.length = 0
  156. const plugin = yield* PluginV2.Service
  157. const accounts = yield* AccountV2.Service
  158. const catalog = yield* Catalog.Service
  159. const events = yield* EventV2.Service
  160. yield* accounts.create({
  161. serviceID: AccountV2.ServiceID.make("gitlab"),
  162. credential: new AccountV2.ApiKeyCredential({ type: "api", key: "account-token" }),
  163. })
  164. yield* plugin.add({
  165. ...AccountPlugin,
  166. effect: AccountPlugin.effect.pipe(
  167. Effect.provideService(AccountV2.Service, accounts),
  168. Effect.provideService(Catalog.Service, catalog),
  169. Effect.provideService(EventV2.Service, events),
  170. Effect.provideService(PluginV2.Service, plugin),
  171. ),
  172. })
  173. yield* plugin.add(GitLabPlugin)
  174. const load = yield* catalog.loader()
  175. yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
  176. const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
  177. yield* plugin.trigger(
  178. "aisdk.sdk",
  179. {
  180. model: model("gitlab", "claude"),
  181. package: "gitlab-ai-provider",
  182. options: provider.options.aisdk.provider,
  183. },
  184. {},
  185. )
  186. expect(gitlabSDKOptions[0].apiKey).toBe("account-token")
  187. }),
  188. ),
  189. )
  190. itWithAccount.effect("uses active account OAuth access token when no API token exists", () =>
  191. withEnv(
  192. {
  193. GITLAB_TOKEN: undefined,
  194. },
  195. () =>
  196. Effect.gen(function* () {
  197. gitlabSDKOptions.length = 0
  198. const plugin = yield* PluginV2.Service
  199. const accounts = yield* AccountV2.Service
  200. const catalog = yield* Catalog.Service
  201. const events = yield* EventV2.Service
  202. yield* accounts.create({
  203. serviceID: AccountV2.ServiceID.make("gitlab"),
  204. credential: new AccountV2.OAuthCredential({
  205. type: "oauth",
  206. refresh: "refresh-token",
  207. access: "account-oauth-token",
  208. expires: 9999999999999,
  209. }),
  210. })
  211. yield* plugin.add({
  212. ...AccountPlugin,
  213. effect: AccountPlugin.effect.pipe(
  214. Effect.provideService(AccountV2.Service, accounts),
  215. Effect.provideService(Catalog.Service, catalog),
  216. Effect.provideService(EventV2.Service, events),
  217. Effect.provideService(PluginV2.Service, plugin),
  218. ),
  219. })
  220. yield* plugin.add(GitLabPlugin)
  221. const load = yield* catalog.loader()
  222. yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
  223. const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
  224. yield* plugin.trigger(
  225. "aisdk.sdk",
  226. {
  227. model: model("gitlab", "claude"),
  228. package: "gitlab-ai-provider",
  229. options: provider.options.aisdk.provider,
  230. },
  231. {},
  232. )
  233. expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token")
  234. }),
  235. ),
  236. )
  237. it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () =>
  238. Effect.gen(function* () {
  239. const plugin = yield* PluginV2.Service
  240. const calls: [string, unknown][] = []
  241. yield* plugin.add(GitLabPlugin)
  242. const result = yield* plugin.trigger(
  243. "aisdk.language",
  244. {
  245. model: model("gitlab", "duo-workflow-custom", {
  246. options: {
  247. headers: {},
  248. body: {},
  249. aisdk: { provider: {}, request: { workflowRef: "ref", workflowDefinition: "definition" } },
  250. },
  251. }),
  252. sdk: {
  253. workflowChat: (id: string, options: unknown) => {
  254. calls.push([id, options])
  255. return { id, options }
  256. },
  257. agenticChat: () => undefined,
  258. },
  259. options: { featureFlags: { configured: true } },
  260. },
  261. {},
  262. )
  263. expect(calls).toEqual([
  264. ["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: "definition" }],
  265. ])
  266. expect(result.language as unknown).toEqual({
  267. id: "duo-workflow",
  268. options: calls[0]?.[1],
  269. selectedModelRef: "ref",
  270. })
  271. }),
  272. )
  273. it.effect("uses exact static workflow model ids when the provider recognizes them", () =>
  274. Effect.gen(function* () {
  275. const plugin = yield* PluginV2.Service
  276. const calls: [string, unknown][] = []
  277. yield* plugin.add(GitLabPlugin)
  278. const result = yield* plugin.trigger(
  279. "aisdk.language",
  280. {
  281. model: model("gitlab", "duo-workflow-exact"),
  282. sdk: {
  283. workflowChat: (id: string, options: unknown) => {
  284. calls.push([id, options])
  285. return { id, options }
  286. },
  287. agenticChat: () => undefined,
  288. },
  289. options: { featureFlags: { configured: true } },
  290. },
  291. {},
  292. )
  293. expect(calls).toEqual([
  294. ["duo-workflow-exact", { featureFlags: { configured: true }, workflowDefinition: undefined }],
  295. ])
  296. expect(result.language as unknown).toEqual({ id: "duo-workflow-exact", options: calls[0]?.[1] })
  297. }),
  298. )
  299. it.effect("uses provider feature flags instead of request feature flags", () =>
  300. Effect.gen(function* () {
  301. const plugin = yield* PluginV2.Service
  302. const calls: [string, unknown][] = []
  303. yield* plugin.add(GitLabPlugin)
  304. yield* plugin.trigger(
  305. "aisdk.language",
  306. {
  307. model: model("gitlab", "duo-workflow-custom", {
  308. options: {
  309. headers: {},
  310. body: {},
  311. aisdk: { provider: {}, request: { featureFlags: { request_flag: true } } },
  312. },
  313. }),
  314. sdk: {
  315. workflowChat: (id: string, options: unknown) => {
  316. calls.push([id, options])
  317. return { id, options }
  318. },
  319. agenticChat: () => undefined,
  320. },
  321. options: { featureFlags: { configured: true } },
  322. },
  323. {},
  324. )
  325. expect(calls).toEqual([["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: undefined }]])
  326. }),
  327. )
  328. it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () =>
  329. Effect.gen(function* () {
  330. const plugin = yield* PluginV2.Service
  331. const calls: [string, unknown][] = []
  332. yield* plugin.add(GitLabPlugin)
  333. yield* plugin.trigger(
  334. "aisdk.language",
  335. {
  336. model: model("gitlab", "claude", {
  337. options: { headers: { h: "v" }, body: {}, aisdk: { provider: {}, request: {} } },
  338. }),
  339. sdk: {
  340. workflowChat: () => undefined,
  341. agenticChat: (id: string, options: unknown) => {
  342. const selected = options as {
  343. aiGatewayHeaders?: Record<string, string>
  344. featureFlags?: Record<string, boolean>
  345. }
  346. calls.push([
  347. id,
  348. { aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } },
  349. ])
  350. },
  351. },
  352. options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } },
  353. },
  354. {},
  355. )
  356. expect(calls).toEqual([
  357. ["claude", { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }],
  358. ])
  359. }),
  360. )
  361. })