provider-gitlab.test.ts 13 KB

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